chore: import upstream snapshot with attribution
CI / Run CI (push) Has been cancelled
CI / check-backend (push) Has been cancelled
CI / check-frontend (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
CI / Run CI (push) Has been cancelled
CI / check-backend (push) Has been cancelled
CI / check-frontend (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../stories/**/*.mdx',
|
||||
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions'
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {}
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag'
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Preview } from '@storybook/react';
|
||||
|
||||
import '../src/index.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@chainlit/app/src/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@chainlit/app/src/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import { type IStep } from '@chainlit/react-client';
|
||||
|
||||
// @ts-expect-error inline css
|
||||
import sonnercss from './sonner.css?inline';
|
||||
// @ts-expect-error inline css
|
||||
import tailwindcss from './src/index.css?inline';
|
||||
// @ts-expect-error inline css
|
||||
import hljscss from 'highlight.js/styles/monokai-sublime.css?inline';
|
||||
|
||||
import AppWrapper from './src/appWrapper';
|
||||
import {
|
||||
clearChainlitCopilotThreadId,
|
||||
getChainlitCopilotThreadId
|
||||
} from './src/state';
|
||||
import { IWidgetConfig } from './src/types';
|
||||
|
||||
const id = 'chainlit-copilot';
|
||||
let root: ReactDOM.Root | null = null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cl_shadowRootElement: HTMLDivElement;
|
||||
theme?: {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
mountChainlitWidget: (config: IWidgetConfig) => void;
|
||||
unmountChainlitWidget: () => void;
|
||||
toggleChainlitCopilot: () => void;
|
||||
sendChainlitMessage: (message: IStep) => void;
|
||||
getChainlitCopilotThreadId: () => string | null;
|
||||
clearChainlitCopilotThreadId: (newThreadId?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.mountChainlitWidget = (config: IWidgetConfig) => {
|
||||
const container = document.createElement('div');
|
||||
container.id = id;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const shadowContainer = container.attachShadow({ mode: 'open' });
|
||||
const shadowRootElement = document.createElement('div');
|
||||
shadowRootElement.id = 'cl-shadow-root';
|
||||
shadowContainer.appendChild(shadowRootElement);
|
||||
|
||||
window.cl_shadowRootElement = shadowRootElement;
|
||||
|
||||
root = ReactDOM.createRoot(shadowRootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<style type="text/css">{tailwindcss.toString()}</style>
|
||||
<style type="text/css">{sonnercss.toString()}</style>
|
||||
<style type="text/css">{hljscss.toString()}</style>
|
||||
<AppWrapper widgetConfig={config} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
window.unmountChainlitWidget = () => {
|
||||
root?.unmount();
|
||||
};
|
||||
|
||||
window.sendChainlitMessage = () => {
|
||||
console.info('Copilot is not active. Please check if the widget is mounted.');
|
||||
};
|
||||
|
||||
window.getChainlitCopilotThreadId = getChainlitCopilotThreadId;
|
||||
window.clearChainlitCopilotThreadId = clearChainlitCopilotThreadId;
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@chainlit/copilot",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "echo 'SKIPPED: copilot type-check disabled due to cross-project path alias mismatch. See docs/research/copilot-type-checking.md'",
|
||||
"test": "echo no tests yet",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chainlit/app": "workspace:^",
|
||||
"@chainlit/react-client": "workspace:^",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.9.0",
|
||||
"i18next": "^23.7.16",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^14.0.0",
|
||||
"recoil": "^0.7.7",
|
||||
"sonner": "^1.2.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^8.4.7",
|
||||
"@storybook/addon-interactions": "^8.4.7",
|
||||
"@storybook/addon-links": "^8.4.7",
|
||||
"@storybook/addon-onboarding": "^1.0.11",
|
||||
"@storybook/blocks": "^8.4.7",
|
||||
"@storybook/react": "^8.4.7",
|
||||
"@storybook/react-vite": "^8.4.7",
|
||||
"@storybook/test": "^8.4.7",
|
||||
"@swc/core": "^1.3.86",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-file-icon": "^1.0.2",
|
||||
"@types/uuid": "^9.0.3",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"storybook": "^8.4.7",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite@>=4.4.0 <4.4.12": ">=4.4.12",
|
||||
"vite@>=4.0.0 <=4.5.1": ">=4.5.2",
|
||||
"express@<4.19.2": ">=4.19.2",
|
||||
"vite@>=4.0.0 <=4.5.2": ">=4.5.3",
|
||||
"tar@<6.2.1": ">=6.2.1",
|
||||
"braces@<3.0.3": ">=3.0.3",
|
||||
"ejs@<3.1.10": ">=3.1.10",
|
||||
"ws@>=8.0.0 <8.17.1": ">=8.17.1",
|
||||
"micromatch@<4.0.8": ">=4.0.8",
|
||||
"body-parser@<1.20.3": ">=1.20.3",
|
||||
"send@<0.19.0": ">=0.19.0",
|
||||
"serve-static@<1.16.0": ">=1.16.0",
|
||||
"express@<4.20.0": ">=4.20.0",
|
||||
"path-to-regexp@<0.1.10": ">=0.1.10",
|
||||
"vite@>=4.0.0 <4.5.4": ">=4.5.4",
|
||||
"vite@>=4.0.0 <=4.5.3": ">=4.5.4",
|
||||
"rollup@>=3.0.0 <3.29.5": ">=3.29.5",
|
||||
"cookie@<0.7.0": ">=0.7.0",
|
||||
"cross-spawn@>=7.0.0 <7.0.5": ">=7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4887
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,690 @@
|
||||
:where(html[dir='ltr']),
|
||||
:where([data-sonner-toaster][dir='ltr']) {
|
||||
--toast-icon-margin-start: -3px;
|
||||
--toast-icon-margin-end: 4px;
|
||||
--toast-svg-margin-start: -1px;
|
||||
--toast-svg-margin-end: 0px;
|
||||
--toast-button-margin-start: auto;
|
||||
--toast-button-margin-end: 0;
|
||||
--toast-close-button-start: 0;
|
||||
--toast-close-button-end: unset;
|
||||
--toast-close-button-transform: translate(-35%, -35%);
|
||||
}
|
||||
|
||||
:where(html[dir='rtl']),
|
||||
:where([data-sonner-toaster][dir='rtl']) {
|
||||
--toast-icon-margin-start: 4px;
|
||||
--toast-icon-margin-end: -3px;
|
||||
--toast-svg-margin-start: 0px;
|
||||
--toast-svg-margin-end: -1px;
|
||||
--toast-button-margin-start: 0;
|
||||
--toast-button-margin-end: auto;
|
||||
--toast-close-button-start: unset;
|
||||
--toast-close-button-end: 0;
|
||||
--toast-close-button-transform: translate(35%, -35%);
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster]) {
|
||||
position: fixed;
|
||||
width: var(--width);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
Apple Color Emoji,
|
||||
Segoe UI Emoji,
|
||||
Segoe UI Symbol,
|
||||
Noto Color Emoji;
|
||||
--gray1: hsl(0, 0%, 99%);
|
||||
--gray2: hsl(0, 0%, 97.3%);
|
||||
--gray3: hsl(0, 0%, 95.1%);
|
||||
--gray4: hsl(0, 0%, 93%);
|
||||
--gray5: hsl(0, 0%, 90.9%);
|
||||
--gray6: hsl(0, 0%, 88.7%);
|
||||
--gray7: hsl(0, 0%, 85.8%);
|
||||
--gray8: hsl(0, 0%, 78%);
|
||||
--gray9: hsl(0, 0%, 56.1%);
|
||||
--gray10: hsl(0, 0%, 52.3%);
|
||||
--gray11: hsl(0, 0%, 43.5%);
|
||||
--gray12: hsl(0, 0%, 9%);
|
||||
--border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
z-index: 999999999;
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='right']) {
|
||||
right: max(var(--offset), env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='left']) {
|
||||
left: max(var(--offset), env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='center']) {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-y-position='top']) {
|
||||
top: max(var(--offset), env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-y-position='bottom']) {
|
||||
bottom: max(var(--offset), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) {
|
||||
--y: translateY(100%);
|
||||
--lift-amount: calc(var(--lift) * var(--gap));
|
||||
z-index: var(--z-index);
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: var(--y);
|
||||
filter: blur(0);
|
||||
/* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */
|
||||
touch-action: none;
|
||||
transition:
|
||||
transform 400ms,
|
||||
opacity 400ms,
|
||||
height 400ms,
|
||||
box-shadow 200ms;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
padding: 16px;
|
||||
background: var(--normal-bg);
|
||||
border: 1px solid var(--normal-border);
|
||||
color: var(--normal-text);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
width: var(--width);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]:focus-visible) {
|
||||
box-shadow:
|
||||
0px 4px 12px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='top']) {
|
||||
top: 0;
|
||||
--y: translateY(-100%);
|
||||
--lift: 1;
|
||||
--lift-amount: calc(1 * var(--gap));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='bottom']) {
|
||||
bottom: 0;
|
||||
--y: translateY(100%);
|
||||
--lift: -1;
|
||||
--lift-amount: calc(var(--lift) * var(--gap));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-description]) {
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-title]) {
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
position: relative;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--toast-icon-margin-start);
|
||||
margin-right: var(--toast-icon-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-promise='true']) :where([data-icon]) > svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transform-origin: center;
|
||||
animation: sonner-fade-in 300ms ease forwards;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) svg {
|
||||
margin-left: var(--toast-svg-margin-start);
|
||||
margin-right: var(--toast-svg-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-content]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true'] [data-button] {
|
||||
border-radius: 4px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--normal-bg);
|
||||
background: var(--normal-text);
|
||||
margin-left: var(--toast-button-margin-start);
|
||||
margin-right: var(--toast-button-margin-end);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
opacity 400ms,
|
||||
box-shadow 200ms;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-button]):focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-button]):first-of-type {
|
||||
margin-left: var(--toast-button-margin-start);
|
||||
margin-right: var(--toast-button-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-cancel]) {
|
||||
color: var(--normal-text);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-theme='dark']) :where([data-cancel]) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-close-button]) {
|
||||
position: absolute;
|
||||
left: var(--toast-close-button-start);
|
||||
right: var(--toast-close-button-end);
|
||||
top: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
background: var(--gray1);
|
||||
color: var(--gray12);
|
||||
border: 1px solid var(--gray4);
|
||||
transform: var(--toast-close-button-transform);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
transition:
|
||||
opacity 100ms,
|
||||
background 200ms,
|
||||
border-color 200ms;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
|
||||
box-shadow:
|
||||
0px 4px 12px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-disabled='true']) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]):hover :where([data-close-button]):hover {
|
||||
background: var(--gray2);
|
||||
border-color: var(--gray5);
|
||||
}
|
||||
|
||||
/* Leave a ghost div to avoid setting hover to false when swiping out */
|
||||
:where([data-sonner-toast][data-swiping='true'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-y-position='top'][data-swiping='true']
|
||||
)::before {
|
||||
/* y 50% needed to distribute height additional height evenly */
|
||||
bottom: 50%;
|
||||
transform: scaleY(3) translateY(50%);
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-y-position='bottom'][data-swiping='true']
|
||||
)::before {
|
||||
/* y -50% needed to distribute height additional height evenly */
|
||||
top: 50%;
|
||||
transform: scaleY(3) translateY(-50%);
|
||||
}
|
||||
|
||||
/* Leave a ghost div to avoid setting hover to false when transitioning out */
|
||||
:where([data-sonner-toast][data-swiping='false'][data-removed='true'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: scaleY(2);
|
||||
}
|
||||
|
||||
/* Needed to avoid setting hover to false when inbetween toasts */
|
||||
:where([data-sonner-toast])::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: calc(var(--gap) + 1px);
|
||||
bottom: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-mounted='true']) {
|
||||
--y: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-expanded='false'][data-front='false']) {
|
||||
--scale: var(--toasts-before) * 0.05 + 1;
|
||||
--y: translateY(calc(var(--lift-amount) * var(--toasts-before)))
|
||||
scale(calc(-1 * var(--scale)));
|
||||
height: var(--front-toast-height);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) > * {
|
||||
transition: opacity 400ms;
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true']
|
||||
)
|
||||
> * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-visible='false']) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-mounted='true'][data-expanded='true']) {
|
||||
--y: translateY(calc(var(--lift) * var(--offset)));
|
||||
height: var(--initial-height);
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false']
|
||||
) {
|
||||
--y: translateY(calc(var(--lift) * -100%));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']
|
||||
) {
|
||||
--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']
|
||||
) {
|
||||
--y: translateY(40%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 500ms,
|
||||
opacity 200ms;
|
||||
}
|
||||
|
||||
/* Bump up the height to make sure hover state doesn't get set to false */
|
||||
:where([data-sonner-toast][data-removed='true'][data-front='false'])::before {
|
||||
height: calc(var(--initial-height) + 20%);
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-swiping='true'] {
|
||||
transform: var(--y) translateY(var(--swipe-amount, 0px));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
|
||||
[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {
|
||||
animation: swipe-out 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes swipe-out {
|
||||
from {
|
||||
transform: translateY(
|
||||
calc(var(--lift) * var(--offset) + var(--swipe-amount))
|
||||
);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(
|
||||
calc(
|
||||
var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%
|
||||
)
|
||||
);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
[data-sonner-toaster] {
|
||||
position: fixed;
|
||||
--mobile-offset: 16px;
|
||||
right: var(--mobile-offset);
|
||||
left: var(--mobile-offset);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: calc(100% - var(--mobile-offset) * 2);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-x-position='left'] {
|
||||
left: var(--mobile-offset);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-y-position='bottom'] {
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-y-position='top'] {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-x-position='center'] {
|
||||
left: var(--mobile-offset);
|
||||
right: var(--mobile-offset);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray4);
|
||||
--normal-text: var(--gray12);
|
||||
|
||||
--success-bg: hsl(143, 85%, 96%);
|
||||
--success-border: hsl(145, 92%, 91%);
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
|
||||
--info-bg: hsl(208, 100%, 97%);
|
||||
--info-border: hsl(221, 91%, 91%);
|
||||
--info-text: hsl(210, 92%, 45%);
|
||||
|
||||
--warning-bg: hsl(49, 100%, 97%);
|
||||
--warning-border: hsl(49, 91%, 91%);
|
||||
--warning-text: hsl(31, 92%, 45%);
|
||||
|
||||
--error-bg: hsl(359, 100%, 97%);
|
||||
--error-border: hsl(359, 100%, 94%);
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light']
|
||||
[data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark']
|
||||
[data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray3);
|
||||
--normal-text: var(--gray12);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
|
||||
--success-bg: hsl(150, 100%, 6%);
|
||||
--success-border: hsl(147, 100%, 12%);
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
|
||||
--info-bg: hsl(215, 100%, 6%);
|
||||
--info-border: hsl(223, 100%, 12%);
|
||||
--info-text: hsl(216, 87%, 65%);
|
||||
|
||||
--warning-bg: hsl(64, 100%, 6%);
|
||||
--warning-border: hsl(60, 100%, 12%);
|
||||
--warning-text: hsl(46, 87%, 65%);
|
||||
|
||||
--error-bg: hsl(358, 76%, 10%);
|
||||
--error-border: hsl(357, 89%, 16%);
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success']
|
||||
[data-close-button] {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='info'] {
|
||||
background: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='info']
|
||||
[data-close-button] {
|
||||
background: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='warning'] {
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='warning']
|
||||
[data-close-button] {
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='error'] {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='error']
|
||||
[data-close-button] {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.sonner-loading-wrapper {
|
||||
--size: 16px;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sonner-loading-wrapper[data-visible='false'] {
|
||||
transform-origin: center;
|
||||
animation: sonner-fade-out 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.sonner-spinner {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
.sonner-loading-bar {
|
||||
animation: sonner-spin 1.2s linear infinite;
|
||||
background: var(--gray11);
|
||||
border-radius: 6px;
|
||||
height: 8%;
|
||||
left: -10%;
|
||||
position: absolute;
|
||||
top: -3.9%;
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(1) {
|
||||
animation-delay: -1.2s;
|
||||
transform: rotate(0.0001deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
transform: rotate(30deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(3) {
|
||||
animation-delay: -1s;
|
||||
transform: rotate(60deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(4) {
|
||||
animation-delay: -0.9s;
|
||||
transform: rotate(90deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(5) {
|
||||
animation-delay: -0.8s;
|
||||
transform: rotate(120deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(6) {
|
||||
animation-delay: -0.7s;
|
||||
transform: rotate(150deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(7) {
|
||||
animation-delay: -0.6s;
|
||||
transform: rotate(180deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(8) {
|
||||
animation-delay: -0.5s;
|
||||
transform: rotate(210deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(9) {
|
||||
animation-delay: -0.4s;
|
||||
transform: rotate(240deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(10) {
|
||||
animation-delay: -0.3s;
|
||||
transform: rotate(270deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(11) {
|
||||
animation-delay: -0.2s;
|
||||
transform: rotate(300deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(12) {
|
||||
animation-delay: -0.1s;
|
||||
transform: rotate(330deg) translate(146%);
|
||||
}
|
||||
|
||||
@keyframes sonner-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonner-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonner-spin {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
[data-sonner-toast],
|
||||
[data-sonner-toast] > *,
|
||||
.sonner-loading-bar {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sonner-loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
opacity 200ms,
|
||||
transform 200ms;
|
||||
}
|
||||
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
function applyThemeVariables(variant: 'dark' | 'light') {
|
||||
if (!window.theme) return;
|
||||
|
||||
const variables = window.theme[variant];
|
||||
if (!variables) return;
|
||||
|
||||
const shadowContainer = window.cl_shadowRootElement;
|
||||
if (!shadowContainer) return;
|
||||
|
||||
// Apply new theme variables
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
shadowContainer.style.setProperty(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'vite-ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const shadowContainer = window.cl_shadowRootElement;
|
||||
if (!shadowContainer) return;
|
||||
|
||||
// Remove existing theme classes
|
||||
shadowContainer.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
shadowContainer.classList.add(systemTheme);
|
||||
applyThemeVariables(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
shadowContainer.classList.add(theme);
|
||||
applyThemeVariables(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
const shadowContainer = window.cl_shadowRootElement;
|
||||
if (!shadowContainer) return;
|
||||
|
||||
const newTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||
shadowContainer.classList.remove('light', 'dark');
|
||||
shadowContainer.classList.add(newTheme);
|
||||
applyThemeVariables(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const variant = context.theme === 'system' ? systemTheme : context.theme;
|
||||
|
||||
return { ...context, variant };
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ChainlitAPI, ClientError } from '@chainlit/react-client';
|
||||
|
||||
export function makeApiClient(
|
||||
chainlitServer: string,
|
||||
additionalQueryParams: Record<string, string>
|
||||
) {
|
||||
const httpEndpoint = chainlitServer;
|
||||
|
||||
const on401 = () => {
|
||||
toast.error('Unauthorized');
|
||||
};
|
||||
|
||||
const onError = (error: ClientError) => {
|
||||
toast.error(error.toString());
|
||||
};
|
||||
|
||||
return new ChainlitAPI(
|
||||
httpEndpoint,
|
||||
'copilot',
|
||||
additionalQueryParams,
|
||||
on401,
|
||||
onError
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { IWidgetConfig } from 'types';
|
||||
import Widget from 'widget';
|
||||
|
||||
import { useTranslation } from '@chainlit/app/src/components/i18n/Translator';
|
||||
import { ChainlitContext, useAuth } from '@chainlit/react-client';
|
||||
|
||||
import { useCopilotInteract } from './hooks/useCopilotInteract';
|
||||
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import {
|
||||
COPILOT_THREAD_CHANGED_EVENT_KEY,
|
||||
CopilotThreadChangedEventParams
|
||||
} from './state';
|
||||
|
||||
interface Props {
|
||||
widgetConfig: IWidgetConfig;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cl_shadowRootElement: HTMLDivElement;
|
||||
toggleChainlitCopilot: () => void;
|
||||
theme?: {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
getChainlitCopilotThreadId: () => string | null;
|
||||
clearChainlitCopilotThreadId: (newThreadId?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App({ widgetConfig }: Props) {
|
||||
const { isAuthenticated, data } = useAuth();
|
||||
const apiClient = useContext(ChainlitContext);
|
||||
const { i18n } = useTranslation();
|
||||
const { startNewChat } = useCopilotInteract();
|
||||
const languageInUse = widgetConfig.language || navigator.language || 'en-US';
|
||||
const [authError, setAuthError] = useState<string>();
|
||||
const [fetchError, setFetchError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get(`/project/translations?language=${languageInUse}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
i18n.addResourceBundle(languageInUse, 'translation', data.translation);
|
||||
i18n.changeLanguage(languageInUse);
|
||||
})
|
||||
.catch((err) => {
|
||||
setFetchError(String(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const defaultTheme = widgetConfig.theme || data?.default_theme;
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchError) return;
|
||||
if (!isAuthenticated) {
|
||||
if (!widgetConfig.accessToken) {
|
||||
setAuthError('No authentication token provided.');
|
||||
} else {
|
||||
apiClient
|
||||
.jwtAuth(widgetConfig.accessToken)
|
||||
.catch((err) => setAuthError(String(err)));
|
||||
}
|
||||
} else {
|
||||
setAuthError(undefined);
|
||||
}
|
||||
}, [isAuthenticated, apiClient, fetchError, setAuthError]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventListener = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<CopilotThreadChangedEventParams>;
|
||||
startNewChat(customEvent?.detail?.newThreadId);
|
||||
};
|
||||
|
||||
window.addEventListener(COPILOT_THREAD_CHANGED_EVENT_KEY, eventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
COPILOT_THREAD_CHANGED_EVENT_KEY,
|
||||
eventListener
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider storageKey="vite-ui-theme" defaultTheme={defaultTheme}>
|
||||
<Toaster className="toast" position="bottom-center" />
|
||||
<Widget config={widgetConfig} error={fetchError || authError} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { makeApiClient } from 'api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IWidgetConfig } from 'types';
|
||||
|
||||
import { i18nSetupLocalization } from '@chainlit/app/src/i18n';
|
||||
import { ChainlitContext } from '@chainlit/react-client';
|
||||
|
||||
import App from './app';
|
||||
|
||||
i18nSetupLocalization();
|
||||
interface Props {
|
||||
widgetConfig: IWidgetConfig;
|
||||
}
|
||||
|
||||
export default function AppWrapper({ widgetConfig }: Props) {
|
||||
const additionalQueryParams = widgetConfig?.additionalQueryParamsForAPI;
|
||||
const apiClient = makeApiClient(
|
||||
widgetConfig.chainlitServer,
|
||||
additionalQueryParams || {}
|
||||
);
|
||||
const [customThemeLoaded, setCustomThemeLoaded] = useState(false);
|
||||
|
||||
function completeInitialization() {
|
||||
if (widgetConfig.customCssUrl) {
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.rel = 'stylesheet';
|
||||
linkEl.href = widgetConfig.customCssUrl;
|
||||
window.cl_shadowRootElement.getRootNode().appendChild(linkEl);
|
||||
}
|
||||
setCustomThemeLoaded(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let fontLoaded = false;
|
||||
|
||||
apiClient
|
||||
.get('/public/theme.json')
|
||||
.then(async (res) => {
|
||||
try {
|
||||
const customTheme = await res.json();
|
||||
if (customTheme.custom_fonts?.length) {
|
||||
fontLoaded = true;
|
||||
customTheme.custom_fonts.forEach((href: string) => {
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.rel = 'stylesheet';
|
||||
linkEl.href = href;
|
||||
window.cl_shadowRootElement.getRootNode().appendChild(linkEl);
|
||||
});
|
||||
}
|
||||
if (customTheme.variables) {
|
||||
window.theme = customTheme.variables;
|
||||
}
|
||||
} finally {
|
||||
// If no custom font, default to Inter
|
||||
if (!fontLoaded) {
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.rel = 'stylesheet';
|
||||
linkEl.href =
|
||||
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap';
|
||||
window.cl_shadowRootElement.getRootNode().appendChild(linkEl);
|
||||
}
|
||||
completeInitialization();
|
||||
}
|
||||
})
|
||||
.catch(() => completeInitialization());
|
||||
}, []);
|
||||
|
||||
if (!customThemeLoaded) return null;
|
||||
|
||||
return (
|
||||
<ChainlitContext.Provider value={apiClient}>
|
||||
<RecoilRoot>
|
||||
<App widgetConfig={widgetConfig} />
|
||||
</RecoilRoot>
|
||||
</ChainlitContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { toast } from 'sonner';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Alert from '@chainlit/app/src/components/Alert';
|
||||
import ChatSettingsModal from '@chainlit/app/src/components/ChatSettings';
|
||||
import { ErrorBoundary } from '@chainlit/app/src/components/ErrorBoundary';
|
||||
import { TaskList } from '@chainlit/app/src/components/Tasklist';
|
||||
import ChatFooter from '@chainlit/app/src/components/chat/Footer';
|
||||
import MessagesContainer from '@chainlit/app/src/components/chat/MessagesContainer';
|
||||
import ScrollContainer from '@chainlit/app/src/components/chat/ScrollContainer';
|
||||
import Translator from '@chainlit/app/src/components/i18n/Translator';
|
||||
import { useLayoutMaxWidth } from '@chainlit/app/src/hooks/useLayoutMaxWidth';
|
||||
import { useUpload } from '@chainlit/app/src/hooks/useUpload';
|
||||
import { IAttachment, attachmentsState } from '@chainlit/app/src/state/chat';
|
||||
import {
|
||||
useChatData,
|
||||
useChatInteract,
|
||||
useConfig
|
||||
} from '@chainlit/react-client';
|
||||
|
||||
import WelcomeScreen from '@/components/WelcomeScreen';
|
||||
import ElementSideView from 'components/ElementSideView';
|
||||
|
||||
const Chat = () => {
|
||||
const { config } = useConfig();
|
||||
const layoutMaxWidth = useLayoutMaxWidth();
|
||||
const setAttachments = useSetRecoilState(attachmentsState);
|
||||
const autoScrollRef = useRef(true);
|
||||
const { error, disabled, callFn } = useChatData();
|
||||
const { uploadFile } = useChatInteract();
|
||||
const uploadFileRef = useRef(uploadFile);
|
||||
|
||||
const fileSpec = useMemo(
|
||||
() => ({
|
||||
max_size_mb:
|
||||
config?.features?.spontaneous_file_upload?.max_size_mb || 500,
|
||||
max_files: config?.features?.spontaneous_file_upload?.max_files || 20,
|
||||
accept: config?.features?.spontaneous_file_upload?.accept || ['*/*']
|
||||
}),
|
||||
[config]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (callFn) {
|
||||
const event = new CustomEvent('chainlit-call-fn', {
|
||||
detail: callFn
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}, [callFn]);
|
||||
|
||||
useEffect(() => {
|
||||
uploadFileRef.current = uploadFile;
|
||||
}, [uploadFile]);
|
||||
|
||||
const onFileUpload = useCallback(
|
||||
(payloads: File[]) => {
|
||||
const attachements: IAttachment[] = payloads.map((file) => {
|
||||
const id = uuidv4();
|
||||
|
||||
const { xhr, promise } = uploadFileRef.current(file, (progress) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((attachment) => {
|
||||
if (attachment.id === id) {
|
||||
return {
|
||||
...attachment,
|
||||
uploadProgress: progress
|
||||
};
|
||||
}
|
||||
return attachment;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
promise
|
||||
.then((res) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((attachment) => {
|
||||
if (attachment.id === id) {
|
||||
return {
|
||||
...attachment,
|
||||
// Update with the server ID
|
||||
serverId: res.id,
|
||||
uploaded: true,
|
||||
uploadProgress: 100,
|
||||
cancel: undefined
|
||||
};
|
||||
}
|
||||
return attachment;
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Failed to upload ${file.name}: ${error.message}`);
|
||||
setAttachments((prev) =>
|
||||
prev.filter((attachment) => attachment.id !== id)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploadProgress: 0,
|
||||
cancel: () => {
|
||||
toast.info(`Cancelled upload of ${file.name}`);
|
||||
xhr.abort();
|
||||
setAttachments((prev) =>
|
||||
prev.filter((attachment) => attachment.id !== id)
|
||||
);
|
||||
},
|
||||
remove: () => {
|
||||
setAttachments((prev) =>
|
||||
prev.filter((attachment) => attachment.id !== id)
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
setAttachments((prev) => prev.concat(attachements));
|
||||
},
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
const onFileUploadError = useCallback(
|
||||
(error: string) => toast.error(error),
|
||||
[toast]
|
||||
);
|
||||
|
||||
const upload = useUpload({
|
||||
spec: fileSpec,
|
||||
onResolved: onFileUpload,
|
||||
onError: onFileUploadError,
|
||||
options: { noClick: true }
|
||||
});
|
||||
|
||||
const enableAttachments =
|
||||
!disabled && config?.features?.spontaneous_file_upload?.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(enableAttachments
|
||||
? upload?.getRootProps({ className: 'dropzone' })
|
||||
: {})}
|
||||
// Disable the onFocus and onBlur events in react-dropzone to avoid interfering with child trigger events
|
||||
onBlur={undefined}
|
||||
onFocus={undefined}
|
||||
className="flex w-full h-full flex-col overflow-y-auto"
|
||||
>
|
||||
{upload ? (
|
||||
<input id="#upload-drop-input" {...upload.getInputProps()} />
|
||||
) : null}
|
||||
<div className="flex-grow flex flex-col overflow-y-auto">
|
||||
{error ? (
|
||||
<div className="w-full mx-auto my-2">
|
||||
<Alert className="mx-2" id="session-error" variant="error">
|
||||
<Translator path="common.status.error.serverConnection" />
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
<ChatSettingsModal />
|
||||
<ErrorBoundary>
|
||||
<ScrollContainer
|
||||
autoScrollUserMessage={config?.features?.user_message_autoscroll}
|
||||
autoScrollAssistantMessage={
|
||||
config?.features?.assistant_message_autoscroll
|
||||
}
|
||||
autoScrollRef={autoScrollRef}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col mx-auto w-full flex-grow px-4 pt-4"
|
||||
style={{
|
||||
maxWidth: layoutMaxWidth
|
||||
}}
|
||||
>
|
||||
<TaskList isMobile={true} isCopilot />
|
||||
<WelcomeScreen />
|
||||
<MessagesContainer />
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
<div
|
||||
className="flex flex-col mx-auto w-full px-4 pb-4"
|
||||
style={{
|
||||
maxWidth: layoutMaxWidth
|
||||
}}
|
||||
>
|
||||
<ChatFooter
|
||||
showIfEmptyThread
|
||||
fileSpec={fileSpec}
|
||||
onFileUpload={onFileUpload}
|
||||
onFileUploadError={onFileUploadError}
|
||||
autoScrollRef={autoScrollRef}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ElementSideView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import {
|
||||
threadIdToResumeState,
|
||||
useChatInteract,
|
||||
useChatSession
|
||||
} from '@chainlit/react-client';
|
||||
|
||||
import { copilotThreadIdState } from '../state';
|
||||
import ChatBody from './body';
|
||||
|
||||
export default function ChatWrapper() {
|
||||
const { connect, session, idToResume } = useChatSession();
|
||||
const { sendMessage } = useChatInteract();
|
||||
const copilotThreadId = useRecoilValue(copilotThreadIdState);
|
||||
const setThreadIdToResume = useSetRecoilState(threadIdToResumeState);
|
||||
const hasConnected = useRef<boolean>(false);
|
||||
const lastConnectedThreadId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copilotThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThreadIdToResume(copilotThreadId);
|
||||
}, [copilotThreadId, setThreadIdToResume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
copilotThreadId &&
|
||||
lastConnectedThreadId.current &&
|
||||
copilotThreadId !== lastConnectedThreadId.current &&
|
||||
hasConnected.current
|
||||
) {
|
||||
if (session?.socket?.connected) {
|
||||
session.socket.disconnect();
|
||||
}
|
||||
hasConnected.current = false;
|
||||
lastConnectedThreadId.current = null;
|
||||
}
|
||||
}, [copilotThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copilotThreadId || !idToResume || copilotThreadId !== idToResume) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasConnected.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasConnected.current = true;
|
||||
lastConnectedThreadId.current = copilotThreadId;
|
||||
connect({
|
||||
// @ts-expect-error window typing
|
||||
transports: window.transports,
|
||||
userEnv: {}
|
||||
});
|
||||
}, [copilotThreadId, idToResume, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error is not a valid prop
|
||||
window.sendChainlitMessage = sendMessage;
|
||||
}, [sendMessage]);
|
||||
|
||||
return <ChatBody />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { Element } from '@chainlit/app/src/components/Elements';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@chainlit/app/src/components/ui/dialog';
|
||||
import { sideViewState } from '@chainlit/react-client';
|
||||
|
||||
export default function ElementSideView() {
|
||||
const [sideView, setSideView] = useRecoilState(sideViewState);
|
||||
|
||||
if (!sideView || sideView.title === 'canvas') return null;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && setSideView(undefined)}>
|
||||
<DialogContent className="max-w-[80%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sideView.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="mt-4 overflow-y-auto overscroll-contain min-h-[50vh] max-h-[80vh] flex flex-col gap-4"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sideView.elements.map((e) => (
|
||||
<Element key={e.id} element={e} />
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ChevronsRight, Maximize, Minimize, PanelRight } from 'lucide-react';
|
||||
|
||||
import AudioPresence from '@chainlit/app/src/components/AudioPresence';
|
||||
import { Logo } from '@chainlit/app/src/components/Logo';
|
||||
import ChatProfiles from '@chainlit/app/src/components/header/ChatProfiles';
|
||||
import NewChatButton from '@chainlit/app/src/components/header/NewChat';
|
||||
import { Button } from '@chainlit/app/src/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@chainlit/app/src/components/ui/dropdown-menu';
|
||||
import { IChainlitConfig, useAudio } from '@chainlit/react-client';
|
||||
|
||||
import { useCopilotInteract } from '../hooks';
|
||||
import { DisplayMode } from '../types';
|
||||
|
||||
interface IProjectConfig {
|
||||
config?: IChainlitConfig;
|
||||
error?: Error;
|
||||
isLoading: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
expanded: boolean;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
projectConfig: IProjectConfig;
|
||||
displayMode?: DisplayMode;
|
||||
setDisplayMode?: (mode: DisplayMode) => void;
|
||||
setIsOpen?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
expanded,
|
||||
setExpanded,
|
||||
projectConfig,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
setIsOpen
|
||||
}: Props): JSX.Element => {
|
||||
const { config } = projectConfig;
|
||||
const { audioConnection } = useAudio();
|
||||
const { startNewChat } = useCopilotInteract();
|
||||
|
||||
const hasChatProfiles = !!config?.chatProfiles.length;
|
||||
|
||||
return (
|
||||
<div className="flex align-center justify-between p-4 pb-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{hasChatProfiles ? <ChatProfiles /> : <Logo className="w-[100px]" />}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{audioConnection === 'on' ? (
|
||||
<AudioPresence
|
||||
type="server"
|
||||
height={20}
|
||||
width={40}
|
||||
barCount={4}
|
||||
barSpacing={2}
|
||||
/>
|
||||
) : null}
|
||||
<NewChatButton
|
||||
className="text-muted-foreground mt-[1.5px]"
|
||||
onConfirm={startNewChat}
|
||||
/>
|
||||
{setDisplayMode && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button id="display-mode-button" size="icon" variant="ghost">
|
||||
<PanelRight className="!size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
container={window.cl_shadowRootElement}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={displayMode}
|
||||
onValueChange={(v) => setDisplayMode(v as DisplayMode)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="floating">
|
||||
Floating
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="sidebar">
|
||||
Sidebar
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{displayMode === 'sidebar' && setIsOpen ? (
|
||||
<Button
|
||||
id="close-sidebar-button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<ChevronsRight className="!size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<Minimize className="!size-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Maximize className="!size-5 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Starters from '@chainlit/app/src/components/chat/Starters';
|
||||
import { cn, hasMessage } from '@chainlit/app/src/lib/utils';
|
||||
import { useChatMessages } from '@chainlit/react-client';
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const { messages } = useChatMessages();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
if (hasMessage(messages)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col pb-4 flex-grow welcome-screen transition-opacity duration-500 opacity-0 delay-100',
|
||||
isVisible && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Starters className="items-end mt-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useCopilotInteract } from './useCopilotInteract';
|
||||
export { useSidebarResize } from './useSidebarResize';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { useChatInteract } from '@chainlit/react-client';
|
||||
|
||||
import { copilotThreadIdState } from '../state';
|
||||
|
||||
export const useCopilotInteract = () => {
|
||||
const chatInteract = useChatInteract();
|
||||
const setCopilotThreadId = useSetRecoilState(copilotThreadIdState);
|
||||
|
||||
const startNewChat = useCallback(
|
||||
(newThreadId?: string) => {
|
||||
chatInteract.clear();
|
||||
setCopilotThreadId(newThreadId || uuidv4());
|
||||
},
|
||||
[chatInteract, setCopilotThreadId]
|
||||
);
|
||||
|
||||
return {
|
||||
...chatInteract,
|
||||
clear: startNewChat,
|
||||
startNewChat
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DisplayMode } from '../types';
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 300;
|
||||
const SIDEBAR_DEFAULT_WIDTH = 400;
|
||||
const SIDEBAR_MAX_WIDTH_RATIO = 0.5;
|
||||
const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth';
|
||||
|
||||
interface UseSidebarResizeOptions {
|
||||
displayMode: DisplayMode;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseSidebarResizeReturn {
|
||||
sidebarWidth: number;
|
||||
handleMouseDown: () => void;
|
||||
}
|
||||
|
||||
export function useSidebarResize({
|
||||
displayMode,
|
||||
isOpen
|
||||
}: UseSidebarResizeOptions): UseSidebarResizeReturn {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = localStorage.getItem(LS_WIDTH_KEY);
|
||||
return stored ? Number(stored) : SIDEBAR_DEFAULT_WIDTH;
|
||||
});
|
||||
const isDragging = useRef(false);
|
||||
const originalMarginRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode === 'sidebar') {
|
||||
localStorage.setItem(LS_WIDTH_KEY, String(sidebarWidth));
|
||||
}
|
||||
}, [sidebarWidth, displayMode]);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.transition = 'margin-right 0.3s ease-in-out';
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
isDragging.current = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.transition = '';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode !== 'sidebar' || !isOpen) return;
|
||||
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
if (!isDragging.current) return;
|
||||
const maxWidth = window.innerWidth * SIDEBAR_MAX_WIDTH_RATIO;
|
||||
const newWidth = Math.min(
|
||||
maxWidth,
|
||||
Math.max(SIDEBAR_MIN_WIDTH, window.innerWidth - e.clientX)
|
||||
);
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', stopDragging);
|
||||
window.addEventListener('blur', stopDragging);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', stopDragging);
|
||||
window.removeEventListener('blur', stopDragging);
|
||||
if (isDragging.current) {
|
||||
isDragging.current = false;
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
};
|
||||
}, [stopDragging, displayMode, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode === 'sidebar' && isOpen) {
|
||||
originalMarginRef.current = document.body.style.marginRight;
|
||||
document.body.style.transition = 'margin-right 0.3s ease-in-out';
|
||||
return () => {
|
||||
document.body.style.marginRight = originalMarginRef.current;
|
||||
document.body.style.transition = '';
|
||||
};
|
||||
}
|
||||
}, [displayMode, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode === 'sidebar' && isOpen) {
|
||||
document.body.style.marginRight = `${sidebarWidth}px`;
|
||||
}
|
||||
}, [sidebarWidth, displayMode, isOpen]);
|
||||
|
||||
return { sidebarWidth, handleMouseDown };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
#cl-shadow-root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
color: hsl(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#cl-shadow-root code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
#shadow-root-container {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-mono:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 5%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 5%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 5%;
|
||||
--primary: 340 92% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 0 0% 90%;
|
||||
--muted-foreground: 0 0% 36%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 0 0% 90%;
|
||||
--input: 0 0% 90%;
|
||||
--ring: 340 92% 52%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.75rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 13%;
|
||||
--foreground: 0 0% 93%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 0 0% 18%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 340 92% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 19%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 0 1% 26%;
|
||||
--muted-foreground: 0 0% 71%;
|
||||
--accent: 0 0% 26%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 0 1% 26%;
|
||||
--input: 0 1% 26%;
|
||||
--ring: 340 92% 52%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--radius: 0.75rem;
|
||||
--sidebar-background: 0 0% 9%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 0 0% 13%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
0% {
|
||||
background-position: -100% top;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 250% top;
|
||||
}
|
||||
}
|
||||
|
||||
#cl-shadow-root .loading-shimmer {
|
||||
background-position: -100% top;
|
||||
text-fill-color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: loading-shimmer;
|
||||
background: hsl(var(--muted))
|
||||
gradient(
|
||||
linear,
|
||||
100% 0,
|
||||
0 0,
|
||||
from(hsl(var(--muted))),
|
||||
color-stop(0.5, hsl(var(--foreground))),
|
||||
to(hsl(var(--muted)))
|
||||
);
|
||||
background: hsl(var(--muted)) -webkit-gradient(
|
||||
linear,
|
||||
100% 0,
|
||||
0 0,
|
||||
from(hsl(var(--muted))),
|
||||
color-stop(0.5, hsl(var(--foreground))),
|
||||
to(hsl(var(--muted)))
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 50% 200%;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DisplayMode } from './types';
|
||||
|
||||
export const LS_DISPLAY_MODE_KEY = 'chainlit-copilot-displayMode';
|
||||
|
||||
export function resolveDisplayMode(
|
||||
configDisplayMode: DisplayMode | undefined
|
||||
): DisplayMode {
|
||||
return (
|
||||
configDisplayMode ||
|
||||
(localStorage.getItem(LS_DISPLAY_MODE_KEY) as DisplayMode) ||
|
||||
'floating'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { atom } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const COPILOT_THREAD_ID_KEY = 'chainlit-copilot-thread-id';
|
||||
export const COPILOT_THREAD_CHANGED_EVENT_KEY =
|
||||
'chainlit-copilot-thread-changed';
|
||||
export class CopilotThreadChangedEventParams {
|
||||
newThreadId?: string;
|
||||
}
|
||||
|
||||
export const copilotThreadIdState = atom<string>({
|
||||
key: 'CopilotThreadId',
|
||||
default: '',
|
||||
effects: [
|
||||
({ setSelf, onSet }) => {
|
||||
const savedValue = localStorage.getItem(COPILOT_THREAD_ID_KEY);
|
||||
|
||||
if (savedValue != null) {
|
||||
try {
|
||||
const parsedValue = JSON.parse(savedValue);
|
||||
setSelf(parsedValue);
|
||||
} catch (_error) {
|
||||
const newThreadId = uuidv4();
|
||||
localStorage.setItem(
|
||||
COPILOT_THREAD_ID_KEY,
|
||||
JSON.stringify(newThreadId)
|
||||
);
|
||||
setSelf(newThreadId);
|
||||
}
|
||||
} else {
|
||||
const newThreadId = uuidv4();
|
||||
localStorage.setItem(
|
||||
COPILOT_THREAD_ID_KEY,
|
||||
JSON.stringify(newThreadId)
|
||||
);
|
||||
setSelf(newThreadId);
|
||||
}
|
||||
|
||||
onSet((newValue, _, isReset) => {
|
||||
if (isReset) {
|
||||
localStorage.removeItem(COPILOT_THREAD_ID_KEY);
|
||||
} else {
|
||||
localStorage.setItem(COPILOT_THREAD_ID_KEY, JSON.stringify(newValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const getChainlitCopilotThreadId = () => {
|
||||
const threadId = localStorage.getItem(COPILOT_THREAD_ID_KEY);
|
||||
return threadId ? JSON.parse(threadId) : null;
|
||||
};
|
||||
|
||||
export const clearChainlitCopilotThreadId = (newThreadId?: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CopilotThreadChangedEventParams>(
|
||||
COPILOT_THREAD_CHANGED_EVENT_KEY,
|
||||
{
|
||||
detail: { newThreadId }
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export type DisplayMode = 'floating' | 'sidebar';
|
||||
|
||||
export interface IWidgetConfig {
|
||||
chainlitServer: string;
|
||||
showCot?: boolean;
|
||||
accessToken?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
button?: {
|
||||
containerId?: string;
|
||||
imageUrl?: string;
|
||||
className?: string;
|
||||
};
|
||||
customCssUrl?: string;
|
||||
additionalQueryParamsForAPI?: Record<string, string>;
|
||||
expanded?: boolean;
|
||||
language?: string;
|
||||
opened?: boolean;
|
||||
displayMode?: DisplayMode;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageCircle, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Alert from '@chainlit/app/src/components/Alert';
|
||||
import { Button } from '@chainlit/app/src/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@chainlit/app/src/components/ui/popover';
|
||||
import { useConfig } from '@chainlit/react-client';
|
||||
|
||||
import Header from './components/Header';
|
||||
|
||||
import ChatWrapper from './chat';
|
||||
import { useSidebarResize } from './hooks';
|
||||
import { LS_DISPLAY_MODE_KEY, resolveDisplayMode } from './resolveDisplayMode';
|
||||
import {
|
||||
clearChainlitCopilotThreadId,
|
||||
getChainlitCopilotThreadId
|
||||
} from './state';
|
||||
import { DisplayMode, IWidgetConfig } from './types';
|
||||
|
||||
interface Props {
|
||||
config: IWidgetConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Widget = ({ config, error }: Props) => {
|
||||
const [expanded, setExpanded] = useState(config?.expanded || false);
|
||||
const [isOpen, setIsOpen] = useState(config?.opened || false);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>(() =>
|
||||
resolveDisplayMode(config?.displayMode)
|
||||
);
|
||||
const projectConfig = useConfig();
|
||||
const { sidebarWidth, handleMouseDown } = useSidebarResize({
|
||||
displayMode,
|
||||
isOpen
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.toggleChainlitCopilot = () => setIsOpen((prev) => !prev);
|
||||
window.getChainlitCopilotThreadId = getChainlitCopilotThreadId;
|
||||
window.clearChainlitCopilotThreadId = clearChainlitCopilotThreadId;
|
||||
|
||||
return () => {
|
||||
window.toggleChainlitCopilot = () => console.error('Widget not mounted.');
|
||||
window.getChainlitCopilotThreadId = () => null;
|
||||
|
||||
window.clearChainlitCopilotThreadId = () =>
|
||||
console.error('Widget not mounted.');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_DISPLAY_MODE_KEY, displayMode);
|
||||
}, [displayMode]);
|
||||
|
||||
const customClassName = config?.button?.className || '';
|
||||
|
||||
const chatContent = error ? (
|
||||
<Alert variant="error">{error}</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Header
|
||||
expanded={expanded}
|
||||
setExpanded={setExpanded}
|
||||
projectConfig={projectConfig}
|
||||
displayMode={displayMode}
|
||||
setDisplayMode={setDisplayMode}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
<div className="flex flex-grow overflow-y-auto">
|
||||
<ChatWrapper />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (displayMode === 'sidebar') {
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
id="chainlit-copilot-button"
|
||||
aria-expanded="false"
|
||||
className={cn(
|
||||
'fixed h-16 w-16 rounded-full bottom-8 right-8 z-[20]',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
customClassName
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{config?.button?.imageUrl ? (
|
||||
<img
|
||||
width="100%"
|
||||
src={config.button.imageUrl}
|
||||
alt="Chat bubble icon"
|
||||
/>
|
||||
) : (
|
||||
<MessageCircle className="!size-7" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full z-[50] bg-background border-l shadow-lg flex flex-col"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div
|
||||
data-testid="sidebar-drag-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/20 active:bg-primary/30 z-10"
|
||||
/>
|
||||
<div id="chainlit-copilot-chat" className="flex flex-col h-full w-full">
|
||||
{chatContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="chainlit-copilot-button"
|
||||
className={cn(
|
||||
'fixed h-16 w-16 rounded-full bottom-8 right-8 z-[20]',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
customClassName
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{config?.button?.imageUrl ? (
|
||||
<img
|
||||
width="100%"
|
||||
src={config.button.imageUrl}
|
||||
alt="Chat bubble icon"
|
||||
className={cn(
|
||||
'transition-opacity',
|
||||
isOpen ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<MessageCircle
|
||||
className={cn(
|
||||
'!size-7 transition-opacity',
|
||||
isOpen ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<X
|
||||
className={cn(
|
||||
'absolute !size-7 transition-all',
|
||||
isOpen ? 'rotate-0 scale-100' : 'rotate-90 scale-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className={cn(
|
||||
'flex flex-col p-0',
|
||||
'transition-all duration-300 ease-in-out bg-background',
|
||||
expanded ? 'w-[80vw]' : 'w-[min(400px,80vw)]',
|
||||
'h-[min(730px,calc(100vh-150px))]',
|
||||
'overflow-hidden rounded-xl',
|
||||
'shadow-lg',
|
||||
'z-50',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
expanded
|
||||
? 'copilot-container-expanded'
|
||||
: 'copilot-container-collapsed'
|
||||
)}
|
||||
>
|
||||
<div id="chainlit-copilot-chat" className="flex flex-col h-full w-full">
|
||||
{chatContent}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Widget;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import AppWrapper from 'appWrapper';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
||||
const meta = {
|
||||
title: 'Example/App',
|
||||
component: AppWrapper,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'centered'
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {}
|
||||
} satisfies Meta<typeof AppWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
widgetConfig: {
|
||||
chainlitServer: 'http://localhost:8000'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
widgetConfig: {
|
||||
chainlitServer: 'http://localhost:8000',
|
||||
theme: 'dark',
|
||||
button: {
|
||||
imageUrl:
|
||||
'https://steelbluemedia.com/wp-content/uploads/2019/06/new-google-favicon-512.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./src/**/*.{ts,tsx,js,jsx}',
|
||||
'../../frontend/src/**/*.{ts,tsx,js,jsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
1: 'hsl(var(--chart-1))',
|
||||
2: 'hsl(var(--chart-2))',
|
||||
3: 'hsl(var(--chart-3))',
|
||||
4: 'hsl(var(--chart-4))',
|
||||
5: 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: '0'
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
}
|
||||
},
|
||||
'accordion-up': {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
},
|
||||
to: {
|
||||
height: '0'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
plugins: [require('tailwindcss-animate')]
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./src", "./stories"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths(), svgr()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
copilot: path.resolve(__dirname, 'index.tsx')
|
||||
},
|
||||
output: [
|
||||
{
|
||||
name: 'copilot',
|
||||
dir: 'dist',
|
||||
format: 'iife',
|
||||
entryFileNames: 'index.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// To prevent conflicts with packages in @chainlit/app, we need to specify the resolution paths for these dependencies.
|
||||
react: path.resolve(__dirname, './node_modules/react'),
|
||||
'@chainlit': path.resolve(__dirname, './node_modules/@chainlit'),
|
||||
postcss: path.resolve(__dirname, './node_modules/postcss'),
|
||||
tailwindcss: path.resolve(__dirname, './node_modules/tailwindcss'),
|
||||
i18next: path.resolve(__dirname, './node_modules/i18next'),
|
||||
sonner: path.resolve(__dirname, './node_modules/sonner'),
|
||||
'highlight.js': path.resolve(__dirname, './node_modules/highlight.js'),
|
||||
'react-i18next': path.resolve(__dirname, './node_modules/react-i18next'),
|
||||
'usehooks-ts': path.resolve(__dirname, './node_modules/usehooks-ts'),
|
||||
lodash: path.resolve(__dirname, './node_modules/lodash'),
|
||||
recoil: path.resolve(__dirname, './node_modules/recoil')
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
## Overview
|
||||
|
||||
The `@chainlit/react-client` package provides a set of React hooks as well as an API client to connect to your [Chainlit](https://github.com/Chainlit/chainlit) application from any React application. The package includes hooks for managing chat sessions, messages, data, and interactions.
|
||||
|
||||
## Installation
|
||||
|
||||
To install the package, run the following command in your project directory:
|
||||
|
||||
```sh
|
||||
npm install @chainlit/react-client
|
||||
```
|
||||
|
||||
This package use [Recoil](https://github.com/facebookexperimental/Recoil) to manage its state. This means you will have to wrap your application in a recoil provider:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { ChainlitAPI, ChainlitContext } from '@chainlit/react-client';
|
||||
|
||||
const CHAINLIT_SERVER_URL = 'http://localhost:8000';
|
||||
|
||||
const apiClient = new ChainlitAPI(CHAINLIT_SERVER_URL, 'webapp');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ChainlitContext.Provider value={apiClient}>
|
||||
<RecoilRoot>
|
||||
<MyApp />
|
||||
</RecoilRoot>
|
||||
</ChainlitContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `useChatSession`
|
||||
|
||||
This hook is responsible for managing the chat session's connection to the WebSocket server.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `connect`: Establishes a connection to the WebSocket server.
|
||||
- `disconnect`: Disconnects from the WebSocket server.
|
||||
- `setChatProfile`: Sets the chat profile state.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsx
|
||||
import { useChatSession } from '@chainlit/react-client';
|
||||
|
||||
const ChatComponent = () => {
|
||||
const { connect, disconnect, chatProfile, setChatProfile } = useChatSession();
|
||||
|
||||
// Connect to the WebSocket server
|
||||
useEffect(() => {
|
||||
connect({
|
||||
userEnv: {
|
||||
/* user environment variables */
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Rest of your component logic
|
||||
};
|
||||
```
|
||||
|
||||
### `useChatMessages`
|
||||
|
||||
This hook provides access to the chat messages and the first user message.
|
||||
|
||||
#### Properties
|
||||
|
||||
- `messages`: An array of chat messages.
|
||||
- `firstUserMessage`: The first message from the user.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsx
|
||||
import { useChatMessages } from '@chainlit/react-client';
|
||||
|
||||
const MessagesComponent = () => {
|
||||
const { messages, firstUserMessage } = useChatMessages();
|
||||
|
||||
// Render your messages
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<p key={message.id}>{message.output}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### `useChatData`
|
||||
|
||||
This hook provides access to various chat-related data and states.
|
||||
|
||||
#### Properties
|
||||
|
||||
- `actions`: An array of actions.
|
||||
- `askUser`: The current ask user state.
|
||||
- `avatars`: An array of avatar elements.
|
||||
- `chatSettingsDefaultValue`: The default value for chat settings.
|
||||
- `chatSettingsInputs`: The current chat settings inputs.
|
||||
- `chatSettingsValue`: The current value of chat settings.
|
||||
- `connected`: A boolean indicating if the WebSocket connection is established.
|
||||
- `disabled`: A boolean indicating if the chat is disabled.
|
||||
- `elements`: An array of chat elements.
|
||||
- `error`: A boolean indicating if there is an error in the session.
|
||||
- `loading`: A boolean indicating if the chat is in a loading state.
|
||||
- `tasklists`: An array of tasklist elements.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsx
|
||||
import { useChatData } from '@chainlit/react-client';
|
||||
|
||||
const ChatDataComponent = () => {
|
||||
const { loading, connected, error } = useChatData();
|
||||
|
||||
// Use the data to render your component
|
||||
if (loading) return <p>Loading...</p>;
|
||||
if (error) return <p>Error connecting to chat...</p>;
|
||||
if (!connected) return <p>Disconnected...</p>;
|
||||
|
||||
// Rest of your component logic
|
||||
};
|
||||
```
|
||||
|
||||
### `useChatInteract`
|
||||
|
||||
This hook provides methods to interact with the chat, such as sending messages, replying, and updating settings.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `callAction`: Calls an action.
|
||||
- `clear`: Clears the chat session.
|
||||
- `replyMessage`: Replies to a message.
|
||||
- `sendMessage`: Sends a message.
|
||||
- `stopTask`: Stops the current task.
|
||||
- `setIdToResume`: Sets the ID to resume a thread.
|
||||
- `updateChatSettings`: Updates the chat settings.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsx
|
||||
import { useChatInteract } from '@chainlit/react-client';
|
||||
|
||||
const InteractionComponent = () => {
|
||||
const { sendMessage, replyMessage } = useChatInteract();
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const message = { output: 'Hello, World!', id: 'message-id' };
|
||||
sendMessage(message);
|
||||
};
|
||||
|
||||
const handleReplyMessage = () => {
|
||||
const message = { output: 'Replying to your message', id: 'reply-id' };
|
||||
replyMessage(message);
|
||||
};
|
||||
|
||||
// Render your interaction component
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleSendMessage}>Send Message</button>
|
||||
<button onClick={handleReplyMessage}>Reply to Message</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@chainlit/react-client",
|
||||
"description": "Websocket client to connect to your chainlit app.",
|
||||
"version": "0.4.2",
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --clean --format esm,cjs --dts --external react --external recoil --minify --sourcemap --treeshake",
|
||||
"build": "tsup src/index.ts --tsconfig tsconfig.build.json --clean --format esm,cjs --dts --external react --external recoil --minify --sourcemap --treeshake",
|
||||
"type-check": "tsc --noemit",
|
||||
"test": "echo no tests yet"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Chainlit/chainlit.git",
|
||||
"directory": "libs/react-client"
|
||||
},
|
||||
"private": false,
|
||||
"keywords": [
|
||||
"llm",
|
||||
"ai",
|
||||
"chain of thought"
|
||||
],
|
||||
"author": "Chainlit",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.86",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/uuid": "^9.0.3",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"jsdom": "^22.1.0",
|
||||
"tslib": "^2.6.2",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.14",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recoil": "^0.7.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"sonner": "^1.7.1",
|
||||
"swr": "^2.2.2",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite@>=4.4.0 <4.4.12": ">=4.4.12",
|
||||
"@adobe/css-tools@<4.3.2": ">=4.3.2",
|
||||
"vite@>=4.0.0 <=4.5.1": ">=4.5.2",
|
||||
"vite@>=4.0.0 <=4.5.2": ">=4.5.3",
|
||||
"braces@<3.0.3": ">=3.0.3",
|
||||
"ws@>=8.0.0 <8.17.1": ">=8.17.1",
|
||||
"micromatch@<4.0.8": ">=4.0.8",
|
||||
"vite@>=4.0.0 <4.5.4": ">=4.5.4",
|
||||
"vite@>=4.0.0 <=4.5.3": ">=4.5.4",
|
||||
"rollup@>=3.0.0 <3.29.5": ">=3.29.5",
|
||||
"cross-spawn@>=7.0.0 <7.0.5": ">=7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { ChainlitAPI } from 'src/api';
|
||||
import { ChainlitContext } from 'src/context';
|
||||
import useSWR, { SWRConfig, SWRConfiguration } from 'swr';
|
||||
|
||||
import { useAuthState } from './auth/state';
|
||||
|
||||
const fetcher = async (client: ChainlitAPI, endpoint: string) => {
|
||||
const res = await client.get(endpoint);
|
||||
return res?.json();
|
||||
};
|
||||
|
||||
const cloneClient = (client: ChainlitAPI): ChainlitAPI => {
|
||||
// Shallow clone API client.
|
||||
// TODO: Move me to core API.
|
||||
|
||||
// Create new client
|
||||
const newClient = new ChainlitAPI('', 'webapp');
|
||||
|
||||
// Assign old properties to new client
|
||||
Object.assign(newClient, client);
|
||||
|
||||
return newClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for cached API data fetching using SWR (stale-while-revalidate).
|
||||
* Optimized for GET requests with automatic caching and revalidation.
|
||||
*
|
||||
* Key features:
|
||||
* - Automatic data caching and revalidation
|
||||
* - Integration with React component lifecycle
|
||||
* - Loading state management
|
||||
* - Recoil state integration for global state
|
||||
* - Memoized fetcher function to prevent unnecessary rerenders
|
||||
*
|
||||
* @param path - API endpoint path or null to disable the request
|
||||
* @param config - Optional SWR configuration
|
||||
* @returns SWR response object containing:
|
||||
* - data: The fetched data
|
||||
* - error: Any error that occurred
|
||||
* - isValidating: Whether a request is in progress
|
||||
* - mutate: Function to mutate the cached data
|
||||
*
|
||||
* @example
|
||||
* const { data, error, isValidating } = useApi<UserData>('/user');
|
||||
*/
|
||||
function useApi<T>(
|
||||
path?: string | null,
|
||||
{ ...swrConfig }: SWRConfiguration = {}
|
||||
) {
|
||||
const client = useContext(ChainlitContext);
|
||||
const { setUser } = useAuthState();
|
||||
|
||||
// Memoize the fetcher function to avoid recreating it on every render
|
||||
const memoizedFetcher = useMemo(
|
||||
() =>
|
||||
([url]: [url: string]) => {
|
||||
if (!swrConfig.onErrorRetry) {
|
||||
swrConfig.onErrorRetry = (...args) => {
|
||||
const [err] = args;
|
||||
|
||||
// Don't do automatic retry for 401 - it just means we're not logged in (yet).
|
||||
if (err.status === 401) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to default behavior.
|
||||
return SWRConfig.defaultValue.onErrorRetry(...args);
|
||||
};
|
||||
}
|
||||
|
||||
const useApiClient = cloneClient(client);
|
||||
useApiClient.on401 = useApiClient.onError = undefined;
|
||||
return fetcher(useApiClient, url);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
// Use a stable key for useSWR
|
||||
const swrKey = useMemo(() => {
|
||||
return path ? [path] : null;
|
||||
}, [path]);
|
||||
|
||||
return useSWR<T, Error>(swrKey, memoizedFetcher, swrConfig);
|
||||
}
|
||||
|
||||
export { useApi, fetcher };
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { IAuthConfig } from 'src/index';
|
||||
|
||||
import { useApi } from '../api';
|
||||
import { useAuthState } from './state';
|
||||
|
||||
export const useAuthConfig = () => {
|
||||
const { authConfig, setAuthConfig } = useAuthState();
|
||||
const { data: authConfigData, isLoading } = useApi<IAuthConfig>(
|
||||
authConfig ? null : '/auth/config'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (authConfigData) {
|
||||
setAuthConfig(authConfigData);
|
||||
}
|
||||
}, [authConfigData, setAuthConfig]);
|
||||
|
||||
return { authConfig, isLoading };
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IAuthConfig, IUser } from 'src/types';
|
||||
|
||||
import { useAuthConfig } from './config';
|
||||
import { useSessionManagement } from './sessionManagement';
|
||||
import { useUserManagement } from './userManagement';
|
||||
|
||||
export const useAuth = () => {
|
||||
const { authConfig } = useAuthConfig();
|
||||
const { logout } = useSessionManagement();
|
||||
const { user, setUserFromAPI } = useUserManagement();
|
||||
|
||||
const isReady =
|
||||
!!authConfig && (!authConfig.requireLogin || user !== undefined);
|
||||
|
||||
if (authConfig && !authConfig.requireLogin) {
|
||||
return {
|
||||
data: authConfig,
|
||||
user: null,
|
||||
isReady,
|
||||
isAuthenticated: true,
|
||||
logout: () => Promise.resolve(),
|
||||
setUserFromAPI: () => Promise.resolve()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: authConfig,
|
||||
user,
|
||||
isReady,
|
||||
isAuthenticated: !!user,
|
||||
logout,
|
||||
setUserFromAPI
|
||||
};
|
||||
};
|
||||
|
||||
export type { IAuthConfig, IUser };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useContext } from 'react';
|
||||
import { ChainlitContext } from 'src/index';
|
||||
|
||||
import { useAuthState } from './state';
|
||||
|
||||
export const useSessionManagement = () => {
|
||||
const apiClient = useContext(ChainlitContext);
|
||||
const { setUser, setThreadHistory } = useAuthState();
|
||||
|
||||
const logout = async (reload = false): Promise<void> => {
|
||||
await apiClient.logout();
|
||||
setUser(undefined);
|
||||
setThreadHistory(undefined);
|
||||
|
||||
if (reload) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return { logout };
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { authState, threadHistoryState, userState } from 'src/state';
|
||||
|
||||
export const useAuthState = () => {
|
||||
const [authConfig, setAuthConfig] = useRecoilState(authState);
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const setThreadHistory = useSetRecoilState(threadHistoryState);
|
||||
|
||||
return {
|
||||
authConfig,
|
||||
setAuthConfig,
|
||||
user,
|
||||
setUser,
|
||||
setThreadHistory
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IAuthConfig, IUser } from 'src/types';
|
||||
|
||||
export interface JWTPayload extends IUser {
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
data: IAuthConfig | undefined;
|
||||
user: IUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export interface AuthActions {
|
||||
logout: (reload?: boolean) => Promise<void>;
|
||||
setUserFromAPI: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type IUseAuth = AuthState & AuthActions;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { IUser } from 'src/types';
|
||||
|
||||
import { useApi } from '../api';
|
||||
import { useAuthState } from './state';
|
||||
|
||||
export const useUserManagement = () => {
|
||||
const { user, setUser } = useAuthState();
|
||||
|
||||
const {
|
||||
data: userData,
|
||||
error,
|
||||
mutate: setUserFromAPI
|
||||
} = useApi<IUser>('/user');
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
setUser(userData);
|
||||
}
|
||||
}, [userData, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUser(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return { user, setUserFromAPI };
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import { IElement, IThread, IUser } from 'src/types';
|
||||
|
||||
import { IAction } from 'src/types/action';
|
||||
import { IFeedback } from 'src/types/feedback';
|
||||
|
||||
export * from './hooks/auth';
|
||||
export * from './hooks/api';
|
||||
|
||||
export interface IThreadFilters {
|
||||
search?: string;
|
||||
feedback?: number;
|
||||
}
|
||||
|
||||
export interface IPageInfo {
|
||||
hasNextPage: boolean;
|
||||
endCursor?: string;
|
||||
}
|
||||
|
||||
export interface IPagination {
|
||||
first: number;
|
||||
cursor?: string | number;
|
||||
}
|
||||
|
||||
export class ClientError extends Error {
|
||||
status: number;
|
||||
detail?: string;
|
||||
|
||||
constructor(message: string, status: number, detail?: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.detail) {
|
||||
return `${this.message}: ${this.detail}`;
|
||||
} else {
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Payload = FormData | any;
|
||||
|
||||
export class APIBase {
|
||||
constructor(
|
||||
public httpEndpoint: string,
|
||||
public type: 'webapp' | 'copilot' | 'teams' | 'slack' | 'discord',
|
||||
public additionalQueryParams?: Record<string, string>,
|
||||
public on401?: () => void,
|
||||
public onError?: (error: ClientError) => void
|
||||
) {}
|
||||
|
||||
buildEndpoint(path: string) {
|
||||
let fullUrl = `${this.httpEndpoint}${path}`;
|
||||
if (this.httpEndpoint.endsWith('/')) {
|
||||
// remove trailing slash on httpEndpoint
|
||||
fullUrl = `${this.httpEndpoint.slice(0, -1)}${path}`;
|
||||
}
|
||||
|
||||
const url = new URL(fullUrl);
|
||||
|
||||
// Add additionalQueryParams for all API calls
|
||||
if (this.additionalQueryParams) {
|
||||
const params = new URLSearchParams(this.additionalQueryParams);
|
||||
const separator = url.search ? '&' : '?';
|
||||
url.search = url.search + `${separator}${params.toString()}`;
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async getDetailFromErrorResponse(
|
||||
res: Response
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail;
|
||||
} catch (error: any) {
|
||||
console.error('Unable to parse error response', error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private handleRequestError(error: any) {
|
||||
if (error instanceof ClientError) {
|
||||
if (error.status === 401 && this.on401) {
|
||||
this.on401();
|
||||
}
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level HTTP request handler for direct API interactions.
|
||||
* Provides full control over HTTP methods, request configuration, and error handling.
|
||||
*
|
||||
* Key features:
|
||||
* - Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE)
|
||||
* - Handles both FormData and JSON payloads
|
||||
* - Manages authentication headers
|
||||
* - Custom error handling with ClientError class
|
||||
* - Support for request cancellation via AbortSignal
|
||||
*
|
||||
* @param method - HTTP method to use (GET, POST, etc.)
|
||||
* @param path - API endpoint path
|
||||
* @param data - Optional request payload (FormData or JSON-serializable data)
|
||||
* @param signal - Optional AbortSignal for request cancellation
|
||||
* @returns Promise<Response>
|
||||
* @throws ClientError for HTTP errors, including 401 unauthorized
|
||||
*/
|
||||
async fetch(
|
||||
method: string,
|
||||
path: string,
|
||||
data?: Payload,
|
||||
signal?: AbortSignal,
|
||||
headers: { Authorization?: string; 'Content-Type'?: string } = {}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
let body;
|
||||
|
||||
if (data instanceof FormData) {
|
||||
body = data;
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = data ? JSON.stringify(data) : null;
|
||||
}
|
||||
|
||||
const res = await fetch(this.buildEndpoint(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
signal,
|
||||
body
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await this.getDetailFromErrorResponse(res);
|
||||
|
||||
throw new ClientError(res.statusText, res.status, detail);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
this.handleRequestError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get(endpoint: string) {
|
||||
return await this.fetch('GET', endpoint);
|
||||
}
|
||||
|
||||
async post(endpoint: string, data: Payload, signal?: AbortSignal) {
|
||||
return await this.fetch('POST', endpoint, data, signal);
|
||||
}
|
||||
|
||||
async put(endpoint: string, data: Payload) {
|
||||
return await this.fetch('PUT', endpoint, data);
|
||||
}
|
||||
|
||||
async patch(endpoint: string, data: Payload) {
|
||||
return await this.fetch('PATCH', endpoint, data);
|
||||
}
|
||||
|
||||
async delete(endpoint: string, data: Payload) {
|
||||
return await this.fetch('DELETE', endpoint, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChainlitAPI extends APIBase {
|
||||
async headerAuth() {
|
||||
const res = await this.post(`/auth/header`, {});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async jwtAuth(token: string) {
|
||||
const res = await this.fetch('POST', '/auth/jwt', undefined, undefined, {
|
||||
Authorization: `Bearer ${token}`
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async stickyCookie(sessionId: string) {
|
||||
const res = await this.fetch('POST', '/set-session-cookie', {
|
||||
session_id: sessionId
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async passwordAuth(data: FormData) {
|
||||
const res = await this.post(`/login`, data);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getUser(): Promise<IUser> {
|
||||
const res = await this.get(`/user`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const res = await this.post(`/logout`, {});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async setFeedback(
|
||||
feedback: IFeedback,
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; feedbackId: string }> {
|
||||
const res = await this.put(`/feedback`, { feedback, sessionId });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async deleteFeedback(feedbackId: string): Promise<{ success: boolean }> {
|
||||
const res = await this.delete(`/feedback`, { feedbackId });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async listThreads(
|
||||
pagination: IPagination,
|
||||
filter: IThreadFilters
|
||||
): Promise<{
|
||||
pageInfo: IPageInfo;
|
||||
data: IThread[];
|
||||
}> {
|
||||
const res = await this.post(`/project/threads`, { pagination, filter });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async renameThread(threadId: string, name: string) {
|
||||
const res = await this.put(`/project/thread`, { threadId, name });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async deleteThread(threadId: string) {
|
||||
const res = await this.delete(`/project/thread`, { threadId });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
uploadFile(
|
||||
file: File,
|
||||
onProgress: (progress: number) => void,
|
||||
sessionId: string,
|
||||
parentId?: string
|
||||
) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.withCredentials = true;
|
||||
|
||||
const promise = new Promise<{ id: string }>((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const ask_parent_id = parentId ? `&ask_parent_id=${parentId}` : '';
|
||||
xhr.open(
|
||||
'POST',
|
||||
this.buildEndpoint(
|
||||
`/project/file?session_id=${sessionId}${ask_parent_id}`
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
// Track the progress of the upload
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (event.lengthComputable) {
|
||||
const percentage = (event.loaded / event.total) * 100;
|
||||
onProgress(percentage);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
const contentType = xhr.getResponseHeader('Content-Type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
reject(response.detail);
|
||||
} else {
|
||||
reject('Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
reject('Upload error');
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
return { xhr, promise };
|
||||
}
|
||||
|
||||
async callAction(action: IAction, sessionId: string) {
|
||||
const res = await this.post(`/project/action`, { sessionId, action });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async updateElement(element: IElement, sessionId: string) {
|
||||
const res = await this.put(`/project/element`, { sessionId, element });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async deleteElement(element: IElement, sessionId: string) {
|
||||
const res = await this.delete(`/project/element`, { sessionId, element });
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async connectStdioMCP(sessionId: string, name: string, fullCommand: string) {
|
||||
const res = await this.post(`/mcp`, {
|
||||
sessionId,
|
||||
name,
|
||||
fullCommand,
|
||||
clientType: 'stdio'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async connectSseMCP(
|
||||
sessionId: string,
|
||||
name: string,
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
const res = await this.post(`/mcp`, {
|
||||
sessionId,
|
||||
name,
|
||||
url,
|
||||
...(headers ? { headers } : {}),
|
||||
clientType: 'sse'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async connectStreamableHttpMCP(
|
||||
sessionId: string,
|
||||
name: string,
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
const res = await this.post(`/mcp`, {
|
||||
sessionId,
|
||||
name,
|
||||
url,
|
||||
...(headers ? { headers } : {}),
|
||||
clientType: 'streamable-http'
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async disconnectMcp(sessionId: string, name: string) {
|
||||
const res = await this.delete(`/mcp`, { sessionId, name });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
getElementUrl(id: string, sessionId: string) {
|
||||
const queryParams = `?session_id=${sessionId}`;
|
||||
return this.buildEndpoint(`/project/file/${id}${queryParams}`);
|
||||
}
|
||||
|
||||
getLogoEndpoint(theme: string, configuredLogoUrl?: string) {
|
||||
if (configuredLogoUrl) return configuredLogoUrl;
|
||||
return this.buildEndpoint(`/logo?theme=${theme}`);
|
||||
}
|
||||
|
||||
getOAuthEndpoint(provider: string) {
|
||||
return this.buildEndpoint(`/auth/oauth/${provider}`);
|
||||
}
|
||||
async shareThread(
|
||||
threadId: string,
|
||||
isShared: boolean
|
||||
): Promise<{ success: boolean }> {
|
||||
const res = await this.put(`/project/thread/share`, {
|
||||
threadId,
|
||||
isShared
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import { ChainlitAPI } from './api';
|
||||
|
||||
const defaultChainlitContext = undefined;
|
||||
|
||||
const ChainlitContext = createContext<ChainlitAPI>(
|
||||
new ChainlitAPI('http://localhost:8000', 'webapp')
|
||||
);
|
||||
|
||||
export { ChainlitContext, defaultChainlitContext };
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './useChatData';
|
||||
export * from './useChatInteract';
|
||||
export * from './useChatMessages';
|
||||
export * from './useChatSession';
|
||||
export * from './useAudio';
|
||||
export * from './useConfig';
|
||||
export * from './api';
|
||||
export * from './types';
|
||||
export * from './context';
|
||||
export * from './state';
|
||||
export * from './utils/message';
|
||||
|
||||
export { Socket } from 'socket.io-client';
|
||||
|
||||
export { WavRenderer } from './wavtools/wav_renderer';
|
||||
@@ -0,0 +1,278 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { AtomEffect, DefaultValue, atom, selector } from 'recoil';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ICommand } from './types/command';
|
||||
import { IMode } from './types/mode';
|
||||
|
||||
import {
|
||||
IAction,
|
||||
IAsk,
|
||||
IAuthConfig,
|
||||
ICallFn,
|
||||
IChainlitConfig,
|
||||
IMcp,
|
||||
IMessageElement,
|
||||
IStep,
|
||||
ITasklistElement,
|
||||
IUser,
|
||||
ThreadHistory
|
||||
} from './types';
|
||||
import { groupByDate } from './utils/group';
|
||||
import { WavRecorder, WavStreamPlayer } from './wavtools';
|
||||
|
||||
export interface ISession {
|
||||
socket: Socket;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const threadIdToResumeState = atom<string | undefined>({
|
||||
key: 'ThreadIdToResume',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const resumeThreadErrorState = atom<string | undefined>({
|
||||
key: 'ResumeThreadErrorState',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const chatProfileState = atom<string | undefined>({
|
||||
key: 'ChatProfile',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const sessionIdAtom = atom<string>({
|
||||
key: 'SessionId',
|
||||
default: uuidv4()
|
||||
});
|
||||
|
||||
export const sessionIdState = selector({
|
||||
key: 'SessionIdSelector',
|
||||
get: ({ get }) => get(sessionIdAtom),
|
||||
set: ({ set }, newValue) =>
|
||||
set(sessionIdAtom, newValue instanceof DefaultValue ? uuidv4() : newValue)
|
||||
});
|
||||
|
||||
export const sessionState = atom<ISession | undefined>({
|
||||
key: 'Session',
|
||||
dangerouslyAllowMutability: true,
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const actionState = atom<IAction[]>({
|
||||
key: 'Actions',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const messagesState = atom<IStep[]>({
|
||||
key: 'Messages',
|
||||
dangerouslyAllowMutability: true,
|
||||
default: []
|
||||
});
|
||||
|
||||
export const commandsState = atom<ICommand[]>({
|
||||
key: 'Commands',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const modesState = atom<IMode[]>({
|
||||
key: 'Modes',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const tokenCountState = atom<number>({
|
||||
key: 'TokenCount',
|
||||
default: 0
|
||||
});
|
||||
|
||||
export const loadingState = atom<boolean>({
|
||||
key: 'Loading',
|
||||
default: false
|
||||
});
|
||||
|
||||
export const askUserState = atom<IAsk | undefined>({
|
||||
key: 'AskUser',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const wavRecorderState = atom({
|
||||
key: 'WavRecorder',
|
||||
dangerouslyAllowMutability: true,
|
||||
default: new WavRecorder()
|
||||
});
|
||||
|
||||
export const wavStreamPlayerState = atom({
|
||||
key: 'WavStreamPlayer',
|
||||
dangerouslyAllowMutability: true,
|
||||
default: new WavStreamPlayer()
|
||||
});
|
||||
|
||||
export const audioConnectionState = atom<'connecting' | 'on' | 'off'>({
|
||||
key: 'AudioConnection',
|
||||
default: 'off'
|
||||
});
|
||||
|
||||
export const isAiSpeakingState = atom({
|
||||
key: 'isAiSpeaking',
|
||||
default: false
|
||||
});
|
||||
|
||||
export const callFnState = atom<ICallFn | undefined>({
|
||||
key: 'CallFn',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const chatSettingsInputsState = atom<any>({
|
||||
key: 'ChatSettings',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const chatSettingsDefaultValueSelector = selector({
|
||||
key: 'ChatSettingsValue/Default',
|
||||
get: ({ get }) => {
|
||||
const chatSettings = get(chatSettingsInputsState);
|
||||
|
||||
const collectInitialValues = (
|
||||
inputs: any[],
|
||||
acc: Record<string, any>
|
||||
): Record<string, any> => {
|
||||
if (!Array.isArray(inputs)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
inputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(input?.inputs) && input.inputs.length > 0) {
|
||||
// Handle tabs
|
||||
collectInitialValues(input.inputs, acc);
|
||||
} else if (input?.id !== undefined) {
|
||||
acc[input.id] = input.initial;
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
return collectInitialValues(chatSettings, {});
|
||||
}
|
||||
});
|
||||
|
||||
export const chatSettingsValueState = atom<Record<string, any>>({
|
||||
key: 'ChatSettingsValue',
|
||||
default: chatSettingsDefaultValueSelector
|
||||
});
|
||||
|
||||
export const elementState = atom<IMessageElement[]>({
|
||||
key: 'DisplayElements',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const tasklistState = atom<ITasklistElement[]>({
|
||||
key: 'TasklistElements',
|
||||
default: []
|
||||
});
|
||||
|
||||
export const firstUserInteraction = atom<string | undefined>({
|
||||
key: 'FirstUserInteraction',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const userState = atom<IUser | undefined | null>({
|
||||
key: 'User',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const configState = atom<IChainlitConfig | undefined>({
|
||||
key: 'ChainlitConfig',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const authState = atom<IAuthConfig | undefined>({
|
||||
key: 'AuthConfig',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const threadHistoryState = atom<ThreadHistory | undefined>({
|
||||
key: 'ThreadHistory',
|
||||
default: {
|
||||
threads: undefined,
|
||||
currentThreadId: undefined,
|
||||
timeGroupedThreads: undefined,
|
||||
pageInfo: undefined
|
||||
},
|
||||
effects: [
|
||||
({ setSelf, onSet }: { setSelf: any; onSet: any }) => {
|
||||
onSet(
|
||||
(
|
||||
newValue: ThreadHistory | undefined,
|
||||
oldValue: ThreadHistory | undefined
|
||||
) => {
|
||||
let timeGroupedThreads = newValue?.timeGroupedThreads;
|
||||
if (
|
||||
newValue?.threads &&
|
||||
!isEqual(newValue.threads, oldValue?.timeGroupedThreads)
|
||||
) {
|
||||
timeGroupedThreads = groupByDate(newValue.threads);
|
||||
}
|
||||
|
||||
setSelf({
|
||||
...newValue,
|
||||
timeGroupedThreads
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const sideViewState = atom<
|
||||
{ title: string; elements: IMessageElement[]; key?: string } | undefined
|
||||
>({
|
||||
key: 'SideView',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
export const currentThreadIdState = atom<string | undefined>({
|
||||
key: 'CurrentThreadId',
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const localStorageEffect =
|
||||
<T>(key: string): AtomEffect<T> =>
|
||||
({ setSelf, onSet }) => {
|
||||
// When the atom is first initialized, try to get its value from localStorage
|
||||
const savedValue = localStorage.getItem(key);
|
||||
if (savedValue != null) {
|
||||
try {
|
||||
setSelf(JSON.parse(savedValue));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error parsing localStorage value for key "${key}":`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to state changes and update localStorage
|
||||
onSet((newValue, _, isReset) => {
|
||||
if (isReset) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const mcpState = atom<IMcp[]>({
|
||||
key: 'Mcp',
|
||||
default: [],
|
||||
effects: [localStorageEffect<IMcp[]>('mcp_storage_key')]
|
||||
});
|
||||
|
||||
export const favoriteMessagesState = atom<IStep[]>({
|
||||
key: 'favoriteMessagesState',
|
||||
default: []
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface IAction {
|
||||
label: string;
|
||||
forId: string;
|
||||
id: string;
|
||||
payload: Record<string, unknown>;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ICallFn {
|
||||
callback: (payload: Record<string, any>) => void;
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface OutputAudioChunk {
|
||||
track: string;
|
||||
mimeType: string;
|
||||
data: Int16Array;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ICommand {
|
||||
id: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
button?: boolean;
|
||||
persistent?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface IStarter {
|
||||
label: string;
|
||||
message: string;
|
||||
icon?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface IStarterCategory {
|
||||
label: string;
|
||||
icon?: string;
|
||||
starters: IStarter[];
|
||||
}
|
||||
|
||||
export interface ChatProfile {
|
||||
default: boolean;
|
||||
icon?: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
markdown_description: string;
|
||||
starters?: IStarter[];
|
||||
}
|
||||
|
||||
export interface IAudioConfig {
|
||||
enabled: boolean;
|
||||
sample_rate: number;
|
||||
}
|
||||
|
||||
export interface IAuthConfig {
|
||||
requireLogin: boolean;
|
||||
passwordAuth: boolean;
|
||||
headerAuth: boolean;
|
||||
oauthProviders: string[];
|
||||
default_theme?: 'light' | 'dark';
|
||||
ui?: IChainlitConfig['ui'];
|
||||
}
|
||||
|
||||
export interface IChainlitConfig {
|
||||
markdown?: string;
|
||||
ui: {
|
||||
name: string;
|
||||
description?: string;
|
||||
default_theme?: 'light' | 'dark';
|
||||
layout?: 'default' | 'wide';
|
||||
default_sidebar_state?: 'open' | 'closed' | 'hidden';
|
||||
chat_settings_location?: 'message_composer' | 'sidebar';
|
||||
default_chat_settings_open?: boolean;
|
||||
confirm_new_chat?: boolean;
|
||||
cot: 'hidden' | 'tool_call' | 'full';
|
||||
github?: string;
|
||||
custom_css?: string;
|
||||
custom_js?: string;
|
||||
custom_font?: string;
|
||||
alert_style?: 'classic' | 'modern';
|
||||
login_page_image?: string;
|
||||
login_page_image_filter?: string;
|
||||
login_page_image_dark_filter?: string;
|
||||
custom_meta_image_url?: string;
|
||||
logo_file_url?: string;
|
||||
default_avatar_file_url?: string;
|
||||
avatar_size?: number;
|
||||
header_links?: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icon_url: string;
|
||||
url: string;
|
||||
target?: '_blank' | '_self' | '_parent' | '_top';
|
||||
}[];
|
||||
};
|
||||
features: {
|
||||
spontaneous_file_upload?: {
|
||||
enabled?: boolean;
|
||||
max_size_mb?: number;
|
||||
max_files?: number;
|
||||
accept?: string[] | Record<string, string[]>;
|
||||
};
|
||||
audio: IAudioConfig;
|
||||
unsafe_allow_html?: boolean;
|
||||
user_message_autoscroll?: boolean;
|
||||
assistant_message_autoscroll?: boolean;
|
||||
latex?: boolean;
|
||||
user_message_markdown?: boolean;
|
||||
edit_message?: boolean;
|
||||
favorites?: boolean;
|
||||
mcp?: {
|
||||
enabled?: boolean;
|
||||
sse?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
streamable_http?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
stdio?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
debugUrl?: string;
|
||||
userEnv: string[];
|
||||
maskUserEnv?: boolean;
|
||||
dataPersistence: boolean;
|
||||
threadResumable: boolean;
|
||||
threadSharing?: boolean;
|
||||
chatProfiles: ChatProfile[];
|
||||
starters?: IStarter[];
|
||||
starterCategories?: IStarterCategory[];
|
||||
|
||||
translation: object;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type IElement =
|
||||
| IImageElement
|
||||
| ITextElement
|
||||
| IPdfElement
|
||||
| ITasklistElement
|
||||
| IAudioElement
|
||||
| IVideoElement
|
||||
| IFileElement
|
||||
| IPlotlyElement
|
||||
| IDataframeElement
|
||||
| ICustomElement;
|
||||
|
||||
export type IMessageElement =
|
||||
| IImageElement
|
||||
| ITextElement
|
||||
| IPdfElement
|
||||
| IAudioElement
|
||||
| IVideoElement
|
||||
| IFileElement
|
||||
| IPlotlyElement
|
||||
| IDataframeElement
|
||||
| ICustomElement;
|
||||
|
||||
export type ElementType = IElement['type'];
|
||||
export type IElementSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface TElement<T> {
|
||||
id: string;
|
||||
type: T;
|
||||
threadId?: string;
|
||||
forId: string;
|
||||
mime?: string;
|
||||
url?: string;
|
||||
chainlitKey?: string;
|
||||
}
|
||||
|
||||
interface TMessageElement<T> extends TElement<T> {
|
||||
name: string;
|
||||
display: 'inline' | 'side' | 'page';
|
||||
}
|
||||
|
||||
export interface IImageElement extends TMessageElement<'image'> {
|
||||
size?: IElementSize;
|
||||
}
|
||||
|
||||
export interface ITextElement extends TMessageElement<'text'> {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface IPdfElement extends TMessageElement<'pdf'> {
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface IAudioElement extends TMessageElement<'audio'> {
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
export interface IVideoElement extends TMessageElement<'video'> {
|
||||
size?: IElementSize;
|
||||
|
||||
/**
|
||||
* Override settings for each type of player in ReactPlayer
|
||||
* https://github.com/cookpete/react-player?tab=readme-ov-file#config-prop
|
||||
* @type {object}
|
||||
*/
|
||||
playerConfig?: object;
|
||||
}
|
||||
|
||||
export interface IFileElement extends TMessageElement<'file'> {
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
export type IPlotlyElement = TMessageElement<'plotly'>;
|
||||
|
||||
export type ITasklistElement = TElement<'tasklist'>;
|
||||
|
||||
export type IDataframeElement = TMessageElement<'dataframe'>;
|
||||
|
||||
export interface ICustomElement extends TMessageElement<'custom'> {
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface IFeedback {
|
||||
id?: string;
|
||||
forId?: string;
|
||||
threadId?: string;
|
||||
comment?: string;
|
||||
value: number;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IAction } from './action';
|
||||
import { IStep } from './step';
|
||||
|
||||
export interface IAskElementResponse {
|
||||
submitted: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FileSpec {
|
||||
accept?: string[] | Record<string, string[]>;
|
||||
max_size_mb?: number;
|
||||
max_files?: number;
|
||||
}
|
||||
|
||||
export interface ActionSpec {
|
||||
keys?: string[];
|
||||
}
|
||||
|
||||
export interface IFileRef {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IAsk {
|
||||
callback: (
|
||||
payload: IStep | IFileRef[] | IAction | IAskElementResponse
|
||||
) => void;
|
||||
spec: {
|
||||
type: 'text' | 'file' | 'action' | 'element';
|
||||
step_id: string;
|
||||
timeout: number;
|
||||
element_id?: string;
|
||||
} & FileSpec &
|
||||
ActionSpec;
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IThread } from 'src/types';
|
||||
|
||||
import { IPageInfo } from '..';
|
||||
|
||||
export type UserInput = {
|
||||
content: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type ThreadHistory = {
|
||||
threads?: IThread[];
|
||||
currentThreadId?: string;
|
||||
timeGroupedThreads?: { [key: string]: IThread[] };
|
||||
pageInfo?: IPageInfo;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './action';
|
||||
export * from './element';
|
||||
export * from './command';
|
||||
export * from './mode';
|
||||
export * from './file';
|
||||
export * from './feedback';
|
||||
export * from './step';
|
||||
export * from './user';
|
||||
export * from './thread';
|
||||
export * from './history';
|
||||
export * from './config';
|
||||
export * from './mcp';
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface IMcp {
|
||||
name: string;
|
||||
tools: [{ name: string }];
|
||||
status: 'connected' | 'connecting' | 'failed';
|
||||
clientType: 'sse' | 'stdio' | 'streamable-http';
|
||||
command?: string;
|
||||
url?: string;
|
||||
/** Optional HTTP headers used when connecting (SSE or streamable-http) */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Represents a single selectable option within a mode.
|
||||
*/
|
||||
export interface IModeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a mode category containing multiple selectable options.
|
||||
* Examples: Model selection, Reasoning Effort, Approach preference, etc.
|
||||
*/
|
||||
export interface IMode {
|
||||
id: string;
|
||||
name: string;
|
||||
options: IModeOption[];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { IFeedback } from './feedback';
|
||||
|
||||
type StepType =
|
||||
| 'assistant_message'
|
||||
| 'user_message'
|
||||
| 'system_message'
|
||||
| 'run'
|
||||
| 'tool'
|
||||
| 'llm'
|
||||
| 'embedding'
|
||||
| 'retrieval'
|
||||
| 'rerank'
|
||||
| 'undefined';
|
||||
|
||||
export interface IStep {
|
||||
id: string;
|
||||
name: string;
|
||||
type: StepType;
|
||||
threadId?: string;
|
||||
parentId?: string;
|
||||
isError?: boolean;
|
||||
command?: string;
|
||||
modes?: Record<string, string>;
|
||||
showInput?: boolean | string;
|
||||
waitForAnswer?: boolean;
|
||||
input?: string;
|
||||
output: string;
|
||||
createdAt: number | string;
|
||||
start?: number | string;
|
||||
end?: number | string;
|
||||
feedback?: IFeedback;
|
||||
language?: string;
|
||||
defaultOpen?: boolean;
|
||||
autoCollapse?: boolean;
|
||||
streaming?: boolean;
|
||||
steps?: IStep[];
|
||||
metadata?: Record<string, any>;
|
||||
//legacy
|
||||
indent?: number;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IElement } from './element';
|
||||
import { IStep } from './step';
|
||||
|
||||
export interface IThread {
|
||||
id: string;
|
||||
createdAt: number | string;
|
||||
name?: string;
|
||||
userId?: string;
|
||||
userIdentifier?: string;
|
||||
metadata?: Record<string, any>;
|
||||
steps: IStep[];
|
||||
elements?: IElement[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type AuthProvider =
|
||||
| 'credentials'
|
||||
| 'header'
|
||||
| 'github'
|
||||
| 'google'
|
||||
| 'azure-ad'
|
||||
| 'azure-ad-hybrid';
|
||||
|
||||
export interface IUserMetadata extends Record<string, any> {
|
||||
tags?: string[];
|
||||
image?: string;
|
||||
provider?: AuthProvider;
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
identifier: string;
|
||||
display_name?: string;
|
||||
metadata: IUserMetadata;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import {
|
||||
audioConnectionState,
|
||||
isAiSpeakingState,
|
||||
wavRecorderState,
|
||||
wavStreamPlayerState
|
||||
} from './state';
|
||||
import { useChatInteract } from './useChatInteract';
|
||||
|
||||
const useAudio = () => {
|
||||
const [audioConnection, setAudioConnection] =
|
||||
useRecoilState(audioConnectionState);
|
||||
const wavRecorder = useRecoilValue(wavRecorderState);
|
||||
const wavStreamPlayer = useRecoilValue(wavStreamPlayerState);
|
||||
const isAiSpeaking = useRecoilValue(isAiSpeakingState);
|
||||
|
||||
const { startAudioStream, endAudioStream } = useChatInteract();
|
||||
|
||||
const startConversation = useCallback(async () => {
|
||||
setAudioConnection('connecting');
|
||||
await startAudioStream();
|
||||
}, [startAudioStream]);
|
||||
|
||||
const endConversation = useCallback(async () => {
|
||||
setAudioConnection('off');
|
||||
await wavRecorder.end();
|
||||
await wavStreamPlayer.interrupt();
|
||||
await endAudioStream();
|
||||
}, [endAudioStream, wavRecorder, wavStreamPlayer]);
|
||||
|
||||
return {
|
||||
startConversation,
|
||||
endConversation,
|
||||
audioConnection,
|
||||
isAiSpeaking,
|
||||
wavRecorder,
|
||||
wavStreamPlayer
|
||||
};
|
||||
};
|
||||
|
||||
export { useAudio };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import {
|
||||
actionState,
|
||||
askUserState,
|
||||
callFnState,
|
||||
chatSettingsDefaultValueSelector,
|
||||
chatSettingsInputsState,
|
||||
chatSettingsValueState,
|
||||
elementState,
|
||||
loadingState,
|
||||
sessionState,
|
||||
tasklistState
|
||||
} from './state';
|
||||
|
||||
export interface IToken {
|
||||
id: number | string;
|
||||
token: string;
|
||||
isSequence: boolean;
|
||||
isInput: boolean;
|
||||
}
|
||||
|
||||
const useChatData = () => {
|
||||
const loading = useRecoilValue(loadingState);
|
||||
const elements = useRecoilValue(elementState);
|
||||
const tasklists = useRecoilValue(tasklistState);
|
||||
const actions = useRecoilValue(actionState);
|
||||
const session = useRecoilValue(sessionState);
|
||||
const askUser = useRecoilValue(askUserState);
|
||||
const callFn = useRecoilValue(callFnState);
|
||||
const chatSettingsInputs = useRecoilValue(chatSettingsInputsState);
|
||||
const chatSettingsValue = useRecoilValue(chatSettingsValueState);
|
||||
const chatSettingsDefaultValue = useRecoilValue(
|
||||
chatSettingsDefaultValueSelector
|
||||
);
|
||||
|
||||
const connected = session?.socket.connected && !session?.error;
|
||||
const disabled =
|
||||
!connected ||
|
||||
loading ||
|
||||
askUser?.spec.type === 'file' ||
|
||||
askUser?.spec.type === 'action' ||
|
||||
askUser?.spec.type === 'element';
|
||||
|
||||
return {
|
||||
actions,
|
||||
askUser,
|
||||
callFn,
|
||||
chatSettingsDefaultValue,
|
||||
chatSettingsInputs,
|
||||
chatSettingsValue,
|
||||
connected,
|
||||
disabled,
|
||||
elements,
|
||||
error: session?.error,
|
||||
loading,
|
||||
tasklists
|
||||
};
|
||||
};
|
||||
|
||||
export { useChatData };
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
actionState,
|
||||
askUserState,
|
||||
chatSettingsInputsState,
|
||||
chatSettingsValueState,
|
||||
currentThreadIdState,
|
||||
elementState,
|
||||
favoriteMessagesState,
|
||||
firstUserInteraction,
|
||||
loadingState,
|
||||
messagesState,
|
||||
sessionIdState,
|
||||
sessionState,
|
||||
sideViewState,
|
||||
tasklistState,
|
||||
threadIdToResumeState,
|
||||
tokenCountState
|
||||
} from 'src/state';
|
||||
import { IFileRef, IStep } from 'src/types';
|
||||
import { addMessage } from 'src/utils/message';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ChainlitContext } from './context';
|
||||
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
const useChatInteract = () => {
|
||||
const client = useContext(ChainlitContext);
|
||||
const session = useRecoilValue(sessionState);
|
||||
const askUser = useRecoilValue(askUserState);
|
||||
const sessionId = useRecoilValue(sessionIdState);
|
||||
|
||||
const resetChatSettings = useResetRecoilState(chatSettingsInputsState);
|
||||
const resetSessionId = useResetRecoilState(sessionIdState);
|
||||
const resetChatSettingsValue = useResetRecoilState(chatSettingsValueState);
|
||||
|
||||
const setFirstUserInteraction = useSetRecoilState(firstUserInteraction);
|
||||
const setLoading = useSetRecoilState(loadingState);
|
||||
const setMessages = useSetRecoilState(messagesState);
|
||||
const setElements = useSetRecoilState(elementState);
|
||||
const setTasklists = useSetRecoilState(tasklistState);
|
||||
const setActions = useSetRecoilState(actionState);
|
||||
const setTokenCount = useSetRecoilState(tokenCountState);
|
||||
const setIdToResume = useSetRecoilState(threadIdToResumeState);
|
||||
const setSideView = useSetRecoilState(sideViewState);
|
||||
const setCurrentThreadId = useSetRecoilState(currentThreadIdState);
|
||||
const setFavoriteMessages = useSetRecoilState(favoriteMessagesState);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
session?.socket.emit('clear_session');
|
||||
session?.socket.disconnect();
|
||||
setIdToResume(undefined);
|
||||
resetSessionId();
|
||||
setFirstUserInteraction(undefined);
|
||||
setMessages([]);
|
||||
setElements([]);
|
||||
setTasklists([]);
|
||||
setActions([]);
|
||||
setTokenCount(0);
|
||||
resetChatSettings();
|
||||
resetChatSettingsValue();
|
||||
setSideView(undefined);
|
||||
setCurrentThreadId(undefined);
|
||||
}, [session]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(
|
||||
message: PartialBy<IStep, 'createdAt' | 'id'>,
|
||||
fileReferences: IFileRef[] = []
|
||||
) => {
|
||||
if (!message.id) {
|
||||
message.id = uuidv4();
|
||||
}
|
||||
if (!message.createdAt) {
|
||||
message.createdAt = new Date().toISOString();
|
||||
}
|
||||
setMessages((oldMessages) => addMessage(oldMessages, message as IStep));
|
||||
|
||||
session?.socket.emit('client_message', { message, fileReferences });
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const editMessage = useCallback(
|
||||
(message: IStep) => {
|
||||
session?.socket.emit('edit_message', { message });
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const toggleMessageFavorite = useCallback(
|
||||
(message: IStep) => {
|
||||
const favorite = !(message.metadata?.favorite ?? false);
|
||||
const updatedMetadata = {
|
||||
...(message.metadata || {}),
|
||||
favorite
|
||||
};
|
||||
|
||||
setMessages((oldMessages) =>
|
||||
oldMessages.map((item) =>
|
||||
item.id === message.id
|
||||
? { ...item, metadata: { ...(item.metadata || {}), favorite } }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
const nextMessage: IStep = {
|
||||
...message,
|
||||
metadata: updatedMetadata
|
||||
};
|
||||
|
||||
setFavoriteMessages((oldFavorites) => {
|
||||
if (favorite) {
|
||||
const filtered = oldFavorites.filter(
|
||||
(step) => step.id !== message.id
|
||||
);
|
||||
return [nextMessage, ...filtered];
|
||||
}
|
||||
return oldFavorites.filter((step) => step.id !== message.id);
|
||||
});
|
||||
|
||||
session?.socket.emit('message_favorite', { message: nextMessage });
|
||||
},
|
||||
[session?.socket, setFavoriteMessages, setMessages]
|
||||
);
|
||||
|
||||
const windowMessage = useCallback(
|
||||
(data: any) => {
|
||||
session?.socket.emit('window_message', data);
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const startAudioStream = useCallback(() => {
|
||||
session?.socket.emit('audio_start');
|
||||
}, [session?.socket]);
|
||||
|
||||
const sendAudioChunk = useCallback(
|
||||
(
|
||||
isStart: boolean,
|
||||
mimeType: string,
|
||||
elapsedTime: number,
|
||||
data: Int16Array
|
||||
) => {
|
||||
session?.socket.emit('audio_chunk', {
|
||||
isStart,
|
||||
mimeType,
|
||||
elapsedTime,
|
||||
data
|
||||
});
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const endAudioStream = useCallback(() => {
|
||||
session?.socket.emit('audio_end');
|
||||
}, [session?.socket]);
|
||||
|
||||
const replyMessage = useCallback(
|
||||
(message: IStep) => {
|
||||
if (askUser) {
|
||||
if (askUser.parentId) message.parentId = askUser.parentId;
|
||||
setMessages((oldMessages) => addMessage(oldMessages, message));
|
||||
askUser.callback(message);
|
||||
}
|
||||
},
|
||||
[askUser]
|
||||
);
|
||||
|
||||
const updateChatSettings = useCallback(
|
||||
(values: object) => {
|
||||
session?.socket.emit('chat_settings_change', values);
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const editChatSettings = useCallback(
|
||||
(values: object) => {
|
||||
session?.socket.emit('chat_settings_edit', values);
|
||||
},
|
||||
[session?.socket]
|
||||
);
|
||||
|
||||
const stopTask = useCallback(() => {
|
||||
setMessages((oldMessages) =>
|
||||
oldMessages.map((m) => {
|
||||
m.streaming = false;
|
||||
return m;
|
||||
})
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
session?.socket.emit('stop');
|
||||
}, [session?.socket]);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
(file: File, onProgress: (progress: number) => void, parentId?: string) => {
|
||||
return client.uploadFile(file, onProgress, sessionId, parentId);
|
||||
},
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
return {
|
||||
uploadFile,
|
||||
clear,
|
||||
replyMessage,
|
||||
sendMessage,
|
||||
editMessage,
|
||||
windowMessage,
|
||||
startAudioStream,
|
||||
sendAudioChunk,
|
||||
endAudioStream,
|
||||
stopTask,
|
||||
setIdToResume,
|
||||
updateChatSettings,
|
||||
editChatSettings,
|
||||
toggleMessageFavorite
|
||||
};
|
||||
};
|
||||
|
||||
export { useChatInteract };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import {
|
||||
currentThreadIdState,
|
||||
firstUserInteraction,
|
||||
messagesState
|
||||
} from './state';
|
||||
|
||||
const useChatMessages = () => {
|
||||
const messages = useRecoilValue(messagesState);
|
||||
const firstInteraction = useRecoilValue(firstUserInteraction);
|
||||
const threadId = useRecoilValue(currentThreadIdState);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
messages,
|
||||
firstInteraction
|
||||
};
|
||||
};
|
||||
|
||||
export { useChatMessages };
|
||||
@@ -0,0 +1,519 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import {
|
||||
useRecoilState,
|
||||
useRecoilValue,
|
||||
useResetRecoilState,
|
||||
useSetRecoilState
|
||||
} from 'recoil';
|
||||
import io from 'socket.io-client';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
actionState,
|
||||
askUserState,
|
||||
audioConnectionState,
|
||||
callFnState,
|
||||
chatProfileState,
|
||||
chatSettingsInputsState,
|
||||
chatSettingsValueState,
|
||||
commandsState,
|
||||
currentThreadIdState,
|
||||
elementState,
|
||||
favoriteMessagesState,
|
||||
firstUserInteraction,
|
||||
isAiSpeakingState,
|
||||
loadingState,
|
||||
mcpState,
|
||||
messagesState,
|
||||
modesState,
|
||||
resumeThreadErrorState,
|
||||
sessionIdState,
|
||||
sessionState,
|
||||
sideViewState,
|
||||
tasklistState,
|
||||
threadIdToResumeState,
|
||||
tokenCountState,
|
||||
wavRecorderState,
|
||||
wavStreamPlayerState
|
||||
} from 'src/state';
|
||||
import {
|
||||
IAction,
|
||||
ICommand,
|
||||
IElement,
|
||||
IMessageElement,
|
||||
IMode,
|
||||
IStep,
|
||||
ITasklistElement,
|
||||
IThread
|
||||
} from 'src/types';
|
||||
import {
|
||||
addMessage,
|
||||
deleteMessageById,
|
||||
updateMessageById,
|
||||
updateMessageContentById
|
||||
} from 'src/utils/message';
|
||||
|
||||
import { OutputAudioChunk } from './types/audio';
|
||||
|
||||
import { ChainlitContext } from './context';
|
||||
import type { IToken } from './useChatData';
|
||||
|
||||
const useChatSession = () => {
|
||||
const client = useContext(ChainlitContext);
|
||||
const sessionId = useRecoilValue(sessionIdState);
|
||||
|
||||
const [session, setSession] = useRecoilState(sessionState);
|
||||
const setIsAiSpeaking = useSetRecoilState(isAiSpeakingState);
|
||||
const setAudioConnection = useSetRecoilState(audioConnectionState);
|
||||
const resetChatSettingsValue = useResetRecoilState(chatSettingsValueState);
|
||||
const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);
|
||||
const setFirstUserInteraction = useSetRecoilState(firstUserInteraction);
|
||||
const setLoading = useSetRecoilState(loadingState);
|
||||
const setMcps = useSetRecoilState(mcpState);
|
||||
const wavStreamPlayer = useRecoilValue(wavStreamPlayerState);
|
||||
const wavRecorder = useRecoilValue(wavRecorderState);
|
||||
const setMessages = useSetRecoilState(messagesState);
|
||||
const setAskUser = useSetRecoilState(askUserState);
|
||||
const setCallFn = useSetRecoilState(callFnState);
|
||||
const setCommands = useSetRecoilState(commandsState);
|
||||
const setModes = useSetRecoilState(modesState);
|
||||
const setSideView = useSetRecoilState(sideViewState);
|
||||
const setElements = useSetRecoilState(elementState);
|
||||
const setTasklists = useSetRecoilState(tasklistState);
|
||||
const setActions = useSetRecoilState(actionState);
|
||||
const setChatSettingsInputs = useSetRecoilState(chatSettingsInputsState);
|
||||
const setTokenCount = useSetRecoilState(tokenCountState);
|
||||
const [chatProfile, setChatProfile] = useRecoilState(chatProfileState);
|
||||
const idToResume = useRecoilValue(threadIdToResumeState);
|
||||
const setThreadResumeError = useSetRecoilState(resumeThreadErrorState);
|
||||
const setFavoriteMessages = useSetRecoilState(favoriteMessagesState);
|
||||
|
||||
const [currentThreadId, setCurrentThreadId] =
|
||||
useRecoilState(currentThreadIdState);
|
||||
|
||||
// Use currentThreadId as thread id in websocket header
|
||||
useEffect(() => {
|
||||
if (session?.socket) {
|
||||
session.socket.auth['threadId'] = currentThreadId || '';
|
||||
}
|
||||
}, [currentThreadId]);
|
||||
|
||||
const _connect = useCallback(
|
||||
async ({
|
||||
transports,
|
||||
userEnv
|
||||
}: {
|
||||
transports?: string[];
|
||||
userEnv: Record<string, string>;
|
||||
}) => {
|
||||
const { protocol, host, pathname } = new URL(client.httpEndpoint);
|
||||
const uri = `${protocol}//${host}`;
|
||||
const path =
|
||||
pathname && pathname !== '/'
|
||||
? `${pathname}/ws/socket.io`
|
||||
: '/ws/socket.io';
|
||||
|
||||
try {
|
||||
await client.stickyCookie(sessionId);
|
||||
} catch (err) {
|
||||
console.error(`Failed to set sticky session cookie: ${err}`);
|
||||
}
|
||||
|
||||
const socket = io(uri, {
|
||||
path,
|
||||
withCredentials: true,
|
||||
transports,
|
||||
auth: {
|
||||
clientType: client.type,
|
||||
sessionId,
|
||||
threadId: idToResume || '',
|
||||
userEnv: JSON.stringify(userEnv),
|
||||
chatProfile: chatProfile ? encodeURIComponent(chatProfile) : ''
|
||||
}
|
||||
});
|
||||
setSession((old) => {
|
||||
old?.socket?.removeAllListeners();
|
||||
old?.socket?.close();
|
||||
return {
|
||||
socket
|
||||
};
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('connection_successful');
|
||||
setSession((s) => ({ ...s!, error: false }));
|
||||
socket.emit('fetch_favorites');
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) => {
|
||||
let promise;
|
||||
if (mcp.clientType === 'sse') {
|
||||
promise = client.connectSseMCP(sessionId, mcp.name, mcp.url!);
|
||||
} else if (mcp.clientType === 'streamable-http') {
|
||||
promise = client.connectStreamableHttpMCP(
|
||||
sessionId,
|
||||
mcp.name,
|
||||
mcp.url!,
|
||||
mcp.headers || {}
|
||||
);
|
||||
} else {
|
||||
promise = client.connectStdioMCP(
|
||||
sessionId,
|
||||
mcp.name,
|
||||
mcp.command!
|
||||
);
|
||||
}
|
||||
promise
|
||||
.then(async ({ success, mcp }) => {
|
||||
setMcps((prev) =>
|
||||
prev.map((existingMcp) => {
|
||||
if (existingMcp.name === mcp.name) {
|
||||
return {
|
||||
...existingMcp,
|
||||
status: success ? 'connected' : 'failed',
|
||||
tools: mcp ? mcp.tools : existingMcp.tools
|
||||
};
|
||||
}
|
||||
return existingMcp;
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setMcps((prev) =>
|
||||
prev.map((existingMcp) => {
|
||||
if (existingMcp.name === mcp.name) {
|
||||
return {
|
||||
...existingMcp,
|
||||
status: 'failed'
|
||||
};
|
||||
}
|
||||
return existingMcp;
|
||||
})
|
||||
);
|
||||
});
|
||||
return { ...mcp, status: 'connecting' };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (_) => {
|
||||
setSession((s) => ({ ...s!, error: true }));
|
||||
});
|
||||
|
||||
socket.on('task_start', () => {
|
||||
setLoading(true);
|
||||
});
|
||||
|
||||
socket.on('task_end', () => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
socket.on('reload', () => {
|
||||
socket.emit('clear_session');
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
socket.on('audio_connection', async (state: 'on' | 'off') => {
|
||||
if (state === 'on') {
|
||||
let isFirstChunk = true;
|
||||
const startTime = Date.now();
|
||||
const mimeType = 'pcm16';
|
||||
try {
|
||||
await wavRecorder.begin();
|
||||
await wavStreamPlayer.connect();
|
||||
await wavRecorder.record(async (data) => {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
socket.emit('audio_chunk', {
|
||||
isStart: isFirstChunk,
|
||||
mimeType,
|
||||
elapsedTime,
|
||||
data: data.mono
|
||||
});
|
||||
isFirstChunk = false;
|
||||
});
|
||||
wavStreamPlayer.onStop = () => setIsAiSpeaking(false);
|
||||
} catch {
|
||||
try {
|
||||
await wavRecorder.end();
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
await wavStreamPlayer.interrupt();
|
||||
socket.emit('audio_end');
|
||||
setAudioConnection('off');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await wavRecorder.end();
|
||||
await wavStreamPlayer.interrupt();
|
||||
}
|
||||
setAudioConnection(state);
|
||||
});
|
||||
|
||||
socket.on('audio_chunk', (chunk: OutputAudioChunk) => {
|
||||
wavStreamPlayer.add16BitPCM(chunk.data, chunk.track);
|
||||
setIsAiSpeaking(true);
|
||||
});
|
||||
|
||||
socket.on('audio_interrupt', () => {
|
||||
wavStreamPlayer.interrupt();
|
||||
});
|
||||
|
||||
socket.on('resume_thread', (thread: IThread) => {
|
||||
const isReadOnlyView = Boolean(
|
||||
(thread as any)?.metadata?.viewer_read_only
|
||||
);
|
||||
if (!isReadOnlyView && idToResume && thread.id !== idToResume) {
|
||||
window.location.href = `/thread/${thread.id}`;
|
||||
}
|
||||
if (!isReadOnlyView && idToResume) {
|
||||
setCurrentThreadId(thread.id);
|
||||
}
|
||||
let messages: IStep[] = [];
|
||||
for (const step of thread.steps) {
|
||||
messages = addMessage(messages, step);
|
||||
}
|
||||
if (thread.metadata?.chat_profile) {
|
||||
setChatProfile(thread.metadata?.chat_profile);
|
||||
}
|
||||
if (thread.metadata?.chat_settings) {
|
||||
setChatSettingsValue(thread.metadata?.chat_settings);
|
||||
}
|
||||
setMessages(messages);
|
||||
const elements = thread.elements || [];
|
||||
setTasklists(
|
||||
(elements as ITasklistElement[]).filter((e) => e.type === 'tasklist')
|
||||
);
|
||||
setElements(
|
||||
(elements as IMessageElement[]).filter(
|
||||
(e) => ['avatar', 'tasklist'].indexOf(e.type) === -1
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('resume_thread_error', (error?: string) => {
|
||||
setThreadResumeError(error);
|
||||
});
|
||||
|
||||
socket.on('new_message', (message: IStep) => {
|
||||
setMessages((oldMessages) => addMessage(oldMessages, message));
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'first_interaction',
|
||||
(event: { interaction: string; thread_id: string }) => {
|
||||
setFirstUserInteraction(event.interaction);
|
||||
setCurrentThreadId(event.thread_id);
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('update_message', (message: IStep) => {
|
||||
setMessages((oldMessages) =>
|
||||
updateMessageById(oldMessages, message.id, message)
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('delete_message', (message: IStep) => {
|
||||
setMessages((oldMessages) =>
|
||||
deleteMessageById(oldMessages, message.id)
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('stream_start', (message: IStep) => {
|
||||
setMessages((oldMessages) => addMessage(oldMessages, message));
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'stream_token',
|
||||
({ id, token, isSequence, isInput }: IToken) => {
|
||||
setMessages((oldMessages) =>
|
||||
updateMessageContentById(
|
||||
oldMessages,
|
||||
id,
|
||||
token,
|
||||
isSequence,
|
||||
isInput
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('ask', ({ msg, spec }, callback) => {
|
||||
setAskUser({ spec, callback, parentId: msg.parentId });
|
||||
setMessages((oldMessages) => addMessage(oldMessages, msg));
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
socket.on('ask_timeout', () => {
|
||||
setAskUser(undefined);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
socket.on('clear_ask', () => {
|
||||
setAskUser(undefined);
|
||||
});
|
||||
|
||||
socket.on('call_fn', ({ name, args }, callback) => {
|
||||
setCallFn({ name, args, callback });
|
||||
});
|
||||
|
||||
socket.on('clear_call_fn', () => {
|
||||
setCallFn(undefined);
|
||||
});
|
||||
|
||||
socket.on('call_fn_timeout', () => {
|
||||
setCallFn(undefined);
|
||||
});
|
||||
|
||||
socket.on('chat_settings', (inputs: any) => {
|
||||
setChatSettingsInputs(inputs);
|
||||
resetChatSettingsValue();
|
||||
});
|
||||
|
||||
socket.on('set_commands', (commands: ICommand[]) => {
|
||||
setCommands(commands);
|
||||
});
|
||||
|
||||
socket.on('set_modes', (modes: IMode[]) => {
|
||||
setModes(modes);
|
||||
});
|
||||
|
||||
socket.on('set_favorites', (steps: IStep[]) => {
|
||||
setFavoriteMessages(steps);
|
||||
});
|
||||
|
||||
socket.on('set_sidebar_title', (title: string) => {
|
||||
setSideView((prev) => {
|
||||
if (prev?.title === title) return prev;
|
||||
return { title, elements: prev?.elements || [] };
|
||||
});
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'set_sidebar_elements',
|
||||
({ elements, key }: { elements: IMessageElement[]; key?: string }) => {
|
||||
if (!elements.length) {
|
||||
setSideView(undefined);
|
||||
} else {
|
||||
elements.forEach((element) => {
|
||||
if (!element.url && element.chainlitKey) {
|
||||
element.url = client.getElementUrl(
|
||||
element.chainlitKey,
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
});
|
||||
setSideView((prev) => {
|
||||
if (prev?.key === key) return prev;
|
||||
return { title: prev?.title || '', elements: elements, key };
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('element', (element: IElement) => {
|
||||
if (!element.url && element.chainlitKey) {
|
||||
element.url = client.getElementUrl(element.chainlitKey, sessionId);
|
||||
}
|
||||
|
||||
if (element.type === 'tasklist') {
|
||||
setTasklists((old) => {
|
||||
const index = old.findIndex((e) => e.id === element.id);
|
||||
if (index === -1) {
|
||||
return [...old, element];
|
||||
} else {
|
||||
return [...old.slice(0, index), element, ...old.slice(index + 1)];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setElements((old) => {
|
||||
const index = old.findIndex((e) => e.id === element.id);
|
||||
if (index === -1) {
|
||||
return [...old, element];
|
||||
} else {
|
||||
return [...old.slice(0, index), element, ...old.slice(index + 1)];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('remove_element', (remove: { id: string }) => {
|
||||
setElements((old) => {
|
||||
return old.filter((e) => e.id !== remove.id);
|
||||
});
|
||||
setTasklists((old) => {
|
||||
return old.filter((e) => e.id !== remove.id);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('action', (action: IAction) => {
|
||||
setActions((old) => [...old, action]);
|
||||
});
|
||||
|
||||
socket.on('remove_action', (action: IAction) => {
|
||||
setActions((old) => {
|
||||
const index = old.findIndex((a) => a.id === action.id);
|
||||
if (index === -1) return old;
|
||||
return [...old.slice(0, index), ...old.slice(index + 1)];
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('token_usage', (count: number) => {
|
||||
setTokenCount((old) => old + count);
|
||||
});
|
||||
|
||||
socket.on('window_message', (data: any) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(data, '*');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('toast', (data: { message: string; type: string }) => {
|
||||
if (!data.message) {
|
||||
console.warn('No message received for toast.');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'info':
|
||||
toast.info(data.message);
|
||||
break;
|
||||
case 'error':
|
||||
toast.error(data.message);
|
||||
break;
|
||||
case 'success':
|
||||
toast.success(data.message);
|
||||
break;
|
||||
case 'warning':
|
||||
toast.warning(data.message);
|
||||
break;
|
||||
default:
|
||||
toast(data.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
[setSession, sessionId, idToResume, chatProfile]
|
||||
);
|
||||
|
||||
const connect = useCallback(debounce(_connect, 200), [_connect]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (session?.socket) {
|
||||
session.socket.removeAllListeners();
|
||||
session.socket.close();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
session,
|
||||
sessionId,
|
||||
chatProfile,
|
||||
idToResume,
|
||||
setChatProfile
|
||||
};
|
||||
};
|
||||
|
||||
export { useChatSession };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { useApi, useAuth } from './api';
|
||||
import { chatProfileState, configState } from './state';
|
||||
import { IChainlitConfig } from './types';
|
||||
|
||||
const useConfig = () => {
|
||||
const [config, setConfig] = useRecoilState(configState);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const chatProfile = useRecoilValue(chatProfileState);
|
||||
const language = navigator.language || 'en-US';
|
||||
const prevChatProfileRef = useRef(chatProfile);
|
||||
|
||||
// Build the API URL with optional chat profile parameter
|
||||
const apiUrl = isAuthenticated
|
||||
? `/project/settings?language=${language}${chatProfile ? `&chat_profile=${encodeURIComponent(chatProfile)}` : ''}`
|
||||
: null;
|
||||
|
||||
// Always fetch if we don't have config and we're authenticated
|
||||
const shouldFetch = isAuthenticated && !config;
|
||||
|
||||
const { data, error, isLoading } = useApi<IChainlitConfig>(
|
||||
shouldFetch ? apiUrl : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setConfig(data);
|
||||
}, [data, setConfig]);
|
||||
|
||||
// Clear config when chat profile changes to force re-fetch
|
||||
useEffect(() => {
|
||||
if (prevChatProfileRef.current !== chatProfile) {
|
||||
setConfig(undefined);
|
||||
prevChatProfileRef.current = chatProfile;
|
||||
}
|
||||
}, [chatProfile, setConfig]);
|
||||
|
||||
return { config, error, isLoading, language };
|
||||
};
|
||||
|
||||
export { useConfig };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IThread } from 'src/types';
|
||||
|
||||
export const groupByDate = (data: IThread[]) => {
|
||||
const groupedData: { [key: string]: IThread[] } = {};
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
[...data]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
.forEach((item) => {
|
||||
const threadDate = new Date(item.createdAt);
|
||||
threadDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysDiff = Math.floor(
|
||||
(today.getTime() - threadDate.getTime()) / 86400000
|
||||
);
|
||||
const locale = navigator.language;
|
||||
|
||||
let category: string;
|
||||
if (daysDiff === 0) {
|
||||
category = 'Today';
|
||||
} else if (daysDiff === 1) {
|
||||
category = 'Yesterday';
|
||||
} else if (daysDiff <= 7) {
|
||||
category = 'Previous 7 days';
|
||||
} else if (daysDiff <= 30) {
|
||||
category = 'Previous 30 days';
|
||||
} else {
|
||||
category = threadDate.toLocaleString(locale, {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
groupedData[category] ??= [];
|
||||
groupedData[category].push(item);
|
||||
});
|
||||
|
||||
return groupedData;
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { IStep } from '..';
|
||||
|
||||
const nestMessages = (messages: IStep[]): IStep[] => {
|
||||
let nestedMessages: IStep[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
nestedMessages = addMessage(nestedMessages, message);
|
||||
}
|
||||
|
||||
return nestedMessages;
|
||||
};
|
||||
|
||||
const isLastMessage = (messages: IStep[], index: number) => {
|
||||
if (messages.length - 1 === index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
if (messages[i].streaming) {
|
||||
continue;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Nested messages utils
|
||||
|
||||
const addMessage = (messages: IStep[], message: IStep): IStep[] => {
|
||||
if (hasMessageById(messages, message.id)) {
|
||||
return updateMessageById(messages, message.id, message);
|
||||
} else if ('parentId' in message && message.parentId) {
|
||||
return addMessageToParent(messages, message.parentId, message);
|
||||
} else if ('indent' in message && message.indent && message.indent > 0) {
|
||||
return addIndentMessage(messages, message.indent, message);
|
||||
} else {
|
||||
return [...messages, message];
|
||||
}
|
||||
};
|
||||
|
||||
const addIndentMessage = (
|
||||
messages: IStep[],
|
||||
indent: number,
|
||||
newMessage: IStep,
|
||||
currentIndentation: number = 0
|
||||
): IStep[] => {
|
||||
if (messages.length === 0) {
|
||||
return [newMessage];
|
||||
}
|
||||
|
||||
const index = messages.length - 1;
|
||||
const msg = messages[index];
|
||||
const msgSteps = msg.steps || [];
|
||||
|
||||
if (currentIndentation + 1 === indent) {
|
||||
const updatedMsg = {
|
||||
...msg,
|
||||
steps: [...msgSteps, newMessage]
|
||||
};
|
||||
const nextMessages = [...messages];
|
||||
nextMessages[index] = updatedMsg;
|
||||
return nextMessages;
|
||||
} else {
|
||||
const updatedSteps = addIndentMessage(
|
||||
msgSteps,
|
||||
indent,
|
||||
newMessage,
|
||||
currentIndentation + 1
|
||||
);
|
||||
|
||||
if (updatedSteps === msgSteps) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const nextMessages = [...messages];
|
||||
nextMessages[index] = { ...msg, steps: updatedSteps };
|
||||
return nextMessages;
|
||||
}
|
||||
};
|
||||
|
||||
const addMessageToParent = (
|
||||
messages: IStep[],
|
||||
parentId: string,
|
||||
newMessage: IStep
|
||||
): IStep[] => {
|
||||
let hasChanges = false;
|
||||
|
||||
const nextMessages = messages.map((msg) => {
|
||||
if (isEqual(msg.id, parentId)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...msg,
|
||||
steps: msg.steps ? [...msg.steps, newMessage] : [newMessage]
|
||||
};
|
||||
} else if (hasMessageById(messages, parentId) && msg.steps) {
|
||||
const updatedSteps = addMessageToParent(msg.steps, parentId, newMessage);
|
||||
if (updatedSteps !== msg.steps) {
|
||||
hasChanges = true;
|
||||
return { ...msg, steps: updatedSteps };
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return hasChanges ? nextMessages : messages;
|
||||
};
|
||||
|
||||
const findMessageById = (
|
||||
messages: IStep[],
|
||||
messageId: string
|
||||
): IStep | undefined => {
|
||||
for (const message of messages) {
|
||||
if (isEqual(message.id, messageId)) {
|
||||
return message;
|
||||
} else if (message.steps && message.steps.length > 0) {
|
||||
const foundMessage = findMessageById(message.steps, messageId);
|
||||
if (foundMessage) {
|
||||
return foundMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasMessageById = (messages: IStep[], messageId: string): boolean => {
|
||||
return findMessageById(messages, messageId) !== undefined;
|
||||
};
|
||||
|
||||
const updateMessageById = (
|
||||
messages: IStep[],
|
||||
messageId: string,
|
||||
updatedMessage: IStep
|
||||
): IStep[] => {
|
||||
let hasChanges = false;
|
||||
const nextMessages = messages.map((msg) => {
|
||||
if (isEqual(msg.id, messageId)) {
|
||||
hasChanges = true;
|
||||
return { ...msg, ...updatedMessage };
|
||||
} else if (msg.steps) {
|
||||
const updatedSteps = updateMessageById(
|
||||
msg.steps,
|
||||
messageId,
|
||||
updatedMessage
|
||||
);
|
||||
if (updatedSteps !== msg.steps) {
|
||||
hasChanges = true;
|
||||
return { ...msg, steps: updatedSteps };
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return hasChanges ? nextMessages : messages;
|
||||
};
|
||||
|
||||
const deleteMessageById = (messages: IStep[], messageId: string): IStep[] => {
|
||||
let hasChanges = false;
|
||||
const nextMessages = messages.reduce((acc, msg) => {
|
||||
if (msg.id === messageId) {
|
||||
hasChanges = true;
|
||||
return acc;
|
||||
} else if (msg.steps) {
|
||||
const updatedSteps = deleteMessageById(msg.steps, messageId);
|
||||
if (updatedSteps !== msg.steps) {
|
||||
hasChanges = true;
|
||||
acc.push({ ...msg, steps: updatedSteps });
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
acc.push(msg);
|
||||
return acc;
|
||||
}, [] as IStep[]);
|
||||
|
||||
return hasChanges ? nextMessages : messages;
|
||||
};
|
||||
|
||||
const updateMessageContentById = (
|
||||
messages: IStep[],
|
||||
messageId: number | string,
|
||||
updatedContent: string,
|
||||
isSequence: boolean,
|
||||
isInput: boolean
|
||||
): IStep[] => {
|
||||
let hasChanges = false;
|
||||
const nextMessages = messages.map((msg) => {
|
||||
if (isEqual(msg.id, messageId)) {
|
||||
hasChanges = true;
|
||||
const newMsg = { ...msg };
|
||||
if ('content' in newMsg && newMsg.content !== undefined) {
|
||||
if (isSequence) {
|
||||
newMsg.content = updatedContent;
|
||||
} else {
|
||||
newMsg.content += updatedContent;
|
||||
}
|
||||
} else if (isInput) {
|
||||
if ('input' in newMsg && newMsg.input !== undefined) {
|
||||
if (isSequence) {
|
||||
newMsg.input = updatedContent;
|
||||
} else {
|
||||
newMsg.input += updatedContent;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ('output' in newMsg && newMsg.output !== undefined) {
|
||||
if (isSequence) {
|
||||
newMsg.output = updatedContent;
|
||||
} else {
|
||||
newMsg.output += updatedContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newMsg;
|
||||
} else if (msg.steps) {
|
||||
const updatedSteps = updateMessageContentById(
|
||||
msg.steps,
|
||||
messageId,
|
||||
updatedContent,
|
||||
isSequence,
|
||||
isInput
|
||||
);
|
||||
if (updatedSteps !== msg.steps) {
|
||||
hasChanges = true;
|
||||
return { ...msg, steps: updatedSteps };
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return hasChanges ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export {
|
||||
addMessageToParent,
|
||||
addMessage,
|
||||
deleteMessageById,
|
||||
hasMessageById,
|
||||
isLastMessage,
|
||||
nestMessages,
|
||||
updateMessageById,
|
||||
updateMessageContentById
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
noteFrequencies,
|
||||
noteFrequencyLabels,
|
||||
voiceFrequencies,
|
||||
voiceFrequencyLabels
|
||||
} from './constants.js';
|
||||
|
||||
/**
|
||||
* Output of AudioAnalysis for the frequency domain of the audio
|
||||
* @typedef {Object} AudioAnalysisOutputType
|
||||
* @property {Float32Array} values Amplitude of this frequency between {0, 1} inclusive
|
||||
* @property {number[]} frequencies Raw frequency bucket values
|
||||
* @property {string[]} labels Labels for the frequency bucket values
|
||||
*/
|
||||
|
||||
/**
|
||||
* Analyzes audio for visual output
|
||||
* @class
|
||||
*/
|
||||
export class AudioAnalysis {
|
||||
/**
|
||||
* Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range
|
||||
* returns human-readable formatting and labels
|
||||
* @param {AnalyserNode} analyser
|
||||
* @param {number} sampleRate
|
||||
* @param {Float32Array} [fftResult]
|
||||
* @param {"frequency"|"music"|"voice"} [analysisType]
|
||||
* @param {number} [minDecibels] default -100
|
||||
* @param {number} [maxDecibels] default -30
|
||||
* @returns {AudioAnalysisOutputType}
|
||||
*/
|
||||
static getFrequencies(
|
||||
analyser,
|
||||
sampleRate,
|
||||
fftResult,
|
||||
analysisType = 'frequency',
|
||||
minDecibels = -100,
|
||||
maxDecibels = -30
|
||||
) {
|
||||
if (!fftResult) {
|
||||
fftResult = new Float32Array(analyser.frequencyBinCount);
|
||||
analyser.getFloatFrequencyData(fftResult);
|
||||
}
|
||||
const nyquistFrequency = sampleRate / 2;
|
||||
const frequencyStep = (1 / fftResult.length) * nyquistFrequency;
|
||||
let outputValues;
|
||||
let frequencies;
|
||||
let labels;
|
||||
if (analysisType === 'music' || analysisType === 'voice') {
|
||||
const useFrequencies =
|
||||
analysisType === 'voice' ? voiceFrequencies : noteFrequencies;
|
||||
const aggregateOutput = Array(useFrequencies.length).fill(minDecibels);
|
||||
for (let i = 0; i < fftResult.length; i++) {
|
||||
const frequency = i * frequencyStep;
|
||||
const amplitude = fftResult[i];
|
||||
for (let n = useFrequencies.length - 1; n >= 0; n--) {
|
||||
if (frequency > useFrequencies[n]) {
|
||||
aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
outputValues = aggregateOutput;
|
||||
frequencies =
|
||||
analysisType === 'voice' ? voiceFrequencies : noteFrequencies;
|
||||
labels =
|
||||
analysisType === 'voice' ? voiceFrequencyLabels : noteFrequencyLabels;
|
||||
} else {
|
||||
outputValues = Array.from(fftResult);
|
||||
frequencies = outputValues.map((_, i) => frequencyStep * i);
|
||||
labels = frequencies.map((f) => `${f.toFixed(2)} Hz`);
|
||||
}
|
||||
// We normalize to {0, 1}
|
||||
const normalizedOutput = outputValues.map((v) => {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1)
|
||||
);
|
||||
});
|
||||
const values = new Float32Array(normalizedOutput);
|
||||
return {
|
||||
values,
|
||||
frequencies,
|
||||
labels
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AudioAnalysis instance for an HTMLAudioElement
|
||||
* @param {HTMLAudioElement} audioElement
|
||||
* @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer
|
||||
* @returns {AudioAnalysis}
|
||||
*/
|
||||
constructor(audioElement, audioBuffer = null) {
|
||||
this.fftResults = [];
|
||||
if (audioBuffer) {
|
||||
/**
|
||||
* Modified from
|
||||
* https://stackoverflow.com/questions/75063715/using-the-web-audio-api-to-analyze-a-song-without-playing
|
||||
*
|
||||
* We do this to populate FFT values for the audio if provided an `audioBuffer`
|
||||
* The reason to do this is that Safari fails when using `createMediaElementSource`
|
||||
* This has a non-zero RAM cost so we only opt-in to run it on Safari, Chrome is better
|
||||
*/
|
||||
const { length, sampleRate } = audioBuffer;
|
||||
const offlineAudioContext = new OfflineAudioContext({
|
||||
length,
|
||||
sampleRate
|
||||
});
|
||||
const source = offlineAudioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
const analyser = offlineAudioContext.createAnalyser();
|
||||
analyser.fftSize = 8192;
|
||||
analyser.smoothingTimeConstant = 0.1;
|
||||
source.connect(analyser);
|
||||
// limit is :: 128 / sampleRate;
|
||||
// but we just want 60fps - cuts ~1s from 6MB to 1MB of RAM
|
||||
const renderQuantumInSeconds = 1 / 60;
|
||||
const durationInSeconds = length / sampleRate;
|
||||
const analyze = (index) => {
|
||||
const suspendTime = renderQuantumInSeconds * index;
|
||||
if (suspendTime < durationInSeconds) {
|
||||
offlineAudioContext.suspend(suspendTime).then(() => {
|
||||
const fftResult = new Float32Array(analyser.frequencyBinCount);
|
||||
analyser.getFloatFrequencyData(fftResult);
|
||||
this.fftResults.push(fftResult);
|
||||
analyze(index + 1);
|
||||
});
|
||||
}
|
||||
if (index === 1) {
|
||||
offlineAudioContext.startRendering();
|
||||
} else {
|
||||
offlineAudioContext.resume();
|
||||
}
|
||||
};
|
||||
source.start(0);
|
||||
analyze(1);
|
||||
this.audio = audioElement;
|
||||
this.context = offlineAudioContext;
|
||||
this.analyser = analyser;
|
||||
this.sampleRate = sampleRate;
|
||||
this.audioBuffer = audioBuffer;
|
||||
} else {
|
||||
const audioContext = new AudioContext();
|
||||
const track = audioContext.createMediaElementSource(audioElement);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 8192;
|
||||
analyser.smoothingTimeConstant = 0.1;
|
||||
track.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
this.audio = audioElement;
|
||||
this.context = audioContext;
|
||||
this.analyser = analyser;
|
||||
this.sampleRate = this.context.sampleRate;
|
||||
this.audioBuffer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current frequency domain data from the playing audio track
|
||||
* @param {"frequency"|"music"|"voice"} [analysisType]
|
||||
* @param {number} [minDecibels] default -100
|
||||
* @param {number} [maxDecibels] default -30
|
||||
* @returns {AudioAnalysisOutputType}
|
||||
*/
|
||||
getFrequencies(
|
||||
analysisType = 'frequency',
|
||||
minDecibels = -100,
|
||||
maxDecibels = -30
|
||||
) {
|
||||
let fftResult = null;
|
||||
if (this.audioBuffer && this.fftResults.length) {
|
||||
const pct = this.audio.currentTime / this.audio.duration;
|
||||
const index = Math.min(
|
||||
(pct * this.fftResults.length) | 0,
|
||||
this.fftResults.length - 1
|
||||
);
|
||||
fftResult = this.fftResults[index];
|
||||
}
|
||||
return AudioAnalysis.getFrequencies(
|
||||
this.analyser,
|
||||
this.sampleRate,
|
||||
fftResult,
|
||||
analysisType,
|
||||
minDecibels,
|
||||
maxDecibels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the internal AudioContext if it was suspended due to the lack of
|
||||
* user interaction when the AudioAnalysis was instantiated.
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async resumeIfSuspended() {
|
||||
if (this.context.state === 'suspended') {
|
||||
await this.context.resume();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.AudioAnalysis = AudioAnalysis;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Constants for help with visualization
|
||||
* Helps map frequency ranges from Fast Fourier Transform
|
||||
* to human-interpretable ranges, notably music ranges and
|
||||
* human vocal ranges.
|
||||
*/
|
||||
|
||||
// Eighth octave frequencies
|
||||
const octave8Frequencies = [
|
||||
4186.01, 4434.92, 4698.63, 4978.03, 5274.04, 5587.65, 5919.91, 6271.93,
|
||||
6644.88, 7040.0, 7458.62, 7902.13
|
||||
];
|
||||
|
||||
// Labels for each of the above frequencies
|
||||
const octave8FrequencyLabels = [
|
||||
'C',
|
||||
'C#',
|
||||
'D',
|
||||
'D#',
|
||||
'E',
|
||||
'F',
|
||||
'F#',
|
||||
'G',
|
||||
'G#',
|
||||
'A',
|
||||
'A#',
|
||||
'B'
|
||||
];
|
||||
|
||||
/**
|
||||
* All note frequencies from 1st to 8th octave
|
||||
* in format "A#8" (A#, 8th octave)
|
||||
*/
|
||||
export const noteFrequencies = [];
|
||||
export const noteFrequencyLabels = [];
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
for (let f = 0; f < octave8Frequencies.length; f++) {
|
||||
const freq = octave8Frequencies[f];
|
||||
noteFrequencies.push(freq / Math.pow(2, 8 - i));
|
||||
noteFrequencyLabels.push(octave8FrequencyLabels[f] + i);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of the note frequencies between 32 and 2000 Hz
|
||||
* 6 octave range: C1 to B6
|
||||
*/
|
||||
const voiceFrequencyRange = [32.0, 2000.0];
|
||||
export const voiceFrequencies = noteFrequencies.filter((_, i) => {
|
||||
return (
|
||||
noteFrequencies[i] > voiceFrequencyRange[0] &&
|
||||
noteFrequencies[i] < voiceFrequencyRange[1]
|
||||
);
|
||||
});
|
||||
export const voiceFrequencyLabels = noteFrequencyLabels.filter((_, i) => {
|
||||
return (
|
||||
noteFrequencies[i] > voiceFrequencyRange[0] &&
|
||||
noteFrequencies[i] < voiceFrequencyRange[1]
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Courtesy of https://github.com/openai/openai-realtime-console
|
||||
import { AudioAnalysis } from './analysis/audio_analysis.js';
|
||||
import { WavPacker } from './wav_packer.js';
|
||||
import { WavRecorder } from './wav_recorder.js';
|
||||
import { WavStreamPlayer } from './wav_stream_player.js';
|
||||
|
||||
export { AudioAnalysis, WavPacker, WavStreamPlayer, WavRecorder };
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Raw wav audio file contents
|
||||
* @typedef {Object} WavPackerAudioType
|
||||
* @property {Blob} blob
|
||||
* @property {string} url
|
||||
* @property {number} channelCount
|
||||
* @property {number} sampleRate
|
||||
* @property {number} duration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility class for assembling PCM16 "audio/wav" data
|
||||
* @class
|
||||
*/
|
||||
export class WavPacker {
|
||||
/**
|
||||
* Converts Float32Array of amplitude data to ArrayBuffer in Int16Array format
|
||||
* @param {Float32Array} float32Array
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
static floatTo16BitPCM(float32Array) {
|
||||
const buffer = new ArrayBuffer(float32Array.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < float32Array.length; i++, offset += 2) {
|
||||
let s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates two ArrayBuffers
|
||||
* @param {ArrayBuffer} leftBuffer
|
||||
* @param {ArrayBuffer} rightBuffer
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
static mergeBuffers(leftBuffer, rightBuffer) {
|
||||
const tmpArray = new Uint8Array(
|
||||
leftBuffer.byteLength + rightBuffer.byteLength
|
||||
);
|
||||
tmpArray.set(new Uint8Array(leftBuffer), 0);
|
||||
tmpArray.set(new Uint8Array(rightBuffer), leftBuffer.byteLength);
|
||||
return tmpArray.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs data into an Int16 format
|
||||
* @private
|
||||
* @param {number} size 0 = 1x Int16, 1 = 2x Int16
|
||||
* @param {number} arg value to pack
|
||||
* @returns
|
||||
*/
|
||||
_packData(size, arg) {
|
||||
return [
|
||||
new Uint8Array([arg, arg >> 8]),
|
||||
new Uint8Array([arg, arg >> 8, arg >> 16, arg >> 24])
|
||||
][size];
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs audio into "audio/wav" Blob
|
||||
* @param {number} sampleRate
|
||||
* @param {{bitsPerSample: number, channels: Array<Float32Array>, data: Int16Array}} audio
|
||||
* @returns {WavPackerAudioType}
|
||||
*/
|
||||
pack(sampleRate, audio) {
|
||||
if (!audio?.bitsPerSample) {
|
||||
throw new Error(`Missing "bitsPerSample"`);
|
||||
} else if (!audio?.channels) {
|
||||
throw new Error(`Missing "channels"`);
|
||||
} else if (!audio?.data) {
|
||||
throw new Error(`Missing "data"`);
|
||||
}
|
||||
const { bitsPerSample, channels, data } = audio;
|
||||
const output = [
|
||||
// Header
|
||||
'RIFF',
|
||||
this._packData(
|
||||
1,
|
||||
4 + (8 + 24) /* chunk 1 length */ + (8 + 8) /* chunk 2 length */
|
||||
), // Length
|
||||
'WAVE',
|
||||
// chunk 1
|
||||
'fmt ', // Sub-chunk identifier
|
||||
this._packData(1, 16), // Chunk length
|
||||
this._packData(0, 1), // Audio format (1 is linear quantization)
|
||||
this._packData(0, channels.length),
|
||||
this._packData(1, sampleRate),
|
||||
this._packData(1, (sampleRate * channels.length * bitsPerSample) / 8), // Byte rate
|
||||
this._packData(0, (channels.length * bitsPerSample) / 8),
|
||||
this._packData(0, bitsPerSample),
|
||||
// chunk 2
|
||||
'data', // Sub-chunk identifier
|
||||
this._packData(
|
||||
1,
|
||||
(channels[0].length * channels.length * bitsPerSample) / 8
|
||||
), // Chunk length
|
||||
data
|
||||
];
|
||||
const blob = new Blob(output, { type: 'audio/mpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
return {
|
||||
blob,
|
||||
url,
|
||||
channelCount: channels.length,
|
||||
sampleRate,
|
||||
duration: data.byteLength / (channels.length * sampleRate * 2)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.WavPacker = WavPacker;
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
import { AudioAnalysis } from './analysis/audio_analysis.js';
|
||||
import { WavPacker } from './wav_packer.js';
|
||||
import { AudioProcessorSrc } from './worklets/audio_processor.js';
|
||||
|
||||
/**
|
||||
* Decodes audio into a wav file
|
||||
* @typedef {Object} DecodedAudioType
|
||||
* @property {Blob} blob
|
||||
* @property {string} url
|
||||
* @property {Float32Array} values
|
||||
* @property {AudioBuffer} audioBuffer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Records live stream of user audio as PCM16 "audio/wav" data
|
||||
* @class
|
||||
*/
|
||||
export class WavRecorder {
|
||||
/**
|
||||
* Create a new WavRecorder instance
|
||||
* @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options]
|
||||
* @returns {WavRecorder}
|
||||
*/
|
||||
constructor({
|
||||
sampleRate = 24000,
|
||||
outputToSpeakers = false,
|
||||
debug = false
|
||||
} = {}) {
|
||||
// Script source
|
||||
this.scriptSrc = AudioProcessorSrc;
|
||||
// Config
|
||||
this.sampleRate = sampleRate;
|
||||
this.outputToSpeakers = outputToSpeakers;
|
||||
this.debug = !!debug;
|
||||
this._deviceChangeCallback = null;
|
||||
this._devices = [];
|
||||
// State variables
|
||||
this.stream = null;
|
||||
this.processor = null;
|
||||
this.source = null;
|
||||
this.node = null;
|
||||
this.recording = false;
|
||||
// Event handling with AudioWorklet
|
||||
this._lastEventId = 0;
|
||||
this.eventReceipts = {};
|
||||
this.eventTimeout = 5000;
|
||||
// Process chunks of audio
|
||||
this._chunkProcessor = () => {};
|
||||
this._chunkProcessorSize = void 0;
|
||||
this._chunkProcessorBuffer = {
|
||||
raw: new ArrayBuffer(0),
|
||||
mono: new ArrayBuffer(0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer
|
||||
* @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData
|
||||
* @param {number} sampleRate
|
||||
* @param {number} fromSampleRate
|
||||
* @returns {Promise<DecodedAudioType>}
|
||||
*/
|
||||
static async decode(audioData, sampleRate = 24000, fromSampleRate = -1) {
|
||||
const context = new AudioContext({ sampleRate });
|
||||
let arrayBuffer;
|
||||
let blob;
|
||||
if (audioData instanceof Blob) {
|
||||
if (fromSampleRate !== -1) {
|
||||
throw new Error(
|
||||
`Can not specify "fromSampleRate" when reading from Blob`
|
||||
);
|
||||
}
|
||||
blob = audioData;
|
||||
arrayBuffer = await blob.arrayBuffer();
|
||||
} else if (audioData instanceof ArrayBuffer) {
|
||||
if (fromSampleRate !== -1) {
|
||||
throw new Error(
|
||||
`Can not specify "fromSampleRate" when reading from ArrayBuffer`
|
||||
);
|
||||
}
|
||||
arrayBuffer = audioData;
|
||||
blob = new Blob([arrayBuffer], { type: 'audio/wav' });
|
||||
} else {
|
||||
let float32Array;
|
||||
let data;
|
||||
if (audioData instanceof Int16Array) {
|
||||
data = audioData;
|
||||
float32Array = new Float32Array(audioData.length);
|
||||
for (let i = 0; i < audioData.length; i++) {
|
||||
float32Array[i] = audioData[i] / 0x8000;
|
||||
}
|
||||
} else if (audioData instanceof Float32Array) {
|
||||
float32Array = audioData;
|
||||
} else if (audioData instanceof Array) {
|
||||
float32Array = new Float32Array(audioData);
|
||||
} else {
|
||||
throw new Error(
|
||||
`"audioData" must be one of: Blob, Float32Arrray, Int16Array, ArrayBuffer, Array<number>`
|
||||
);
|
||||
}
|
||||
if (fromSampleRate === -1) {
|
||||
throw new Error(
|
||||
`Must specify "fromSampleRate" when reading from Float32Array, In16Array or Array`
|
||||
);
|
||||
} else if (fromSampleRate < 3000) {
|
||||
throw new Error(`Minimum "fromSampleRate" is 3000 (3kHz)`);
|
||||
}
|
||||
if (!data) {
|
||||
data = WavPacker.floatTo16BitPCM(float32Array);
|
||||
}
|
||||
const audio = {
|
||||
bitsPerSample: 16,
|
||||
channels: [float32Array],
|
||||
data
|
||||
};
|
||||
const packer = new WavPacker();
|
||||
const result = packer.pack(fromSampleRate, audio);
|
||||
blob = result.blob;
|
||||
arrayBuffer = await blob.arrayBuffer();
|
||||
}
|
||||
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
||||
const values = audioBuffer.getChannelData(0);
|
||||
const url = URL.createObjectURL(blob);
|
||||
return {
|
||||
blob,
|
||||
url,
|
||||
values,
|
||||
audioBuffer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs data in debug mode
|
||||
* @param {...any} arguments
|
||||
* @returns {true}
|
||||
*/
|
||||
log() {
|
||||
if (this.debug) {
|
||||
this.log(...arguments);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current sampleRate for the recorder
|
||||
* @returns {number}
|
||||
*/
|
||||
getSampleRate() {
|
||||
return this.sampleRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current status of the recording
|
||||
* @returns {"ended"|"paused"|"recording"}
|
||||
*/
|
||||
getStatus() {
|
||||
if (!this.processor) {
|
||||
return 'ended';
|
||||
} else if (!this.recording) {
|
||||
return 'paused';
|
||||
} else {
|
||||
return 'recording';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to the AudioWorklet
|
||||
* @private
|
||||
* @param {string} name
|
||||
* @param {{[key: string]: any}} data
|
||||
* @param {AudioWorkletNode} [_processor]
|
||||
* @returns {Promise<{[key: string]: any}>}
|
||||
*/
|
||||
async _event(name, data = {}, _processor = null) {
|
||||
_processor = _processor || this.processor;
|
||||
if (!_processor) {
|
||||
throw new Error('Can not send events without recording first');
|
||||
}
|
||||
const message = {
|
||||
event: name,
|
||||
id: this._lastEventId++,
|
||||
data
|
||||
};
|
||||
_processor.port.postMessage(message);
|
||||
const t0 = new Date().valueOf();
|
||||
while (!this.eventReceipts[message.id]) {
|
||||
if (new Date().valueOf() - t0 > this.eventTimeout) {
|
||||
throw new Error(`Timeout waiting for "${name}" event`);
|
||||
}
|
||||
await new Promise((res) => setTimeout(() => res(true), 1));
|
||||
}
|
||||
const payload = this.eventReceipts[message.id];
|
||||
delete this.eventReceipts[message.id];
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets device change callback, remove if callback provided is `null`
|
||||
* @param {(Array<MediaDeviceInfo & {default: boolean}>): void|null} callback
|
||||
* @returns {true}
|
||||
*/
|
||||
listenForDeviceChange(callback) {
|
||||
if (callback === null && this._deviceChangeCallback) {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
'devicechange',
|
||||
this._deviceChangeCallback
|
||||
);
|
||||
this._deviceChangeCallback = null;
|
||||
} else if (callback !== null) {
|
||||
// Basically a debounce; we only want this called once when devices change
|
||||
// And we only want the most recent callback() to be executed
|
||||
// if a few are operating at the same time
|
||||
let lastId = 0;
|
||||
let lastDevices = [];
|
||||
const serializeDevices = (devices) =>
|
||||
devices
|
||||
.map((d) => d.deviceId)
|
||||
.sort()
|
||||
.join(',');
|
||||
const cb = async () => {
|
||||
let id = ++lastId;
|
||||
const devices = await this.listDevices();
|
||||
if (id === lastId) {
|
||||
if (serializeDevices(lastDevices) !== serializeDevices(devices)) {
|
||||
lastDevices = devices;
|
||||
callback(devices.slice());
|
||||
}
|
||||
}
|
||||
};
|
||||
navigator.mediaDevices.addEventListener('devicechange', cb);
|
||||
cb();
|
||||
this._deviceChangeCallback = cb;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually request permission to use the microphone
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async requestPermission() {
|
||||
const permissionStatus = await navigator.permissions.query({
|
||||
name: 'microphone'
|
||||
});
|
||||
if (permissionStatus.state === 'denied') {
|
||||
window.alert('You must grant microphone access to use this feature.');
|
||||
} else if (permissionStatus.state === 'prompt') {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
} catch (_e) {
|
||||
window.alert('You must grant microphone access to use this feature.');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all eligible devices for recording, will request permission to use microphone
|
||||
* @returns {Promise<Array<MediaDeviceInfo & {default: boolean}>>}
|
||||
*/
|
||||
async listDevices() {
|
||||
if (
|
||||
!navigator.mediaDevices ||
|
||||
!('enumerateDevices' in navigator.mediaDevices)
|
||||
) {
|
||||
throw new Error('Could not request user devices');
|
||||
}
|
||||
await this.requestPermission();
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioDevices = devices.filter(
|
||||
(device) => device.kind === 'audioinput'
|
||||
);
|
||||
const defaultDeviceIndex = audioDevices.findIndex(
|
||||
(device) => device.deviceId === 'default'
|
||||
);
|
||||
const deviceList = [];
|
||||
if (defaultDeviceIndex !== -1) {
|
||||
let defaultDevice = audioDevices.splice(defaultDeviceIndex, 1)[0];
|
||||
let existingIndex = audioDevices.findIndex(
|
||||
(device) => device.groupId === defaultDevice.groupId
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
defaultDevice = audioDevices.splice(existingIndex, 1)[0];
|
||||
}
|
||||
defaultDevice.default = true;
|
||||
deviceList.push(defaultDevice);
|
||||
}
|
||||
return deviceList.concat(audioDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins a recording session and requests microphone permissions if not already granted
|
||||
* Microphone recording indicator will appear on browser tab but status will be "paused"
|
||||
* @param {string} [deviceId] if no device provided, default device will be used
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async begin(deviceId) {
|
||||
if (this.processor) {
|
||||
throw new Error(
|
||||
`Already connected: please call .end() to start a new session`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!navigator.mediaDevices ||
|
||||
!('getUserMedia' in navigator.mediaDevices)
|
||||
) {
|
||||
throw new Error('Could not request user media');
|
||||
}
|
||||
try {
|
||||
const config = { audio: true };
|
||||
if (deviceId) {
|
||||
config.audio = { deviceId: { exact: deviceId } };
|
||||
}
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(config);
|
||||
} catch (err) {
|
||||
throw new Error('Could not start media stream', { cause: err });
|
||||
}
|
||||
|
||||
const context = new AudioContext({ sampleRate: this.sampleRate });
|
||||
const source = context.createMediaStreamSource(this.stream);
|
||||
// Load and execute the module script.
|
||||
try {
|
||||
await context.audioWorklet.addModule(this.scriptSrc);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`, {
|
||||
cause: e
|
||||
});
|
||||
}
|
||||
const processor = new AudioWorkletNode(context, 'audio_processor');
|
||||
processor.port.onmessage = (e) => {
|
||||
const { event, id, data } = e.data;
|
||||
if (event === 'receipt') {
|
||||
this.eventReceipts[id] = data;
|
||||
} else if (event === 'chunk') {
|
||||
if (this._chunkProcessorSize) {
|
||||
const buffer = this._chunkProcessorBuffer;
|
||||
this._chunkProcessorBuffer = {
|
||||
raw: WavPacker.mergeBuffers(buffer.raw, data.raw),
|
||||
mono: WavPacker.mergeBuffers(buffer.mono, data.mono)
|
||||
};
|
||||
if (
|
||||
this._chunkProcessorBuffer.mono.byteLength >=
|
||||
this._chunkProcessorSize
|
||||
) {
|
||||
this._chunkProcessor(this._chunkProcessorBuffer);
|
||||
this._chunkProcessorBuffer = {
|
||||
raw: new ArrayBuffer(0),
|
||||
mono: new ArrayBuffer(0)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this._chunkProcessor(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const node = source.connect(processor);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 8192;
|
||||
analyser.smoothingTimeConstant = 0.1;
|
||||
node.connect(analyser);
|
||||
if (this.outputToSpeakers) {
|
||||
console.warn(
|
||||
'Warning: Output to speakers may affect sound quality,\n' +
|
||||
'especially due to system audio feedback preventative measures.\n' +
|
||||
'use only for debugging'
|
||||
);
|
||||
analyser.connect(context.destination);
|
||||
}
|
||||
|
||||
this.source = source;
|
||||
this.node = node;
|
||||
this.analyser = analyser;
|
||||
this.processor = processor;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current frequency domain data from the recording track
|
||||
* @param {"frequency"|"music"|"voice"} [analysisType]
|
||||
* @param {number} [minDecibels] default -100
|
||||
* @param {number} [maxDecibels] default -30
|
||||
* @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType}
|
||||
*/
|
||||
getFrequencies(
|
||||
analysisType = 'frequency',
|
||||
minDecibels = -100,
|
||||
maxDecibels = -30
|
||||
) {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
}
|
||||
return AudioAnalysis.getFrequencies(
|
||||
this.analyser,
|
||||
this.sampleRate,
|
||||
null,
|
||||
analysisType,
|
||||
minDecibels,
|
||||
maxDecibels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the recording
|
||||
* Keeps microphone stream open but halts storage of audio
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async pause() {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
} else if (!this.recording) {
|
||||
throw new Error('Already paused: please call .record() first');
|
||||
}
|
||||
if (this._chunkProcessorBuffer.raw.byteLength) {
|
||||
this._chunkProcessor(this._chunkProcessorBuffer);
|
||||
}
|
||||
this.log('Pausing ...');
|
||||
await this._event('stop');
|
||||
this.recording = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording stream and storing to memory from the connected audio source
|
||||
* @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor]
|
||||
* @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async record(chunkProcessor = () => {}, chunkSize = 8192) {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
} else if (this.recording) {
|
||||
throw new Error('Already recording: please call .pause() first');
|
||||
} else if (typeof chunkProcessor !== 'function') {
|
||||
throw new Error(`chunkProcessor must be a function`);
|
||||
}
|
||||
this._chunkProcessor = chunkProcessor;
|
||||
this._chunkProcessorSize = chunkSize;
|
||||
this._chunkProcessorBuffer = {
|
||||
raw: new ArrayBuffer(0),
|
||||
mono: new ArrayBuffer(0)
|
||||
};
|
||||
this.log('Recording ...');
|
||||
await this._event('start');
|
||||
this.recording = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the audio buffer, empties stored recording
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async clear() {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
}
|
||||
await this._event('clear');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current audio stream data
|
||||
* @returns {Promise<{meanValues: Float32Array, channels: Array<Float32Array>}>}
|
||||
*/
|
||||
async read() {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
}
|
||||
this.log('Reading ...');
|
||||
const result = await this._event('read');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current audio stream to a file
|
||||
* @param {boolean} [force] Force saving while still recording
|
||||
* @returns {Promise<import('./wav_packer.js').WavPackerAudioType>}
|
||||
*/
|
||||
async save(force = false) {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
}
|
||||
if (!force && this.recording) {
|
||||
throw new Error(
|
||||
'Currently recording: please call .pause() first, or call .save(true) to force'
|
||||
);
|
||||
}
|
||||
this.log('Exporting ...');
|
||||
const exportData = await this._event('export');
|
||||
const packer = new WavPacker();
|
||||
const result = packer.pack(this.sampleRate, exportData.audio);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current recording session and saves the result
|
||||
* @returns {Promise<import('./wav_packer.js').WavPackerAudioType>}
|
||||
*/
|
||||
async end() {
|
||||
if (!this.processor) {
|
||||
throw new Error('Session ended: please call .begin() first');
|
||||
}
|
||||
|
||||
const _processor = this.processor;
|
||||
|
||||
this.log('Stopping ...');
|
||||
await this._event('stop');
|
||||
this.recording = false;
|
||||
const tracks = this.stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
|
||||
this.log('Exporting ...');
|
||||
const exportData = await this._event('export', {}, _processor);
|
||||
|
||||
this.processor.disconnect();
|
||||
this.source.disconnect();
|
||||
this.node.disconnect();
|
||||
this.analyser.disconnect();
|
||||
this.stream = null;
|
||||
this.processor = null;
|
||||
this.source = null;
|
||||
this.node = null;
|
||||
|
||||
const packer = new WavPacker();
|
||||
const result = packer.pack(this.sampleRate, exportData.audio);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a full cleanup of WavRecorder instance
|
||||
* Stops actively listening via microphone and removes existing listeners
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async quit() {
|
||||
this.listenForDeviceChange(null);
|
||||
if (this.processor) {
|
||||
await this.end();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.WavRecorder = WavRecorder;
|
||||
@@ -0,0 +1,132 @@
|
||||
const dataMap = new WeakMap();
|
||||
|
||||
/**
|
||||
* Normalizes a Float32Array to Array(m): We use this to draw amplitudes on a graph
|
||||
* If we're rendering the same audio data, then we'll often be using
|
||||
* the same (data, m, downsamplePeaks) triplets so we give option to memoize
|
||||
*/
|
||||
const normalizeArray = (
|
||||
data: Float32Array,
|
||||
m: number,
|
||||
downsamplePeaks: boolean = false,
|
||||
memoize: boolean = false
|
||||
) => {
|
||||
let cache, mKey, dKey;
|
||||
if (memoize) {
|
||||
mKey = m.toString();
|
||||
dKey = downsamplePeaks.toString();
|
||||
cache = dataMap.has(data) ? dataMap.get(data) : {};
|
||||
dataMap.set(data, cache);
|
||||
cache[mKey] = cache[mKey] || {};
|
||||
if (cache[mKey][dKey]) {
|
||||
return cache[mKey][dKey];
|
||||
}
|
||||
}
|
||||
const n = data.length;
|
||||
const result = new Array(m);
|
||||
if (m <= n) {
|
||||
// Downsampling
|
||||
result.fill(0);
|
||||
const count = new Array(m).fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const index = Math.floor(i * (m / n));
|
||||
if (downsamplePeaks) {
|
||||
// take highest result in the set
|
||||
result[index] = Math.max(result[index], Math.abs(data[i]));
|
||||
} else {
|
||||
result[index] += Math.abs(data[i]);
|
||||
}
|
||||
count[index]++;
|
||||
}
|
||||
if (!downsamplePeaks) {
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
result[i] = result[i] / count[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < m; i++) {
|
||||
const index = (i * (n - 1)) / (m - 1);
|
||||
const low = Math.floor(index);
|
||||
const high = Math.ceil(index);
|
||||
const t = index - low;
|
||||
if (high >= n) {
|
||||
result[i] = data[n - 1];
|
||||
} else {
|
||||
result[i] = data[low] * (1 - t) + data[high] * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (memoize) {
|
||||
cache[mKey as string][dKey as string] = result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const WavRenderer = {
|
||||
/**
|
||||
* Renders a point-in-time snapshot of an audio sample, usually frequency values
|
||||
* @param ctx
|
||||
* @param data
|
||||
* @param color
|
||||
* @param cssWidth
|
||||
* @param cssHeight
|
||||
* @param pointCount number of bars to render
|
||||
* @param barWidth width of bars in px
|
||||
* @param barSpacing spacing between bars in px
|
||||
* @param center vertically center the bars
|
||||
*/
|
||||
drawBars: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
data: Float32Array,
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
color: string,
|
||||
pointCount: number = 0,
|
||||
barWidth: number = 0,
|
||||
barSpacing: number = 0,
|
||||
center: boolean = false
|
||||
) => {
|
||||
pointCount = Math.floor(
|
||||
Math.min(
|
||||
pointCount,
|
||||
(cssWidth - barSpacing) / (Math.max(barWidth, 1) + barSpacing)
|
||||
)
|
||||
);
|
||||
if (!pointCount) {
|
||||
pointCount = Math.floor(
|
||||
(cssWidth - barSpacing) / (Math.max(barWidth, 1) + barSpacing)
|
||||
);
|
||||
}
|
||||
if (!barWidth) {
|
||||
barWidth = (cssWidth - barSpacing) / pointCount - barSpacing;
|
||||
}
|
||||
const points = normalizeArray(data, pointCount, true);
|
||||
for (let i = 0; i < pointCount; i++) {
|
||||
const amplitude = Math.abs(points[i]);
|
||||
const height = Math.max(1, amplitude * cssHeight);
|
||||
const x = barSpacing + i * (barWidth + barSpacing);
|
||||
const y = center ? (cssHeight - height) / 2 : cssHeight - height;
|
||||
const radius = Math.min(barWidth / 2, height / 2); // Calculate the radius for rounded corners
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + barWidth - radius, y);
|
||||
ctx.arcTo(x + barWidth, y, x + barWidth, y + radius, radius);
|
||||
ctx.lineTo(x + barWidth, y + height - radius);
|
||||
ctx.arcTo(
|
||||
x + barWidth,
|
||||
y + height,
|
||||
x + barWidth - radius,
|
||||
y + height,
|
||||
radius
|
||||
);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.arcTo(x, y + height, x, y + height - radius, radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.arcTo(x, y, x + radius, y, radius);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
};
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { AudioAnalysis } from './analysis/audio_analysis.js';
|
||||
import { StreamProcessorSrc } from './worklets/stream_processor.js';
|
||||
|
||||
/**
|
||||
* Plays audio streams received in raw PCM16 chunks from the browser
|
||||
* @class
|
||||
*/
|
||||
export class WavStreamPlayer {
|
||||
/**
|
||||
* Creates a new WavStreamPlayer instance
|
||||
* @param {{sampleRate?: number}} options
|
||||
* @returns {WavStreamPlayer}
|
||||
*/
|
||||
constructor({ sampleRate = 24000, onStop } = {}) {
|
||||
this.scriptSrc = StreamProcessorSrc;
|
||||
this.onStop = onStop;
|
||||
this.sampleRate = sampleRate;
|
||||
this.context = null;
|
||||
this.stream = null;
|
||||
this.analyser = null;
|
||||
this.trackSampleOffsets = {};
|
||||
this.interruptedTrackIds = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the audio context and enables output to speakers
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
async connect() {
|
||||
this.context = new AudioContext({ sampleRate: this.sampleRate });
|
||||
if (this.context.state === 'suspended') {
|
||||
await this.context.resume();
|
||||
}
|
||||
try {
|
||||
await this.context.audioWorklet.addModule(this.scriptSrc);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`, {
|
||||
cause: e
|
||||
});
|
||||
}
|
||||
const analyser = this.context.createAnalyser();
|
||||
analyser.fftSize = 8192;
|
||||
analyser.smoothingTimeConstant = 0.1;
|
||||
this.analyser = analyser;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current frequency domain data from the playing track
|
||||
* @param {"frequency"|"music"|"voice"} [analysisType]
|
||||
* @param {number} [minDecibels] default -100
|
||||
* @param {number} [maxDecibels] default -30
|
||||
* @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType}
|
||||
*/
|
||||
getFrequencies(
|
||||
analysisType = 'frequency',
|
||||
minDecibels = -100,
|
||||
maxDecibels = -30
|
||||
) {
|
||||
if (!this.analyser) {
|
||||
throw new Error('Not connected, please call .connect() first');
|
||||
}
|
||||
return AudioAnalysis.getFrequencies(
|
||||
this.analyser,
|
||||
this.sampleRate,
|
||||
null,
|
||||
analysisType,
|
||||
minDecibels,
|
||||
maxDecibels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts audio streaming
|
||||
* @private
|
||||
* @returns {Promise<true>}
|
||||
*/
|
||||
_start() {
|
||||
const streamNode = new AudioWorkletNode(this.context, 'stream_processor');
|
||||
streamNode.connect(this.context.destination);
|
||||
streamNode.port.onmessage = (e) => {
|
||||
const { event } = e.data;
|
||||
if (event === 'stop') {
|
||||
this.onStop?.();
|
||||
streamNode.disconnect();
|
||||
this.stream = null;
|
||||
} else if (event === 'offset') {
|
||||
const { requestId, trackId, offset } = e.data;
|
||||
const currentTime = offset / this.sampleRate;
|
||||
this.trackSampleOffsets[requestId] = { trackId, offset, currentTime };
|
||||
}
|
||||
};
|
||||
this.analyser.disconnect();
|
||||
streamNode.connect(this.analyser);
|
||||
this.stream = streamNode;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds 16BitPCM data to the currently playing audio stream
|
||||
* You can add chunks beyond the current play point and they will be queued for play
|
||||
* @param {ArrayBuffer|Int16Array} arrayBuffer
|
||||
* @param {string} [trackId]
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
add16BitPCM(arrayBuffer, trackId = 'default') {
|
||||
if (typeof trackId !== 'string') {
|
||||
throw new Error(`trackId must be a string`);
|
||||
} else if (this.interruptedTrackIds[trackId]) {
|
||||
return;
|
||||
}
|
||||
if (!this.stream) {
|
||||
this._start();
|
||||
}
|
||||
let buffer;
|
||||
if (arrayBuffer instanceof Int16Array) {
|
||||
buffer = arrayBuffer;
|
||||
} else if (arrayBuffer instanceof ArrayBuffer) {
|
||||
buffer = new Int16Array(arrayBuffer);
|
||||
} else {
|
||||
throw new Error(`argument must be Int16Array or ArrayBuffer`);
|
||||
}
|
||||
this.stream.port.postMessage({ event: 'write', buffer, trackId });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the offset (sample count) of the currently playing stream
|
||||
* @param {boolean} [interrupt]
|
||||
* @returns {{trackId: string|null, offset: number, currentTime: number}}
|
||||
*/
|
||||
async getTrackSampleOffset(interrupt = false) {
|
||||
if (!this.stream) {
|
||||
return null;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
this.stream.port.postMessage({
|
||||
event: interrupt ? 'interrupt' : 'offset',
|
||||
requestId
|
||||
});
|
||||
let trackSampleOffset;
|
||||
while (!trackSampleOffset) {
|
||||
trackSampleOffset = this.trackSampleOffsets[requestId];
|
||||
await new Promise((r) => setTimeout(() => r(), 1));
|
||||
}
|
||||
const { trackId } = trackSampleOffset;
|
||||
if (interrupt && trackId) {
|
||||
this.interruptedTrackIds[trackId] = true;
|
||||
}
|
||||
return trackSampleOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the current stream and returns the sample offset of the audio
|
||||
* @param {boolean} [interrupt]
|
||||
* @returns {{trackId: string|null, offset: number, currentTime: number}}
|
||||
*/
|
||||
async interrupt() {
|
||||
return this.getTrackSampleOffset(true);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.WavStreamPlayer = WavStreamPlayer;
|
||||
@@ -0,0 +1,214 @@
|
||||
const AudioProcessorWorklet = `
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = this.receive.bind(this);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.foundAudio = false;
|
||||
this.recording = false;
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates sampled chunks into channels
|
||||
* Format is chunk[Left[], Right[]]
|
||||
*/
|
||||
readChannelData(chunks, channel = -1, maxChannels = 9) {
|
||||
let channelLimit;
|
||||
if (channel !== -1) {
|
||||
if (chunks[0] && chunks[0].length - 1 < channel) {
|
||||
throw new Error(
|
||||
\`Channel \${channel} out of range: max \${chunks[0].length}\`
|
||||
);
|
||||
}
|
||||
channelLimit = channel + 1;
|
||||
} else {
|
||||
channel = 0;
|
||||
channelLimit = Math.min(chunks[0] ? chunks[0].length : 1, maxChannels);
|
||||
}
|
||||
const channels = [];
|
||||
for (let n = channel; n < channelLimit; n++) {
|
||||
const length = chunks.reduce((sum, chunk) => {
|
||||
return sum + chunk[n].length;
|
||||
}, 0);
|
||||
const buffers = chunks.map((chunk) => chunk[n]);
|
||||
const result = new Float32Array(length);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
result.set(buffers[i], offset);
|
||||
offset += buffers[i].length;
|
||||
}
|
||||
channels[n] = result;
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines parallel audio data into correct format,
|
||||
* channels[Left[], Right[]] to float32Array[LRLRLRLR...]
|
||||
*/
|
||||
formatAudioData(channels) {
|
||||
if (channels.length === 1) {
|
||||
// Simple case is only one channel
|
||||
const float32Array = channels[0].slice();
|
||||
const meanValues = channels[0].slice();
|
||||
return { float32Array, meanValues };
|
||||
} else {
|
||||
const float32Array = new Float32Array(
|
||||
channels[0].length * channels.length
|
||||
);
|
||||
const meanValues = new Float32Array(channels[0].length);
|
||||
for (let i = 0; i < channels[0].length; i++) {
|
||||
const offset = i * channels.length;
|
||||
let meanValue = 0;
|
||||
for (let n = 0; n < channels.length; n++) {
|
||||
float32Array[offset + n] = channels[n][i];
|
||||
meanValue += channels[n][i];
|
||||
}
|
||||
meanValues[i] = meanValue / channels.length;
|
||||
}
|
||||
return { float32Array, meanValues };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 32-bit float data to 16-bit integers
|
||||
*/
|
||||
floatTo16BitPCM(float32Array) {
|
||||
const buffer = new ArrayBuffer(float32Array.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < float32Array.length; i++, offset += 2) {
|
||||
let s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the most recent amplitude values from the audio stream
|
||||
* @param {number} channel
|
||||
*/
|
||||
getValues(channel = -1) {
|
||||
const channels = this.readChannelData(this.chunks, channel);
|
||||
const { meanValues } = this.formatAudioData(channels);
|
||||
return { meanValues, channels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports chunks as an audio/wav file
|
||||
*/
|
||||
export() {
|
||||
const channels = this.readChannelData(this.chunks);
|
||||
const { float32Array, meanValues } = this.formatAudioData(channels);
|
||||
const audioData = this.floatTo16BitPCM(float32Array);
|
||||
return {
|
||||
meanValues: meanValues,
|
||||
audio: {
|
||||
bitsPerSample: 16,
|
||||
channels: channels,
|
||||
data: audioData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
receive(e) {
|
||||
const { event, id } = e.data;
|
||||
let receiptData = {};
|
||||
switch (event) {
|
||||
case 'start':
|
||||
this.recording = true;
|
||||
break;
|
||||
case 'stop':
|
||||
this.recording = false;
|
||||
break;
|
||||
case 'clear':
|
||||
this.initialize();
|
||||
break;
|
||||
case 'export':
|
||||
receiptData = this.export();
|
||||
break;
|
||||
case 'read':
|
||||
receiptData = this.getValues();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Always send back receipt
|
||||
this.port.postMessage({ event: 'receipt', id, data: receiptData });
|
||||
}
|
||||
|
||||
sendChunk(chunk) {
|
||||
const channels = this.readChannelData([chunk]);
|
||||
const { float32Array, meanValues } = this.formatAudioData(channels);
|
||||
const rawAudioData = this.floatTo16BitPCM(float32Array);
|
||||
const monoAudioData = this.floatTo16BitPCM(meanValues);
|
||||
this.port.postMessage({
|
||||
event: 'chunk',
|
||||
data: {
|
||||
mono: monoAudioData,
|
||||
raw: rawAudioData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
process(inputList, outputList, parameters) {
|
||||
// Copy input to output (e.g. speakers)
|
||||
// Note that this creates choppy sounds with Mac products
|
||||
const sourceLimit = Math.min(inputList.length, outputList.length);
|
||||
for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {
|
||||
const input = inputList[inputNum];
|
||||
const output = outputList[inputNum];
|
||||
const channelCount = Math.min(input.length, output.length);
|
||||
for (let channelNum = 0; channelNum < channelCount; channelNum++) {
|
||||
input[channelNum].forEach((sample, i) => {
|
||||
output[channelNum][i] = sample;
|
||||
});
|
||||
}
|
||||
}
|
||||
const inputs = inputList[0];
|
||||
// There's latency at the beginning of a stream before recording starts
|
||||
// Make sure we actually receive audio data before we start storing chunks
|
||||
let sliceIndex = 0;
|
||||
if (!this.foundAudio) {
|
||||
for (const channel of inputs) {
|
||||
sliceIndex = 0; // reset for each channel
|
||||
if (this.foundAudio) {
|
||||
break;
|
||||
}
|
||||
if (channel) {
|
||||
for (const value of channel) {
|
||||
if (value !== 0) {
|
||||
// find only one non-zero entry in any channel
|
||||
this.foundAudio = true;
|
||||
break;
|
||||
} else {
|
||||
sliceIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inputs && inputs[0] && this.foundAudio && this.recording) {
|
||||
// We need to copy the TypedArray, because the \`process\`
|
||||
// internals will reuse the same buffer to hold each input
|
||||
const chunk = inputs.map((input) => input.slice(sliceIndex));
|
||||
this.chunks.push(chunk);
|
||||
this.sendChunk(chunk);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio_processor', AudioProcessor);
|
||||
`;
|
||||
|
||||
const script = new Blob([AudioProcessorWorklet], {
|
||||
type: 'application/javascript'
|
||||
});
|
||||
const src = URL.createObjectURL(script);
|
||||
export const AudioProcessorSrc = src;
|
||||
@@ -0,0 +1,96 @@
|
||||
export const StreamProcessorWorklet = `
|
||||
class StreamProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.hasStarted = false;
|
||||
this.hasInterrupted = false;
|
||||
this.outputBuffers = [];
|
||||
this.bufferLength = 128;
|
||||
this.write = { buffer: new Float32Array(this.bufferLength), trackId: null };
|
||||
this.writeOffset = 0;
|
||||
this.trackSampleOffsets = {};
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data) {
|
||||
const payload = event.data;
|
||||
if (payload.event === 'write') {
|
||||
const int16Array = payload.buffer;
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
for (let i = 0; i < int16Array.length; i++) {
|
||||
float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32
|
||||
}
|
||||
this.writeData(float32Array, payload.trackId);
|
||||
} else if (
|
||||
payload.event === 'offset' ||
|
||||
payload.event === 'interrupt'
|
||||
) {
|
||||
const requestId = payload.requestId;
|
||||
const trackId = this.write.trackId;
|
||||
const offset = this.trackSampleOffsets[trackId] || 0;
|
||||
this.port.postMessage({
|
||||
event: 'offset',
|
||||
requestId,
|
||||
trackId,
|
||||
offset,
|
||||
});
|
||||
if (payload.event === 'interrupt') {
|
||||
this.hasInterrupted = true;
|
||||
}
|
||||
} else {
|
||||
throw new Error(\`Unhandled event "\${payload.event}"\`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
writeData(float32Array, trackId = null) {
|
||||
let { buffer } = this.write;
|
||||
let offset = this.writeOffset;
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
buffer[offset++] = float32Array[i];
|
||||
if (offset >= buffer.length) {
|
||||
this.outputBuffers.push(this.write);
|
||||
this.write = { buffer: new Float32Array(this.bufferLength), trackId };
|
||||
buffer = this.write.buffer;
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
this.writeOffset = offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const output = outputs[0];
|
||||
const outputChannelData = output[0];
|
||||
const outputBuffers = this.outputBuffers;
|
||||
if (this.hasInterrupted) {
|
||||
this.port.postMessage({ event: 'stop' });
|
||||
return false;
|
||||
} else if (outputBuffers.length) {
|
||||
this.hasStarted = true;
|
||||
const { buffer, trackId } = outputBuffers.shift();
|
||||
for (let i = 0; i < outputChannelData.length; i++) {
|
||||
outputChannelData[i] = buffer[i] || 0;
|
||||
}
|
||||
if (trackId) {
|
||||
this.trackSampleOffsets[trackId] =
|
||||
this.trackSampleOffsets[trackId] || 0;
|
||||
this.trackSampleOffsets[trackId] += buffer.length;
|
||||
}
|
||||
return true;
|
||||
} else if (this.hasStarted) {
|
||||
this.port.postMessage({ event: 'stop' });
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('stream_processor', StreamProcessor);
|
||||
`;
|
||||
|
||||
const script = new Blob([StreamProcessorWorklet], {
|
||||
type: 'application/javascript'
|
||||
});
|
||||
const src = URL.createObjectURL(script);
|
||||
export const StreamProcessorSrc = src;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
// This is for tsup build since it's not supporting composite
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// THIS MUST BE AT ROOT, if you set baseurl in sub-package it breaks intellisense jump to
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"importHelpers": true,
|
||||
"allowJs": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "system",
|
||||
"moduleResolution": "node",
|
||||
"noEmitOnError": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"preserveConstEnums": true,
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "es5",
|
||||
"types": ["node", "react"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["**/test", "**/dist", "**/__tests__"],
|
||||
"include": ["src/**/*"],
|
||||
"types": ["@testing-library/jest-dom", "node"]
|
||||
}
|
||||
Reference in New Issue
Block a user