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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:48:47 +08:00
commit b7f52be4c9
653 changed files with 105877 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import { cn } from '@/lib/utils';
import { useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { router } from 'router';
import { useAuth, useChatSession, useConfig } from '@chainlit/react-client';
import ChatSettingsModal from './components/ChatSettings';
import { ThemeProvider } from './components/ThemeProvider';
import { Loader } from '@/components/Loader';
import { Toaster } from '@/components/ui/sonner';
import { userEnvState } from 'state/user';
declare global {
interface Window {
cl_shadowRootElement?: HTMLDivElement;
transports?: string[];
theme?: {
light: Record<string, string>;
dark: Record<string, string>;
};
}
}
function App() {
const { config } = useConfig();
const { isAuthenticated, data, isReady } = useAuth();
const userEnv = useRecoilValue(userEnvState);
const { connect, chatProfile, setChatProfile } = useChatSession();
const configLoaded = !!config;
const chatProfileOk = configLoaded
? config.chatProfiles.length
? !!chatProfile
: true
: false;
useEffect(() => {
if (!isAuthenticated || !isReady || !chatProfileOk) {
return;
}
connect({
transports: window.transports,
userEnv
});
}, [userEnv, isAuthenticated, connect, isReady, chatProfileOk]);
useEffect(() => {
if (
!configLoaded ||
!config ||
!config.chatProfiles?.length ||
chatProfile
) {
return;
}
const defaultChatProfile = config.chatProfiles.find(
(profile) => profile.default
);
if (defaultChatProfile) {
setChatProfile(defaultChatProfile.name);
} else {
setChatProfile(config.chatProfiles[0].name);
}
}, [configLoaded, config, chatProfile, setChatProfile]);
if (!configLoaded && isAuthenticated) return null;
return (
<ThemeProvider
storageKey="vite-ui-theme"
defaultTheme={data?.default_theme}
>
<Toaster richColors className="toast" position="top-right" />
<ChatSettingsModal />
<RouterProvider router={router} />
<div
className={cn(
'bg-[hsl(var(--background))] flex items-center justify-center fixed size-full p-2 top-0',
isReady && 'hidden'
)}
>
<Loader className="!size-6" />
</div>
</ThemeProvider>
);
}
export default App;
+54
View File
@@ -0,0 +1,54 @@
import getRouterBasename from '@/lib/router';
import App from 'App';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useApi,
useAuth,
useChatInteract,
useConfig
} from '@chainlit/react-client';
export default function AppWrapper() {
const [translationLoaded, setTranslationLoaded] = useState(false);
const { isAuthenticated, isReady } = useAuth();
const { language: languageInUse } = useConfig();
const { i18n } = useTranslation();
const { windowMessage } = useChatInteract();
function handleChangeLanguage(languageBundle: any): void {
i18n.addResourceBundle(languageInUse, 'translation', languageBundle);
i18n.changeLanguage(languageInUse);
}
const { data: translations } = useApi<any>(
`/project/translations?language=${languageInUse}`
);
useEffect(() => {
if (!translations) return;
handleChangeLanguage(translations.translation);
setTranslationLoaded(true);
}, [translations]);
useEffect(() => {
const handleWindowMessage = (event: MessageEvent) => {
windowMessage(event.data);
};
window.addEventListener('message', handleWindowMessage);
return () => window.removeEventListener('message', handleWindowMessage);
}, [windowMessage]);
if (!translationLoaded) return null;
if (
isReady &&
!isAuthenticated &&
window.location.pathname !== getRouterBasename() + '/login' &&
window.location.pathname !== getRouterBasename() + '/login/callback'
) {
window.location.href = getRouterBasename() + '/login';
}
return <App />;
}
+83
View File
@@ -0,0 +1,83 @@
import getRouterBasename from '@/lib/router';
import { toast } from 'sonner';
import { ChainlitAPI, ClientError } from '@chainlit/react-client';
const devServer =
(import.meta.env.VITE_API_URL || 'http://localhost:8000') +
getRouterBasename();
const url = import.meta.env.DEV
? devServer
: window.origin + getRouterBasename();
const serverUrl = new URL(url);
const httpEndpoint = serverUrl.toString();
const on401 = () => {
if (window.location.pathname !== getRouterBasename() + '/login') {
// The credentials aren't correct, remove the token and redirect to login
window.location.href = getRouterBasename() + '/login';
}
};
const onError = (error: ClientError) => {
toast.error(error.toString());
};
class ExtendedChainlitAPI extends ChainlitAPI {
async shareThread(
threadId: string,
isShared: boolean
): Promise<{ success: boolean }> {
const res = await this.put(`/project/thread/share`, {
threadId,
isShared
});
return res.json();
}
connectStreamableHttpMCP(
sessionId: string,
name: string,
url: string,
headers?: Record<string, string>
) {
// Assumes the backend expects { clientType, name, url }
return fetch(
new URL(
'mcp',
this.httpEndpoint.endsWith('/')
? this.httpEndpoint
: `${this.httpEndpoint}/`
),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(sessionId ? { 'x-session-id': sessionId } : {})
},
body: JSON.stringify({
clientType: 'streamable-http',
name,
url,
sessionId,
...(headers ? { headers } : {})
})
}
).then(async (res) => {
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to connect MCP');
}
return { success: true, mcp: data.mcp };
});
}
}
export const apiClient = new ExtendedChainlitAPI(
httpEndpoint,
'webapp',
{}, // Optional - additionalQueryParams property.
on401,
onError
);
+26
View File
@@ -0,0 +1,26 @@
<svg width="1143" height="266" viewBox="0 0 1143 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M342.542 133.753C342.542 85.5861 378.512 59.1252 416.55 59.7454C445.698 59.7454 476.707 75.4565 483.322 111.22H453.347C446.938 94.8888 433.915 87.6533 416.55 87.6533C390.916 87.6533 373.757 106.259 373.757 133.753C373.757 158.56 390.502 179.233 416.343 179.233C434.328 179.233 449.006 170.757 453.967 153.185H483.735C477.534 191.43 446.732 207.347 416.55 207.347C378.512 207.347 342.542 181.713 342.542 133.753ZM533.503 148.844V206.107H504.561V54.784H533.503V119.696C541.565 107.499 553.555 103.778 566.166 104.398C588.492 105.432 598.622 117.835 601.722 136.647C602.343 141.402 602.549 145.95 602.549 150.911V177.579C602.549 186.468 603.996 188.535 613.713 187.915V205.28C589.732 210.242 573.608 205.28 573.608 179.026V160.007C573.608 153.392 573.608 147.81 572.988 143.056C571.334 132.513 566.993 126.931 554.796 126.931C543.633 126.931 533.503 134.787 533.503 148.844ZM726.013 113.287V173.858C726.013 182.334 725.6 186.882 735.522 186.261V204.867C722.085 207.761 702.653 208.795 702.653 188.329C697.071 200.732 684.254 207.554 671.024 207.554C640.222 206.934 625.958 183.161 625.958 154.839C626.991 121.143 649.938 103.364 683.634 103.985C698.312 104.191 712.783 107.706 726.013 113.287ZM697.898 155.873V130.032C692.317 127.758 686.322 127.138 681.36 127.138C665.649 127.138 655.52 135.82 654.899 154.839C654.899 170.55 662.341 182.127 677.226 181.92C691.076 181.92 697.278 170.964 697.898 155.873ZM753.629 77.5238C753.629 55.4042 787.532 55.4042 787.532 77.5238C787.532 99.8502 753.629 99.8502 753.629 77.5238ZM785.051 107.499V205.9H756.109V107.499H785.051ZM804.992 122.797V105.845C819.669 103.158 843.029 101.711 843.029 120.936C849.438 109.153 861.221 103.985 874.452 103.985C902.566 103.985 914.35 123.21 913.73 148.017V175.098C913.73 184.401 914.143 188.949 924.273 188.329V205.28C904.634 210.242 884.995 207.761 884.995 182.954V148.017C885.201 134.167 876.312 126.931 865.769 126.931C855.019 126.311 844.89 134.787 844.89 148.637V205.9H815.948V137.061C815.948 126.104 816.155 122.177 804.992 122.797ZM942.017 54.5773H970.545V168.07C970.545 183.367 976.54 186.468 990.391 184.194L991.424 205.9C960.622 211.482 942.017 203.833 942.017 168.07V54.5773ZM1004.36 77.5238C1004.36 55.4042 1038.27 55.4042 1038.27 77.5238C1038.27 99.8502 1004.36 99.8502 1004.36 77.5238ZM1035.79 107.499V205.9H1006.84V107.499H1035.79ZM1101 75.4565V106.465H1133.46V128.172H1101V166.829C1101 187.502 1118.16 188.535 1134.7 182.54L1137.38 204.453C1103.07 213.963 1072.68 208.381 1072.68 167.449V128.172L1053.87 126.311V106.465H1074.75L1077.85 75.4565H1101Z" fill="white"/>
<path d="M80.0961 121.626C78.244 115.214 74.7914 109.167 69.7384 104.114C53.7848 88.16 27.9188 88.16 11.9652 104.114C-3.98841 120.067 -3.98841 145.933 11.9652 161.887C17.0183 166.94 23.0657 170.392 29.4777 172.245C32.8124 173.307 36.6495 174.133 40.8349 175.034C54.6805 178.016 72.3375 181.818 88.2258 197.706C88.2258 197.706 90.5368 192.507 95.4475 187.596C100.358 182.685 105.558 180.374 105.558 180.374C89.6695 164.486 85.8674 146.829 82.8861 132.983C81.9848 128.798 81.1586 124.961 80.0961 121.626Z" fill="url(#paint0_linear_361_1664)"/>
<path d="M185.904 144.375C187.756 150.786 191.209 156.834 196.262 161.887C212.215 177.84 238.081 177.84 254.035 161.887C269.988 145.933 269.988 120.067 254.035 104.114C248.982 99.0605 242.934 95.608 236.522 93.7559C233.188 92.6934 229.351 91.8672 225.165 90.966C211.32 87.9846 193.662 84.1825 177.774 68.2942C177.774 68.2942 175.463 73.4938 170.553 78.4045C165.642 83.3153 160.442 85.6262 160.442 85.6262C176.331 101.514 180.133 119.172 183.114 133.017C184.015 137.203 184.841 141.04 185.904 144.375Z" fill="url(#paint1_linear_361_1664)"/>
<path d="M93.7559 29.4775C95.608 23.0656 99.0606 17.0182 104.114 11.9652C120.067 -3.98841 145.933 -3.98841 161.887 11.9652C177.84 27.9188 177.84 53.7848 161.887 69.7384C156.834 74.7915 150.786 78.244 144.374 80.0962C141.04 81.1586 137.203 81.9848 133.017 82.8861C119.172 85.8674 101.514 89.6695 85.6262 105.558C85.6262 105.558 83.3153 100.358 78.4045 95.4475C73.4938 90.5368 68.2942 88.2258 68.2942 88.2258C84.1825 72.3375 87.9846 54.6805 90.966 40.8349C91.8672 36.6494 92.6935 32.8122 93.7559 29.4775Z" fill="url(#paint2_linear_361_1664)"/>
<path d="M172.244 236.523C170.392 242.935 166.939 248.982 161.886 254.035C145.933 269.989 120.067 269.989 104.113 254.035C88.1596 238.082 88.1596 212.216 104.113 196.262C109.166 191.209 115.214 187.756 121.626 185.904C124.96 184.842 128.797 184.016 132.983 183.114C146.828 180.133 164.486 176.331 180.374 160.443C180.374 160.443 182.685 165.642 187.595 170.553C192.506 175.464 197.706 177.775 197.706 177.775C181.817 193.663 178.015 211.32 175.034 225.165C174.133 229.351 173.307 233.188 172.244 236.523Z" fill="url(#paint3_linear_361_1664)"/>
<path d="M79.6157 121.765L79.6156 121.765L79.6197 121.778C80.6734 125.085 81.4946 128.897 82.3973 133.089L82.4019 133.11C85.3524 146.812 89.1323 164.366 104.71 180.229C104.626 180.271 104.536 180.318 104.438 180.368C103.864 180.667 103.049 181.116 102.08 181.719C100.143 182.925 97.5829 184.753 95.0939 187.242C92.605 189.731 90.7768 192.291 89.5707 194.228C88.9673 195.197 88.5186 196.012 88.22 196.586C88.1692 196.684 88.1227 196.775 88.0806 196.858C72.218 181.281 54.664 177.501 40.9618 174.55L40.9401 174.546C36.7483 173.643 32.9366 172.822 29.6295 171.768L29.6295 171.768L29.6164 171.764C23.2836 169.935 17.3106 166.525 12.3188 161.533C-3.43959 145.775 -3.43959 120.226 12.3188 104.467C28.0771 88.7088 53.6265 88.7088 69.3849 104.467C74.3766 109.459 77.7865 115.432 79.6157 121.765ZM186.384 144.236L186.384 144.236L186.38 144.223C185.327 140.916 184.505 137.104 183.603 132.912L183.598 132.89C180.648 119.188 176.868 101.634 161.29 85.7715C161.374 85.7293 161.464 85.6828 161.562 85.632C162.136 85.3334 162.951 84.8847 163.92 84.2813C165.857 83.0752 168.417 81.247 170.906 78.7581C173.395 76.2692 175.223 73.7092 176.429 71.7721C177.033 70.803 177.481 69.9883 177.78 69.4141C177.831 69.3163 177.877 69.2255 177.919 69.1421C193.782 84.7197 211.336 88.4996 225.038 91.4501L225.06 91.4548C229.252 92.3574 233.063 93.1786 236.371 94.2323L236.37 94.2324L236.384 94.2362C242.716 96.0655 248.689 99.4753 253.681 104.467C269.44 120.226 269.44 145.775 253.681 161.533C237.923 177.292 212.374 177.292 196.615 161.533C191.623 156.541 188.214 150.569 186.384 144.236ZM94.2323 29.6293L94.2325 29.6293L94.2363 29.6162C96.0655 23.2835 99.4754 17.3105 104.467 12.3188C120.226 -3.43959 145.775 -3.43959 161.533 12.3188C177.292 28.0771 177.292 53.6265 161.533 69.3849C156.541 74.3767 150.568 77.7865 144.236 79.6158L144.236 79.6156L144.223 79.6198C140.915 80.6734 137.104 81.4946 132.912 82.3973L132.89 82.4019C119.188 85.3524 101.634 89.1323 85.7715 104.71C85.7293 104.626 85.6828 104.536 85.632 104.438C85.3334 103.864 84.8847 103.049 84.2813 102.08C83.0752 100.143 81.247 97.5829 78.7581 95.0939C76.2692 92.605 73.7092 90.7768 71.7721 89.5707C70.803 88.9673 69.9883 88.5186 69.4141 88.22C69.3163 88.1692 69.2255 88.1227 69.1421 88.0806C84.7197 72.218 88.4996 54.664 91.4501 40.9618L91.4548 40.9401C92.3574 36.7482 93.1786 32.9364 94.2323 29.6293ZM171.768 236.371L171.768 236.371L171.764 236.384C169.934 242.717 166.525 248.69 161.533 253.682C145.774 269.44 120.225 269.44 104.467 253.682C88.7084 237.923 88.7084 212.374 104.467 196.616C109.459 191.624 115.432 188.214 121.764 186.385L121.764 186.385L121.777 186.381C125.085 185.327 128.896 184.506 133.088 183.603L133.11 183.598C146.812 180.648 164.366 176.868 180.229 161.29C180.271 161.374 180.317 161.465 180.368 161.562C180.667 162.137 181.115 162.951 181.719 163.92C182.925 165.858 184.753 168.418 187.242 170.906C189.731 173.395 192.291 175.224 194.228 176.43C195.197 177.033 196.012 177.482 196.586 177.78C196.684 177.831 196.774 177.878 196.858 177.92C181.28 193.782 177.5 211.336 174.55 225.039L174.545 225.06C173.643 229.252 172.821 233.064 171.768 236.371Z" stroke="black" strokeOpacity="0.1" strokeLinejoin="round"/>
<defs>
<linearGradient id="paint0_linear_361_1664" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint1_linear_361_1664" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint2_linear_361_1664" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint3_linear_361_1664" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg width="1143" height="266" viewBox="0 0 1143 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M342.542 133.753C342.542 181.713 378.512 207.347 416.55 207.347C446.732 207.347 477.534 191.43 483.735 153.185H453.967C449.006 170.757 434.328 179.233 416.343 179.233C390.502 179.233 373.757 158.56 373.757 133.753C373.757 106.259 390.916 87.6533 416.55 87.6533C433.915 87.6533 446.938 94.8888 453.347 111.22H483.322C476.707 75.4565 445.698 59.7454 416.55 59.7454C378.512 59.1252 342.542 85.5861 342.542 133.753ZM533.503 148.844C533.503 134.787 543.633 126.931 554.796 126.931C566.993 126.931 571.334 132.513 572.988 143.056C573.608 147.81 573.608 153.392 573.608 160.007V179.026C573.608 205.28 589.732 210.242 613.713 205.28V187.915C603.996 188.535 602.549 186.468 602.549 177.579V150.911C602.549 145.95 602.343 141.402 601.722 136.647C598.622 117.835 588.492 105.432 566.166 104.398C553.555 103.778 541.565 107.499 533.503 119.696V54.784H504.561V206.107H533.503V148.844ZM726.013 113.287C712.783 107.706 698.312 104.191 683.634 103.985C649.938 103.364 626.991 121.143 625.958 154.839C625.958 183.161 640.222 206.934 671.024 207.554C684.254 207.554 697.071 200.732 702.653 188.329C702.653 208.795 722.085 207.761 735.522 204.867V186.261C725.6 186.882 726.013 182.334 726.013 173.858V113.287ZM697.898 155.873C697.278 170.964 691.076 181.92 677.226 181.92C662.341 182.127 654.899 170.55 654.899 154.839C655.52 135.82 665.649 127.138 681.36 127.138C686.322 127.138 692.317 127.758 697.898 130.032V155.873ZM753.629 77.5238C753.629 99.8502 787.532 99.8502 787.532 77.5238C787.532 55.4042 753.629 55.4042 753.629 77.5238ZM785.051 107.499H756.109V205.9H785.051V107.499ZM804.992 122.797C816.155 122.177 815.948 126.104 815.948 137.061V205.9H844.89V148.637C844.89 134.787 855.019 126.311 865.769 126.931C876.312 126.931 885.201 134.167 884.995 148.017V182.954C884.995 207.761 904.634 210.242 924.273 205.28V188.329C914.143 188.949 913.73 184.401 913.73 175.098V148.017C914.35 123.21 902.566 103.985 874.452 103.985C861.221 103.985 849.438 109.153 843.029 120.936C843.029 101.711 819.669 103.158 804.992 105.845V122.797ZM942.017 54.5773V168.07C942.017 203.833 960.622 211.482 991.424 205.9L990.391 184.194C976.54 186.468 970.545 183.367 970.545 168.07V54.5773H942.017ZM1004.36 77.5238C1004.36 99.8502 1038.27 99.8502 1038.27 77.5238C1038.27 55.4042 1004.36 55.4042 1004.36 77.5238ZM1035.79 107.499H1006.84V205.9H1035.79V107.499ZM1101 75.4565H1077.85L1074.75 106.465H1053.87V126.311L1072.68 128.172V167.449C1072.68 208.381 1103.07 213.963 1137.38 204.453L1134.7 182.54C1118.16 188.535 1101 187.502 1101 166.829V128.172H1133.46V106.465H1101V75.4565Z" fill="#2A1351"/>
<path d="M80.0961 121.626C78.244 115.214 74.7914 109.167 69.7384 104.114C53.7848 88.16 27.9188 88.16 11.9652 104.114C-3.98841 120.067 -3.98841 145.933 11.9652 161.887C17.0183 166.94 23.0657 170.392 29.4777 172.245C32.8124 173.307 36.6495 174.133 40.8349 175.034C54.6805 178.016 72.3375 181.818 88.2258 197.706C88.2258 197.706 90.5368 192.507 95.4475 187.596C100.358 182.685 105.558 180.374 105.558 180.374C89.6695 164.486 85.8674 146.829 82.8861 132.983C81.9848 128.798 81.1586 124.961 80.0961 121.626Z" fill="url(#paint0_linear_361_1534)"/>
<path d="M185.904 144.375C187.756 150.786 191.209 156.834 196.262 161.887C212.215 177.84 238.081 177.84 254.035 161.887C269.988 145.933 269.988 120.067 254.035 104.114C248.982 99.0605 242.934 95.608 236.522 93.7559C233.188 92.6934 229.351 91.8672 225.165 90.966C211.32 87.9846 193.662 84.1825 177.774 68.2942C177.774 68.2942 175.463 73.4938 170.553 78.4045C165.642 83.3153 160.442 85.6262 160.442 85.6262C176.331 101.514 180.133 119.172 183.114 133.017C184.015 137.203 184.841 141.04 185.904 144.375Z" fill="url(#paint1_linear_361_1534)"/>
<path d="M93.7559 29.4775C95.608 23.0656 99.0606 17.0182 104.114 11.9652C120.067 -3.98841 145.933 -3.98841 161.887 11.9652C177.84 27.9188 177.84 53.7848 161.887 69.7384C156.834 74.7915 150.786 78.244 144.374 80.0962C141.04 81.1586 137.203 81.9848 133.017 82.8861C119.172 85.8674 101.514 89.6695 85.6262 105.558C85.6262 105.558 83.3153 100.358 78.4045 95.4475C73.4938 90.5368 68.2942 88.2258 68.2942 88.2258C84.1825 72.3375 87.9846 54.6805 90.966 40.8349C91.8672 36.6494 92.6935 32.8122 93.7559 29.4775Z" fill="url(#paint2_linear_361_1534)"/>
<path d="M172.244 236.523C170.392 242.935 166.939 248.982 161.886 254.035C145.933 269.989 120.067 269.989 104.113 254.035C88.1596 238.082 88.1596 212.216 104.113 196.262C109.166 191.209 115.214 187.756 121.626 185.904C124.96 184.842 128.797 184.016 132.983 183.114C146.828 180.133 164.486 176.331 180.374 160.443C180.374 160.443 182.685 165.642 187.595 170.553C192.506 175.464 197.706 177.775 197.706 177.775C181.817 193.663 178.015 211.32 175.034 225.165C174.133 229.351 173.307 233.188 172.244 236.523Z" fill="url(#paint3_linear_361_1534)"/>
<path d="M79.6157 121.765L79.6156 121.765L79.6197 121.778C80.6734 125.085 81.4946 128.897 82.3973 133.089L82.4019 133.11C85.3524 146.812 89.1323 164.366 104.71 180.229C104.626 180.271 104.536 180.318 104.438 180.368C103.864 180.667 103.049 181.116 102.08 181.719C100.143 182.925 97.5829 184.753 95.0939 187.242C92.605 189.731 90.7768 192.291 89.5707 194.228C88.9673 195.197 88.5186 196.012 88.22 196.586C88.1692 196.684 88.1227 196.775 88.0806 196.858C72.218 181.281 54.664 177.501 40.9618 174.55L40.9401 174.546C36.7483 173.643 32.9366 172.822 29.6295 171.768L29.6295 171.768L29.6164 171.764C23.2836 169.935 17.3106 166.525 12.3188 161.533C-3.43959 145.775 -3.43959 120.226 12.3188 104.467C28.0771 88.7088 53.6265 88.7088 69.3849 104.467C74.3766 109.459 77.7865 115.432 79.6157 121.765ZM186.384 144.236L186.384 144.236L186.38 144.223C185.327 140.916 184.505 137.104 183.603 132.912L183.598 132.89C180.648 119.188 176.868 101.634 161.29 85.7715C161.374 85.7293 161.464 85.6828 161.562 85.632C162.136 85.3334 162.951 84.8847 163.92 84.2813C165.857 83.0752 168.417 81.247 170.906 78.7581C173.395 76.2692 175.223 73.7092 176.429 71.7721C177.033 70.803 177.481 69.9883 177.78 69.4141C177.831 69.3163 177.877 69.2255 177.919 69.1421C193.782 84.7197 211.336 88.4996 225.038 91.4501L225.06 91.4548C229.252 92.3574 233.063 93.1786 236.371 94.2323L236.37 94.2324L236.384 94.2362C242.716 96.0655 248.689 99.4753 253.681 104.467C269.44 120.226 269.44 145.775 253.681 161.533C237.923 177.292 212.374 177.292 196.615 161.533C191.623 156.541 188.214 150.569 186.384 144.236ZM94.2323 29.6293L94.2325 29.6293L94.2363 29.6162C96.0655 23.2835 99.4754 17.3105 104.467 12.3188C120.226 -3.43959 145.775 -3.43959 161.533 12.3188C177.292 28.0771 177.292 53.6265 161.533 69.3849C156.541 74.3767 150.568 77.7865 144.236 79.6158L144.236 79.6156L144.223 79.6198C140.915 80.6734 137.104 81.4946 132.912 82.3973L132.89 82.4019C119.188 85.3524 101.634 89.1323 85.7715 104.71C85.7293 104.626 85.6828 104.536 85.632 104.438C85.3334 103.864 84.8847 103.049 84.2813 102.08C83.0752 100.143 81.247 97.5829 78.7581 95.0939C76.2692 92.605 73.7092 90.7768 71.7721 89.5707C70.803 88.9673 69.9883 88.5186 69.4141 88.22C69.3163 88.1692 69.2255 88.1227 69.1421 88.0806C84.7197 72.218 88.4996 54.664 91.4501 40.9618L91.4548 40.9401C92.3574 36.7482 93.1786 32.9364 94.2323 29.6293ZM171.768 236.371L171.768 236.371L171.764 236.384C169.934 242.717 166.525 248.69 161.533 253.682C145.774 269.44 120.225 269.44 104.467 253.682C88.7084 237.923 88.7084 212.374 104.467 196.616C109.459 191.624 115.432 188.214 121.764 186.385L121.764 186.385L121.777 186.381C125.085 185.327 128.896 184.506 133.088 183.603L133.11 183.598C146.812 180.648 164.366 176.868 180.229 161.29C180.271 161.374 180.317 161.465 180.368 161.562C180.667 162.137 181.115 162.951 181.719 163.92C182.925 165.858 184.753 168.418 187.242 170.906C189.731 173.395 192.291 175.224 194.228 176.43C195.197 177.033 196.012 177.482 196.586 177.78C196.684 177.831 196.774 177.878 196.858 177.92C181.28 193.782 177.5 211.336 174.55 225.039L174.545 225.06C173.643 229.252 172.821 233.064 171.768 236.371Z" stroke="black" strokeOpacity="0.1" strokeLinejoin="round"/>
<defs>
<linearGradient id="paint0_linear_361_1534" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint1_linear_361_1534" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint2_linear_361_1534" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
<linearGradient id="paint3_linear_361_1534" x1="119.251" y1="276.386" x2="112.859" y2="-28.0325" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF004F"/>
<stop offset="1" stop-color="#FF0581"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

+99
View File
@@ -0,0 +1,99 @@
import { cn } from '@/lib/utils';
import React from 'react';
type AlertVariant = 'info' | 'error';
interface AlertProps {
variant?: AlertVariant;
children: React.ReactNode;
className?: string;
id?: string;
}
const variantStyles = {
info: {
light: {
container:
'bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-900',
icon: 'text-blue-400 dark:text-blue-300',
text: 'text-blue-700 dark:text-blue-200'
},
dark: {
container: 'bg-blue-950 border-blue-900',
icon: 'text-blue-300',
text: 'text-blue-200'
}
},
error: {
light: {
container: 'bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-900',
icon: 'text-red-400 dark:text-red-300',
text: 'text-red-700 dark:text-red-200'
},
dark: {
container: 'bg-red-950 border-red-900',
icon: 'text-red-300',
text: 'text-red-200'
}
}
};
const icons = {
info: (
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clipRule="evenodd"
/>
</svg>
),
error: (
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clipRule="evenodd"
/>
</svg>
)
};
export const Alert: React.FC<AlertProps> = ({
variant = 'info',
children,
className,
id
}) => {
const styles = variantStyles[variant].light;
return (
<div
id={id}
className={cn(
'border rounded-lg p-4 mb-4 alert',
styles.container,
className
)}
>
<div className="flex">
<div className={cn('flex-shrink-0', styles.icon)}>{icons[variant]}</div>
<div className="ml-3">
<p className={cn('text-sm', styles.text)}>{children}</p>
</div>
</div>
</div>
);
};
export default Alert;
+135
View File
@@ -0,0 +1,135 @@
import { hslToHex } from '@/lib/utils';
import { useEffect, useMemo, useRef } from 'react';
import { WavRenderer, useAudio } from '@chainlit/react-client';
import { useTheme } from '@/components/ThemeProvider';
interface Props {
type: 'client' | 'server';
height: number;
width: number;
barCount: number;
barSpacing: number;
}
export default function AudioPresence({
type,
height,
width,
barCount,
barSpacing
}: Props) {
const { variant } = useTheme();
const { wavRecorder, wavStreamPlayer, isAiSpeaking } = useAudio();
const canvasRef = useRef<HTMLCanvasElement>(null);
const foregroundColor = useMemo(() => {
const root = document.documentElement;
const styles = getComputedStyle(root);
return hslToHex(styles.getPropertyValue('--foreground'));
}, [variant]);
width = type === 'server' && !isAiSpeaking ? height : width;
useEffect(() => {
let isLoaded = true;
const dpr = window.devicePixelRatio || 1;
let bounceDirection = 1;
let bounceFactor = 0;
const getData = () => {
if (type === 'server' && isAiSpeaking) {
return wavStreamPlayer.analyser
? wavStreamPlayer.getFrequencies('voice')
: { values: new Float32Array([0]) };
} else {
return wavRecorder.recording
? wavRecorder.getFrequencies('voice')
: { values: new Float32Array([0]) };
}
};
const render = () => {
if (!isLoaded) return;
const canvas = canvasRef.current;
let ctx: CanvasRenderingContext2D | null = null;
if (canvas) {
// Set the canvas size based on the DPR
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx = ctx || canvas.getContext('2d');
if (ctx) {
// Scale the context to account for the DPR
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height); // Use CSS dimensions here
const result = getData();
if (type === 'server' && !isAiSpeaking) {
// Draw a bouncing circle
const amplitude = Math.min(
Math.max(0.6, Math.max(...result.values)),
1
); // Ensure a minimum amplitude
const maxRadius = width / 2;
const baseRadius = maxRadius * amplitude;
const radius = baseRadius * (0.6 + 0.2 * bounceFactor);
const centerX = width / 2;
const centerY = height / 2;
ctx.fillStyle = foregroundColor;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
const newFactor = bounceFactor + 0.01 * bounceDirection;
if (newFactor > 1 || newFactor < 0) {
bounceDirection *= -1;
}
bounceFactor = Math.max(0, Math.min(newFactor, 1));
} else {
WavRenderer.drawBars(
ctx,
result.values,
width,
height,
foregroundColor,
barCount,
0,
barSpacing,
true
);
}
}
}
window.requestAnimationFrame(render);
};
render();
return () => {
isLoaded = false;
};
}, [
height,
width,
barCount,
barSpacing,
foregroundColor,
wavRecorder,
isAiSpeaking
]);
return (
<div className="flex items-center gap-1">
{type === 'server' && !isAiSpeaking ? (
<div className="text-muted-foreground">Listening</div>
) : null}
<canvas ref={canvasRef} />
</div>
);
}
@@ -0,0 +1,106 @@
import { cn } from '@/lib/utils';
import { useEffect, useRef, useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
interface Props extends Omit<React.ComponentProps<'textarea'>, 'onPaste'> {
maxHeight?: number;
placeholder?: string;
onPaste?: (event: ClipboardEvent) => void;
onEnter?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCompositionStart?: (
event: React.CompositionEvent<HTMLTextAreaElement>
) => void;
onCompositionEnd?: (
event: React.CompositionEvent<HTMLTextAreaElement>
) => void;
}
const AutoResizeTextarea = ({
maxHeight,
onPaste,
onEnter,
placeholder,
className,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: Props) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposing, setIsComposing] = useState(false);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || !onPaste) return;
textarea.addEventListener('paste', onPaste);
return () => {
textarea.removeEventListener('paste', onPaste);
};
}, [onPaste]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || !maxHeight) return;
textarea.style.height = '40px';
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}, [props.value, maxHeight]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Call the parent's onKeyDown first (this is Input's handler)
if (onKeyDown) {
onKeyDown(event);
}
// Only handle our Enter logic if the event wasn't already handled
if (
!event.defaultPrevented &&
event.key === 'Enter' &&
!event.shiftKey &&
onEnter &&
!isComposing
) {
event.preventDefault();
onEnter(event);
}
};
const handleCompositionStart = (
event: React.CompositionEvent<HTMLTextAreaElement>
) => {
setIsComposing(true);
if (onCompositionStart) {
onCompositionStart(event);
}
};
const handleCompositionEnd = (
event: React.CompositionEvent<HTMLTextAreaElement>
) => {
setIsComposing(false);
if (onCompositionEnd) {
onCompositionEnd(event);
}
};
return (
<Textarea
ref={textareaRef as any}
{...props}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
className={cn(
'p-0 min-h-[40px] h-[40px] rounded-none resize-none border-none overflow-y-auto shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0',
className
)}
placeholder={placeholder}
style={{ maxHeight }}
/>
);
};
export default AutoResizeTextarea;
@@ -0,0 +1,54 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { toast } from 'sonner';
import {
resumeThreadErrorState,
useChatInteract,
useChatSession,
useConfig
} from '@chainlit/react-client';
interface Props {
id: string;
}
export default function AutoResumeThread({ id }: Props) {
const navigate = useNavigate();
const { config } = useConfig();
const { clear, setIdToResume } = useChatInteract();
const { session, idToResume } = useChatSession();
const [resumeThreadError, setResumeThreadError] = useRecoilState(
resumeThreadErrorState
);
useEffect(() => {
if (!config?.threadResumable) return;
clear();
setIdToResume(id);
if (!config?.dataPersistence) {
navigate('/');
}
}, [config?.threadResumable, id]);
useEffect(() => {
if (id !== idToResume) {
return;
}
if (session?.error) {
toast.error("Couldn't resume chat");
navigate('/');
}
}, [session, idToResume, id]);
useEffect(() => {
if (resumeThreadError) {
toast.error("Couldn't resume chat: " + resumeThreadError);
navigate('/');
setResumeThreadError(undefined);
}
}, [resumeThreadError]);
return null;
}
@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
export const CURSOR_PLACEHOLDER = '\u200B';
interface Props {
whitespace?: boolean;
}
export default function BlinkingCursor({ whitespace }: Props) {
return (
<span className={cn('inline-block loading-cursor', whitespace && 'ml-2')} />
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useContext } from 'react';
import { ChainlitContext } from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
export interface ButtonLinkProps {
name?: string;
displayName?: string;
iconUrl?: string;
url: string;
target?: '_blank' | '_self' | '_parent' | '_top';
}
export default function ButtonLink({
name,
displayName,
iconUrl,
url,
target
}: ButtonLinkProps) {
const apiClient = useContext(ChainlitContext);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size={displayName ? 'default' : 'icon'}
className="text-muted-foreground hover:text-muted-foreground"
>
<a
href={url}
target={target ?? '_blank'}
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
<img
src={
iconUrl?.startsWith('/public')
? apiClient.buildEndpoint(iconUrl)
: iconUrl
}
className={'h-6 w-6'}
alt={name}
/>
{displayName && <span>{displayName}</span>}
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,243 @@
import cloneDeep from 'lodash/cloneDeep';
import mapValues from 'lodash/mapValues';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRecoilState, useSetRecoilState } from 'recoil';
import {
chatSettingsInputsState,
chatSettingsValueState,
useChatData,
useChatInteract,
useConfig
} from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ResizableHandle, ResizablePanel } from '@/components/ui/resizable';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Translator } from 'components/i18n';
import { useIsMobile } from '@/hooks/use-mobile';
import { chatSettingsSidebarOpenState } from '@/state/project';
import { FormInput, TFormInputValue } from './FormInput';
import { useChatSettingsSnapshotAtOpen } from './useChatSettingsSnapshotAtOpen';
export default function ChatSettingsSidebar() {
const { config } = useConfig();
const { chatSettingsValue, chatSettingsInputs } = useChatData();
const { updateChatSettings, editChatSettings } = useChatInteract();
const [sidebarOpen, setSidebarOpen] = useRecoilState(
chatSettingsSidebarOpenState
);
const isMobile = useIsMobile();
const [isVisible, setIsVisible] = useState(false);
const { valuesAtOpen, inputsAtOpen } = useChatSettingsSnapshotAtOpen(
sidebarOpen,
chatSettingsValue,
chatSettingsInputs
);
const { handleSubmit, setValue, reset, watch, getValues } = useForm({
defaultValues: chatSettingsValue
});
const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);
const setChatSettingsInputs = useSetRecoilState(chatSettingsInputsState);
const restoreSnapshot = useCallback(() => {
setChatSettingsInputs(cloneDeep(inputsAtOpen));
setChatSettingsValue(cloneDeep(valuesAtOpen));
}, [inputsAtOpen, setChatSettingsInputs, setChatSettingsValue, valuesAtOpen]);
useEffect(() => {
reset(chatSettingsValue);
}, [chatSettingsValue, reset]);
useEffect(() => {
if (
config?.ui?.default_chat_settings_open &&
chatSettingsInputs.length > 0
) {
setSidebarOpen(true);
}
}, [
config?.ui?.default_chat_settings_open,
chatSettingsInputs.length,
setSidebarOpen
]);
useEffect(() => {
if (sidebarOpen) {
requestAnimationFrame(() => {
setIsVisible(true);
});
} else {
setIsVisible(false);
}
}, [sidebarOpen]);
const handleClose = () => {
restoreSnapshot();
setSidebarOpen(false);
};
const handleConfirm = handleSubmit((data) => {
const processedValues = mapValues(data, (x: TFormInputValue) =>
x !== '' ? x : null
);
updateChatSettings(processedValues);
setChatSettingsValue(processedValues);
setSidebarOpen(false);
});
const handleReset = () => {
restoreSnapshot();
};
const handleChange = () => {};
const setFieldValue = (field: string, value: any) => {
setValue(field, value);
editChatSettings(getValues());
};
const values = watch();
const tabInputs = chatSettingsInputs.filter(
(input: any) => Array.isArray(input?.inputs) && input.inputs.length > 0
);
const regularInputs = chatSettingsInputs.filter(
(input: any) => !Array.isArray(input?.inputs) || input.inputs.length === 0
);
const hasTabs = tabInputs.length > 0;
const defaultTab = tabInputs[0]?.id;
if (!sidebarOpen || chatSettingsInputs.length === 0) return null;
const settingsContent = (
<>
{hasTabs ? (
<Tabs
defaultValue={defaultTab}
className="flex flex-col flex-grow min-h-0"
>
<TabsList className="w-full flex justify-start flex-wrap h-auto">
{tabInputs.map((tab: any) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label ?? tab.id}
</TabsTrigger>
))}
</TabsList>
{tabInputs.map((tab: any) => (
<TabsContent
key={tab.id}
value={tab.id}
className="data-[state=active]:flex flex-col flex-grow overflow-y-auto gap-4 p-1 mt-4"
>
{tab.inputs?.map((input: any) => (
<FormInput
key={input.id}
element={{
...input,
value: values[input.id],
onChange: handleChange,
setField: setFieldValue
}}
/>
))}
</TabsContent>
))}
</Tabs>
) : (
<div className="flex flex-col flex-grow overflow-y-auto gap-4 p-1">
{regularInputs.map((input: any) => (
<FormInput
key={input.id}
element={{
...input,
value: values[input.id],
onChange: handleChange,
setField: setFieldValue
}}
/>
))}
</div>
)}
<div className="flex gap-2 pt-4 border-t">
<Button variant="outline" size="sm" onClick={handleReset}>
<Translator path="common.actions.reset" />
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={handleClose}>
<Translator path="common.actions.cancel" />
</Button>
<Button size="sm" onClick={handleConfirm} id="confirm-sidebar">
<Translator path="common.actions.confirm" />
</Button>
</div>
</>
);
if (isMobile) {
return (
<Sheet open onOpenChange={(open) => !open && handleClose()}>
<SheetContent className="flex flex-col md:hidden">
<SheetHeader>
<SheetTitle id="chat-settings-sidebar-title">
<Translator path="chat.settings.title" />
</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto flex-grow flex flex-col gap-4 mt-4">
{settingsContent}
</div>
</SheetContent>
</Sheet>
);
}
return (
<>
<ResizableHandle className="sm:hidden md:block bg-transparent" />
<ResizablePanel
minSize={15}
defaultSize={25}
className={`md:flex flex-col flex-grow sm:hidden transform transition-transform duration-300 ease-in-out ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
>
<aside className="relative flex-grow overflow-y-auto mr-4 mb-4">
<Card className="overflow-y-auto h-full relative flex flex-col">
<div
id="chat-settings-sidebar-title"
className="text-lg font-semibold text-foreground px-6 py-4 flex items-center"
>
<Button
className="-ml-2"
onClick={handleClose}
size="icon"
variant="ghost"
>
<ArrowLeft />
</Button>
<Translator path="chat.settings.title" />
</div>
<CardContent
id="chat-settings-sidebar-content"
className="flex flex-col flex-grow gap-4 overflow-y-auto"
>
{settingsContent}
</CardContent>
</Card>
</aside>
</ResizablePanel>
</>
);
}
@@ -0,0 +1,68 @@
import { IInput } from '@/types';
import * as React from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { InputStateHandler } from './InputStateHandler';
interface CheckboxInputProps extends IInput {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
setField?: (field: string, value: boolean, shouldValidate?: boolean) => void;
}
const CheckboxInput = ({
id,
hasError,
description,
label,
tooltip,
checked,
disabled,
onChange,
setField
}: CheckboxInputProps): JSX.Element => {
return (
<InputStateHandler
id={id}
hasError={hasError}
description={description}
tooltip={tooltip}
>
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(checked) => {
onChange(!!checked);
setField?.(id, !!checked);
}}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</InputStateHandler>
);
};
export { CheckboxInput };
export type { CheckboxInputProps };
@@ -0,0 +1,355 @@
import { getDateFnsLocale } from '@/i18n/dateLocale';
import { cn } from '@/lib/utils';
import { IInput } from '@/types';
import { format } from 'date-fns';
import { Calendar as CalendarIcon, ChevronDownIcon } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { useTranslation } from 'components/i18n/Translator';
import { InputStateHandler } from './InputStateHandler';
// ============================================================================
// Utility Functions
// ============================================================================
const parseDate = (dateStr: string | undefined | null): Date | undefined => {
if (!dateStr) return undefined;
try {
const date = new Date(dateStr);
// Check if date is valid (Invalid Date has NaN time)
if (isNaN(date.getTime())) {
console.warn(`Invalid date string provided: "${dateStr}"`);
return undefined;
}
return date;
} catch {
return undefined;
}
};
const formatDateValue = (date: Date | undefined): string | undefined => {
if (!date) return undefined;
return date.toISOString();
};
const formatRangeValue = (
range: DateRange | undefined
): [string, string] | undefined => {
if (!range?.from) return undefined;
return [
formatDateValue(range.from)!,
formatDateValue(range.to || range.from)!
];
};
const getDisabledMatcher = (
disabled: boolean | undefined,
minDate: Date | undefined,
maxDate: Date | undefined
) => {
if (disabled) return true;
const matchers = [];
if (minDate) matchers.push({ before: minDate });
if (maxDate) matchers.push({ after: maxDate });
return matchers.length > 0 ? matchers : undefined;
};
// ============================================================================
// Base Component
// ============================================================================
interface DatePickerBaseProps extends IInput {
isEmpty: boolean;
buttonText: ReactNode;
calendarContent: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DatePickerBase = ({
id,
label,
description,
tooltip,
hasError,
disabled,
isEmpty,
buttonText,
calendarContent,
open,
onOpenChange,
className
}: DatePickerBaseProps): JSX.Element => {
return (
<InputStateHandler
id={id}
label={label}
description={description}
tooltip={tooltip}
hasError={hasError}
>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<div className={disabled ? 'cursor-not-allowed' : undefined}>
<Button
variant="outline"
disabled={disabled}
data-empty={isEmpty}
className={cn(
'w-full justify-between text-left font-normal data-[empty=true]:text-muted-foreground px-3 py-2',
className
)}
>
<div className="flex gap-3">
<CalendarIcon className="!size-5" />
{buttonText}
</div>
<ChevronDownIcon className="!size-5" />
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{calendarContent}
</PopoverContent>
</Popover>
</InputStateHandler>
);
};
// ============================================================================
// Shared Props
// ============================================================================
interface DatePickerSharedProps {
min_date?: string | null;
max_date?: string | null;
format?: string | null;
placeholder?: string | null;
setField?: (field: string, value: any, shouldValidate?: boolean) => void;
}
// ============================================================================
// Single Date Picker
// ============================================================================
export interface DatePickerSingleProps extends IInput, DatePickerSharedProps {
value?: string;
}
const DatePickerSingle = ({
id,
value,
min_date,
max_date,
format: dateFormat,
placeholder,
setField,
...baseProps
}: DatePickerSingleProps): JSX.Element => {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const date = parseDate(value);
const minDate = parseDate(min_date);
const maxDate = parseDate(max_date);
const dateFnsLocale = getDateFnsLocale(i18n.language);
const defaultPlaceholder =
placeholder ?? t('components.DatePickerInput.placeholder.single');
const handleDateSelect = (newDate: Date | undefined) => {
const formattedDate = formatDateValue(newDate);
setField?.(id, formattedDate);
setOpen(false);
};
const handleOpenChange = (isOpen: boolean) => {
if (baseProps.disabled && isOpen) return;
setOpen(isOpen);
};
const buttonText = date ? (
format(date, dateFormat || 'PPP', { locale: dateFnsLocale })
) : (
<span>{defaultPlaceholder}</span>
);
const calendarContent = (
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
disabled={getDisabledMatcher(baseProps.disabled, minDate, maxDate)}
locale={dateFnsLocale}
showOutsideDays={false}
autoFocus
/>
);
return (
<DatePickerBase
{...baseProps}
id={id}
isEmpty={!date}
buttonText={buttonText}
calendarContent={calendarContent}
open={open}
onOpenChange={handleOpenChange}
/>
);
};
// ============================================================================
// Range Date Picker
// ============================================================================
export interface DatePickerRangeProps extends IInput, DatePickerSharedProps {
value?: [string, string];
}
const DatePickerRange = ({
id,
value,
min_date,
max_date,
format: dateFormatInput,
placeholder,
setField,
...baseProps
}: DatePickerRangeProps): JSX.Element => {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const dateRange: DateRange | undefined =
value && Array.isArray(value)
? {
from: parseDate(value[0]),
to: parseDate(value[1])
}
: undefined;
// Temporary range state for selections before confirmation
const [tempRange, setTempRange] = useState<DateRange | undefined>(dateRange);
const minDate = parseDate(min_date);
const maxDate = parseDate(max_date);
const dateFnsLocale = getDateFnsLocale(i18n.language);
const dateFormat = dateFormatInput || 'PPP';
const defaultPlaceholder =
placeholder ?? t('components.DatePickerInput.placeholder.range');
// Update temp range when selecting dates (don't commit yet)
const handleRangeDateSelect = (newRange: DateRange | undefined) => {
setTempRange(newRange);
};
// Confirm button: commit the temp range and close popover
const handleConfirm = () => {
const formattedRange = formatRangeValue(tempRange);
setField?.(id, formattedRange);
setOpen(false);
};
// Reset button: clear the temp range
const handleReset = () => {
setTempRange(undefined);
};
// Update temp range when popover opens to sync with current value
const handleOpenChange = (isOpen: boolean) => {
if (baseProps.disabled) return;
setOpen(isOpen);
setTempRange(dateRange);
};
const buttonText = tempRange?.from ? (
tempRange.to ? (
<>
{format(tempRange.from, dateFormat, { locale: dateFnsLocale })} -{' '}
{format(tempRange.to, dateFormat, { locale: dateFnsLocale })}
</>
) : (
format(tempRange.from, dateFormat, { locale: dateFnsLocale })
)
) : (
<span>{defaultPlaceholder}</span>
);
const calendarContent = (
<div className="flex flex-col">
<Calendar
mode="range"
defaultMonth={tempRange?.from || dateRange?.from}
selected={tempRange}
onSelect={handleRangeDateSelect}
numberOfMonths={2}
disabled={getDisabledMatcher(baseProps.disabled, minDate, maxDate)}
locale={dateFnsLocale}
autoFocus
showOutsideDays={false}
/>
<div className="flex items-center justify-end gap-2 border-t p-3">
<Button variant="outline" onClick={handleReset} size="sm">
{t('common.actions.reset')}
</Button>
<Button onClick={handleConfirm} size="sm">
{t('common.actions.confirm')}
</Button>
</div>
</div>
);
return (
<DatePickerBase
{...baseProps}
id={id}
isEmpty={!dateRange?.from}
buttonText={buttonText}
calendarContent={calendarContent}
open={open}
onOpenChange={handleOpenChange}
/>
);
};
// ============================================================================
// Main Component (Router)
// ============================================================================
export interface DatePickerInputProps extends IInput, DatePickerSharedProps {
mode: 'single' | 'range';
value?: string | [string, string];
}
const DatePickerInput = (props: DatePickerInputProps): JSX.Element => {
if (props.mode === 'single') {
return (
<DatePickerSingle
{...props}
value={typeof props.value === 'string' ? props.value : undefined}
/>
);
}
return (
<DatePickerRange
{...props}
value={Array.isArray(props.value) ? props.value : undefined}
/>
);
};
export { DatePickerInput, DatePickerRange, DatePickerSingle };
@@ -0,0 +1,72 @@
import { IInput } from 'types/Input';
import { CheckboxInput, CheckboxInputProps } from './CheckboxInput';
import { DatePickerInput, DatePickerInputProps } from './DatePickerInput';
import { MultiSelectInput, MultiSelectInputProps } from './MultiSelectInput';
import { RadioButtonGroup, RadioButtonGroupProps } from './RadioButtonGroup';
import { SelectInput, SelectInputProps } from './SelectInput';
import { SliderInput, SliderInputProps } from './SliderInput';
import { SwitchInput, SwitchInputProps } from './SwitchInput';
import { TagsInput, TagsInputProps } from './TagsInput';
import { TextInput, TextInputProps } from './TextInput';
type TFormInputValue = string | number | boolean | string[] | undefined;
interface IFormInput<T, V extends TFormInputValue> extends IInput {
type: T;
value?: V;
initial?: V;
setField?(field: string, value: V, shouldValidate?: boolean): void;
}
type TFormInput =
| (Omit<SwitchInputProps, 'checked'> & IFormInput<'switch', boolean>)
| (Omit<SliderInputProps, 'value'> & IFormInput<'slider', number>)
| (Omit<TagsInputProps, 'value'> & IFormInput<'tags', string[]>)
| (Omit<SelectInputProps, 'value'> & IFormInput<'select', string>)
| (Omit<TextInputProps, 'value'> & IFormInput<'textinput', string>)
| (Omit<TextInputProps, 'value'> & IFormInput<'numberinput', number>)
| (Omit<MultiSelectInputProps, 'value'> & IFormInput<'multiselect', string[]>)
| (Omit<CheckboxInputProps, 'checked'> & IFormInput<'checkbox', boolean>)
| (Omit<RadioButtonGroupProps, 'value'> & IFormInput<'radio', string>)
| (DatePickerInputProps &
IFormInput<'datepicker', string | [string, string]>);
const FormInput = ({ element }: { element: TFormInput }): JSX.Element => {
switch (element?.type) {
case 'select':
return <SelectInput {...element} value={element.value ?? ''} />;
case 'slider':
return <SliderInput {...element} value={element.value ?? 0} />;
case 'tags':
return <TagsInput {...element} value={element.value ?? []} />;
case 'switch':
return <SwitchInput {...element} checked={!!element.value} />;
case 'textinput':
return <TextInput {...element} value={element.value ?? ''} />;
case 'numberinput':
return (
<TextInput
{...element}
type="number"
value={element.value?.toString() ?? '0'}
/>
);
case 'multiselect':
return <MultiSelectInput {...element} value={element.value ?? []} />;
case 'checkbox':
return <CheckboxInput {...element} checked={!!element.value} />;
case 'radio':
return <RadioButtonGroup {...element} value={element.value ?? ''} />;
case 'datepicker':
return <DatePickerInput {...element} value={element.value} />;
default:
// If the element type is not recognized, we indicate an unimplemented type.
// This code path should not normally occur and serves as a fallback.
element satisfies never;
return <></>;
}
};
export { FormInput };
export type { IFormInput, TFormInput, TFormInputValue };
@@ -0,0 +1,50 @@
import { InfoIcon } from 'lucide-react';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { NotificationCount, NotificationCountProps } from './NotificationCount';
interface InputLabelProps {
id?: string;
label: string | number;
tooltip?: string;
notificationsProps?: NotificationCountProps;
}
const InputLabel = ({
id,
label,
tooltip,
notificationsProps
}: InputLabelProps): JSX.Element => {
return (
<div className="flex justify-between w-full">
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-xs font-semibold text-gray-500">
{label}
</Label>
{tooltip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-3 w-3 text-gray-600" />
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{notificationsProps && <NotificationCount {...notificationsProps} />}
</div>
);
};
export { InputLabel };
@@ -0,0 +1,79 @@
import { cn } from '@/lib/utils';
import { Info } from 'lucide-react';
import React from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { Badge } from '../ui/badge';
interface NotificationsProps {
count?: number;
showBadge?: boolean;
}
interface InputProps {
description?: string;
hasError?: boolean;
id: string;
label?: string;
notificationsProps?: NotificationsProps;
tooltip?: string;
className?: string;
}
interface InputStateHandlerProps extends InputProps {
children: React.ReactNode;
}
const InputStateHandler = ({
children,
description,
id,
label,
notificationsProps,
tooltip,
className
}: InputStateHandlerProps): JSX.Element => {
return (
<div className={cn('space-y-2', className)}>
{label && (
<label
htmlFor={id}
className="flex items-center gap-2 text-sm font-medium"
>
{label}
{tooltip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<Info className="text-muted-foreground !size-4" />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{notificationsProps?.showBadge &&
typeof notificationsProps.count === 'number' ? (
<Badge variant="outline" className="ml-auto">
{notificationsProps.count}
</Badge>
) : null}
</label>
)}
<div className="flex flex-col gap-2">
{children}
{description && (
<div className="text-sm text-muted-foreground">{description}</div>
)}
</div>
</div>
);
};
export { InputStateHandler };
export type { InputStateHandlerProps, InputProps, NotificationsProps };
@@ -0,0 +1,180 @@
import { cn } from '@/lib/utils';
import { IInput } from '@/types';
import { Command as CommandPrimitive } from 'cmdk';
import { X } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandGroup,
CommandItem,
CommandList
} from '@/components/ui/command';
import { InputStateHandler } from './InputStateHandler';
interface SelectItemType {
label: string;
icon?: React.ReactNode;
notificationCount?: number;
value: string | number;
}
interface MultiSelectInputProps extends IInput {
items?: SelectItemType[];
value?: (string | number)[];
onChange: (value: (string | number)[]) => void;
setField?: (
field: string,
value: (string | number)[],
shouldValidate?: boolean
) => void;
placeholder?: string;
}
const MultiSelectInput = ({
id,
hasError,
description,
label,
tooltip,
disabled = false,
items = [],
value = [],
onChange,
setField,
placeholder
}: MultiSelectInputProps) => {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const handleSelect = (selectedValue: string | number) => {
const newValue = value.includes(selectedValue)
? value.filter((v) => v !== selectedValue)
: [...value, selectedValue];
onChange(newValue);
setField?.(id, newValue);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && value.length > 0) {
const newValue = [...value];
newValue.pop();
onChange(newValue);
setField?.(id, newValue);
}
}
if (e.key === 'Escape') {
input.blur();
}
}
};
const selectables = items.filter((item) => !value.includes(item.value));
return (
<InputStateHandler
id={id}
hasError={hasError}
description={description}
label={label}
tooltip={tooltip}
>
<Command
onKeyDown={handleKeyDown}
className="overflow-visible bg-transparent"
>
<div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className={cn('flex flex-wrap gap-1')}>
{value.map((v) => {
const item = items.find((item) => item.value === v);
return (
<Badge
key={v}
variant="secondary"
className={disabled ? 'cursor-not-allowed opacity-50' : ''}
>
{item?.label}
<button
className={cn(
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
disabled ? 'cursor-not-allowed' : ''
)}
disabled={disabled}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSelect(v);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleSelect(v)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={
value.length > 0
? ''
: placeholder ||
t('components.MultiSelectInput.placeholder', 'Select...')
}
className={cn(
'ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
disabled ? 'cursor-not-allowed' : ''
)}
disabled={disabled}
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ? (
<div className="absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandList>
<CommandGroup>
{selectables.map((item) => (
<CommandItem
key={item.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
handleSelect(item.value);
setInputValue('');
}}
className={'cursor-pointer'}
>
{item.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
) : null}
</div>
</Command>
</InputStateHandler>
);
};
export { MultiSelectInput };
export type { MultiSelectInputProps, SelectItemType };
@@ -0,0 +1,59 @@
import React from 'react';
import { Input } from '@/components/ui/input';
export interface NotificationCountProps {
count: number;
inputProps?: {
id: string;
max?: number;
min?: number;
step?: number;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
}
const NotificationCount = ({ count, inputProps }: NotificationCountProps) => {
if (!count) return null;
const renderBox = () => (
<div className="flex items-center rounded-md bg-muted px-2 py-1">
<span className="text-xs font-semibold text-muted-foreground">
{count}
</span>
</div>
);
const renderInput = () => {
const getInputWidth = (hasArrow?: boolean) => {
const countString = count.toString();
let contentWidth = countString.length * 8 + (hasArrow ? 22 : 0);
if (countString.includes('.') || countString.includes(',')) {
contentWidth -= 6;
}
return contentWidth;
};
return inputProps ? (
<Input
id={inputProps.id}
type="number"
max={inputProps.max}
min={inputProps.min}
step={inputProps.step || 1}
value={count}
onChange={inputProps.onChange}
className="rounded-md bg-muted text-xs font-semibold text-muted-foreground"
style={{
width: `${getInputWidth()}px`,
padding: '0.5rem 1rem',
border: 'none'
}}
/>
) : null;
};
return !inputProps ? renderBox() : renderInput();
};
export { NotificationCount };
@@ -0,0 +1,65 @@
import { IInput } from '@/types';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { InputStateHandler } from './InputStateHandler';
interface RadioItemType {
label: string;
value: string;
}
interface RadioButtonGroupProps extends IInput {
items?: RadioItemType[];
value?: string;
onChange: (value: string) => void;
setField?: (field: string, value: string, shouldValidate?: boolean) => void;
}
const RadioButtonGroup = ({
id,
hasError,
description,
label,
tooltip,
items = [],
value,
disabled = false,
onChange,
setField
}: RadioButtonGroupProps): JSX.Element => {
return (
<InputStateHandler
id={id}
hasError={hasError}
description={description}
label={label}
tooltip={tooltip}
>
<RadioGroup
value={value}
disabled={disabled}
onValueChange={(v: string) => {
onChange(v);
setField?.(id, v);
}}
>
{items.map((item) => (
<div key={item.value} className="flex items-center space-x-2">
<RadioGroupItem
value={item.value}
id={item.value}
disabled={disabled}
className="peer"
/>
<Label htmlFor={item.value}>{item.label}</Label>
</div>
))}
</RadioGroup>
</InputStateHandler>
);
};
export { RadioButtonGroup };
export type { RadioButtonGroupProps };
@@ -0,0 +1,83 @@
import { IInput } from '@/types';
import * as React from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { InputStateHandler } from './InputStateHandler';
interface SelectItemType {
label: string;
icon?: React.ReactNode;
notificationCount?: number;
value: string | number;
}
interface SelectInputProps extends IInput {
items?: SelectItemType[];
value?: string | number;
onChange: (value: string) => void;
setField?: (field: string, value: string, shouldValidate?: boolean) => void;
placeholder?: string;
}
const SelectInput = ({
id,
hasError,
description,
label,
tooltip,
disabled = false,
items = [],
value,
onChange,
setField,
placeholder = 'Select',
className
}: SelectInputProps) => {
return (
<InputStateHandler
id={id}
hasError={hasError}
description={description}
label={label}
tooltip={tooltip}
>
<Select
disabled={disabled}
value={value?.toString()}
onValueChange={(v) => {
onChange(v);
setField?.(id, v);
}}
>
<SelectTrigger id={id} className={className}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value.toString()}>
<div className="flex items-center gap-2">
{item.icon}
<span>{item.label}</span>
{item.notificationCount && (
<span className="ml-auto bg-muted rounded-full px-2 py-0.5 text-xs">
{item.notificationCount}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</InputStateHandler>
);
};
export { SelectInput };
export type { SelectItemType, SelectInputProps };
@@ -0,0 +1,86 @@
import { cn } from '@/lib/utils';
import { Slider } from '@/components/ui/slider';
import { InputStateHandler } from './InputStateHandler';
interface IInput {
description?: string;
hasError?: boolean;
id: string;
label?: string;
tooltip?: string;
}
interface SliderInputProps extends IInput {
value?: number;
min?: number;
max?: number;
step?: number;
defaultValue?: number[];
disabled?: boolean;
onValueChange?: (value: number[]) => void;
setField?: (field: string, value: number, shouldValidate?: boolean) => void;
className?: string;
}
const SliderInput = ({
description,
hasError,
id,
label,
tooltip,
value,
min = 0,
max = 100,
step = 1,
defaultValue = [0],
disabled,
onValueChange,
setField,
className,
...props
}: SliderInputProps) => {
const handleValueChange = (newValue: number[]) => {
const parsedValue = newValue[0];
if (max && parsedValue > max) {
setField?.(id, max);
} else if (min && parsedValue < min) {
setField?.(id, min);
} else {
onValueChange?.(newValue);
setField?.(id, parsedValue);
}
};
return (
<InputStateHandler
description={description}
hasError={hasError}
id={id}
label={label}
tooltip={tooltip}
notificationsProps={{
showBadge: true,
count: value || 0
}}
>
<Slider
id={id}
name={id}
max={max}
min={min}
step={step}
disabled={disabled}
value={value !== undefined ? [value] : defaultValue}
onValueChange={handleValueChange}
className={cn('w-full', className)}
{...props}
/>
</InputStateHandler>
);
};
export { SliderInput };
export type { SliderInputProps };
@@ -0,0 +1,59 @@
import { cn } from '@/lib/utils';
import * as React from 'react';
import { Switch } from '@/components/ui/switch';
import { InputStateHandler } from './InputStateHandler';
interface InputStateProps {
description?: string;
hasError?: boolean;
id: string;
label?: string;
tooltip?: string;
children: React.ReactNode;
}
interface SwitchInputProps extends InputStateProps {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
setField?: (field: string, value: boolean, shouldValidate?: boolean) => void;
}
const SwitchInput = ({
checked,
description,
disabled,
hasError,
id,
label,
setField,
tooltip
}: SwitchInputProps): JSX.Element => {
return (
<InputStateHandler
description={description}
hasError={hasError}
id={id}
label={label}
tooltip={tooltip}
>
<Switch
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(checked) => {
setField?.(id, checked);
}}
className={cn(
'data-[state=checked]:bg-primary',
hasError && 'border-destructive'
)}
/>
</InputStateHandler>
);
};
export { SwitchInput };
export type { SwitchInputProps };
@@ -0,0 +1,86 @@
import { X } from 'lucide-react';
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { IInput } from 'types/Input';
import { InputStateHandler } from './InputStateHandler';
export type TagsInputProps = {
placeholder?: string;
value?: string[];
setField?(field: string, value: string[], shouldValidate?: boolean): void;
} & IInput &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'color'>;
export const TagsInput = ({
description,
disabled,
hasError,
id,
label,
tooltip,
value = [],
setField,
placeholder,
...rest
}: TagsInputProps): JSX.Element => {
const [inputValue, setInputValue] = React.useState('');
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
if (!value.includes(inputValue.trim())) {
const newTags = [...value, inputValue.trim()];
setField?.(id, newTags, false);
}
setInputValue('');
}
};
const removeTag = (tagToRemove: string) => {
const newTags = value.filter((tag) => tag !== tagToRemove);
setField?.(id, newTags, false);
};
return (
<InputStateHandler
description={description}
hasError={hasError}
id={id}
label={label}
tooltip={tooltip}
>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{value.map((tag) => (
<Badge
key={tag}
variant="secondary"
className={`flex items-center gap-1 ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
>
{tag}
<X
className={`h-3 w-3 ${disabled ? '' : 'cursor-pointer'}`}
onClick={() => !disabled && removeTag(tag)}
/>
</Badge>
))}
</div>
<Input
{...rest}
id={id}
name={id}
disabled={disabled}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`mt-1 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
</div>
</InputStateHandler>
);
};
@@ -0,0 +1,51 @@
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { IInput } from 'types/Input';
import { InputStateHandler } from './InputStateHandler';
interface TextInputProps
extends IInput, Omit<React.InputHTMLAttributes<any>, 'id' | 'size'> {
setField?: (field: string, value: string, shouldValidate?: boolean) => void;
value?: string;
placeholder?: string;
multiline?: boolean;
}
const TextInput = ({
description,
disabled,
hasError,
id,
label,
tooltip,
multiline,
className,
setField,
...rest
}: TextInputProps): JSX.Element => {
const InputComponent = multiline ? Textarea : Input;
return (
<InputStateHandler
description={description}
hasError={hasError}
id={id}
label={label}
tooltip={tooltip}
>
<InputComponent
disabled={disabled}
id={id}
name={id}
{...rest}
onChange={(e) => setField?.(id, e.target.value)}
className={`text-sm font-normal my-0.5 ${className ?? ''}`}
/>
</InputStateHandler>
);
};
export { TextInput };
export type { TextInputProps };
@@ -0,0 +1,178 @@
import cloneDeep from 'lodash/cloneDeep';
import mapValues from 'lodash/mapValues';
import { useCallback, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRecoilState, useSetRecoilState } from 'recoil';
import {
chatSettingsInputsState,
chatSettingsValueState,
useChatData,
useChatInteract
} from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Translator } from 'components/i18n';
import { chatSettingsOpenState } from 'state/project';
import { FormInput, TFormInputValue } from './FormInput';
import { useChatSettingsSnapshotAtOpen } from './useChatSettingsSnapshotAtOpen';
export default function ChatSettingsModal() {
const { chatSettingsValue, chatSettingsInputs } = useChatData();
const { updateChatSettings, editChatSettings } = useChatInteract();
const [chatSettingsOpen, setChatSettingsOpen] = useRecoilState(
chatSettingsOpenState
);
const { valuesAtOpen, inputsAtOpen } = useChatSettingsSnapshotAtOpen(
chatSettingsOpen,
chatSettingsValue,
chatSettingsInputs
);
const { handleSubmit, setValue, reset, watch, getValues } = useForm({
defaultValues: chatSettingsValue
});
const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);
const setChatSettingsInputs = useSetRecoilState(chatSettingsInputsState);
const restoreSnapshot = useCallback(() => {
setChatSettingsInputs(cloneDeep(inputsAtOpen));
setChatSettingsValue(cloneDeep(valuesAtOpen));
}, [inputsAtOpen, setChatSettingsInputs, setChatSettingsValue, valuesAtOpen]);
// Reset form when default values change
useEffect(() => {
reset(chatSettingsValue);
}, [chatSettingsValue, reset]);
const handleClose = (open: boolean) => {
if (!open) {
restoreSnapshot();
setChatSettingsOpen(false);
}
};
const handleConfirm = handleSubmit((data) => {
const processedValues = mapValues(data, (x: TFormInputValue) =>
x !== '' ? x : null
);
updateChatSettings(processedValues);
setChatSettingsValue(processedValues);
setChatSettingsOpen(false);
});
const handleReset = () => {
restoreSnapshot();
};
// Legacy setField compatibility layer
const handleChange = () => {};
const setFieldValue = (field: string, value: any) => {
setValue(field, value);
editChatSettings(getValues());
};
const values = watch();
const tabInputs = chatSettingsInputs.filter(
(input: any) => Array.isArray(input?.inputs) && input.inputs.length > 0
);
const regularInputs = chatSettingsInputs.filter(
(input: any) => !Array.isArray(input?.inputs) || input.inputs.length === 0
);
const hasTabs = tabInputs.length > 0;
const defaultTab = tabInputs[0]?.id;
return (
<Dialog open={chatSettingsOpen} onOpenChange={handleClose}>
<DialogContent
id="chat-settings"
className={`flex flex-col gap-6 p-6 ${
hasTabs ? 'min-w-[25vw] h-[85vh]' : 'min-w-[20vw] max-h-[85vh]'
}`}
>
<DialogHeader>
<DialogTitle>
<Translator path="chat.settings.title" />
</DialogTitle>
<DialogDescription className="sr-only">
<Translator path="chat.settings.customize" />
</DialogDescription>
</DialogHeader>
{hasTabs ? (
<Tabs
defaultValue={defaultTab}
className="flex flex-col flex-grow min-h-0"
>
<TabsList className="w-full flex justify-start">
{tabInputs.map((tab: any) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label ?? tab.id}
</TabsTrigger>
))}
</TabsList>
{tabInputs.map((tab: any) => (
<TabsContent
key={tab.id}
value={tab.id}
className="data-[state=active]:flex flex-col flex-grow overflow-y-auto gap-6 p-1 mt-4"
>
{tab.inputs?.map((input: any) => (
<FormInput
key={input.id}
element={{
...input,
value: values[input.id],
onChange: handleChange,
setField: setFieldValue
}}
/>
))}
</TabsContent>
))}
</Tabs>
) : (
<div className="flex flex-col flex-grow overflow-y-auto gap-6 p-1">
{regularInputs.map((input: any) => (
<FormInput
key={input.id}
element={{
...input,
value: values[input.id],
onChange: handleChange,
setField: setFieldValue
}}
/>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleReset}>
<Translator path="common.actions.reset" />
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => handleClose(false)}>
<Translator path="common.actions.cancel" />
</Button>
<Button onClick={handleConfirm} id="confirm" autoFocus>
<Translator path="common.actions.confirm" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,36 @@
import cloneDeep from 'lodash/cloneDeep';
import { useEffect, useRef, useState } from 'react';
export interface ChatSettingsSnapshotAtOpen {
valuesAtOpen: Record<string, unknown>;
inputsAtOpen: unknown[];
}
/** Snapshots values + input schema when `isOpen` becomes true (Reset / cancel). */
export function useChatSettingsSnapshotAtOpen(
isOpen: boolean,
chatSettingsValue: Record<string, unknown>,
chatSettingsInputs: unknown[]
): ChatSettingsSnapshotAtOpen {
const [snapshot, setSnapshot] = useState<ChatSettingsSnapshotAtOpen>({
valuesAtOpen: {},
inputsAtOpen: []
});
const wasOpenRef = useRef(false);
useEffect(() => {
if (isOpen) {
if (!wasOpenRef.current) {
setSnapshot({
valuesAtOpen: cloneDeep(chatSettingsValue),
inputsAtOpen: cloneDeep(chatSettingsInputs)
});
}
wasOpenRef.current = true;
} else {
wasOpenRef.current = false;
}
}, [isOpen, chatSettingsValue, chatSettingsInputs]);
return snapshot;
}
+93
View File
@@ -0,0 +1,93 @@
import { cn } from '@/lib/utils';
import hljs from 'highlight.js';
import { useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import 'highlight.js/styles/monokai-sublime.css';
import CopyButton from './CopyButton';
interface CodeSnippetProps {
language: string;
children: string;
}
const HighlightedCode = ({ language, children }: CodeSnippetProps) => {
const codeRef = useRef<HTMLElement>(null);
if (!hljs.getLanguage(language)) {
language = 'txt';
}
useEffect(() => {
if (codeRef.current) {
const highlighted =
codeRef.current.getAttribute('data-highlighted') === 'yes';
if (!highlighted) {
hljs.highlightElement(codeRef.current);
}
}
}, []);
return (
<pre className="m-0">
<code
ref={codeRef}
className={`language-${language} font-mono text-sm rounded-b-md block`}
>
{children}
</code>
</pre>
);
};
interface CodeProps {
children: React.ReactNode;
node?: {
children?: Array<{
properties?: {
className?: string[];
};
children?: Array<{
value?: string;
}>;
}>;
};
}
export default function CodeSnippet({ ...props }: CodeProps) {
const codeChildren = props.node?.children?.[0];
const className = codeChildren?.properties?.className?.[0];
const match = /language-(\w+)/.exec(className || '');
const code = codeChildren?.children?.[0]?.value;
const showSyntaxHighlighter = match && code;
const highlightedCode = showSyntaxHighlighter ? (
<HighlightedCode language={match[1]}>{code}</HighlightedCode>
) : null;
const nonHighlightedCode = showSyntaxHighlighter ? null : (
<div
className={cn('rounded-b-md overflow-x-auto bg-accent', code && 'p-2')}
>
<code className="whitespace-pre-wrap">{code}</code>
</div>
);
return (
<Card className="relative my-2">
<CardHeader className="flex flex-row items-center justify-between py-1 px-4">
<span className="text-sm text-muted-foreground">
{match?.[1] || 'Raw code'}
</span>
<CopyButton content={code} />
</CardHeader>
<CardContent className="p-0">
{highlightedCode}
{nonHighlightedCode}
</CardContent>
</Card>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface Props {
content: unknown;
className?: string;
contentRef?: React.RefObject<HTMLDivElement>;
}
const CopyButton = ({ content, className, contentRef }: Props) => {
const [copied, setCopied] = useState(false);
const { t } = useTranslation();
const copyToClipboard = async () => {
try {
const textToCopy =
typeof content === 'object'
? JSON.stringify(content, null, 2)
: String(content);
// Create clipboard items array
const clipboardItems: ClipboardItem[] = [];
// Always add text version
clipboardItems.push(
new ClipboardItem({
'text/plain': new Blob([textToCopy], { type: 'text/plain' })
})
);
// If contentRef is provided, also add HTML version
if (contentRef?.current) {
const htmlContent = contentRef.current.innerHTML;
clipboardItems.push(
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' })
})
);
}
// Try to write multiple formats to clipboard
if (navigator.clipboard.write && clipboardItems.length > 1) {
// Use the newer clipboard API that supports multiple formats
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([textToCopy], { type: 'text/plain' }),
...(contentRef?.current && {
'text/html': new Blob([contentRef.current.innerHTML], {
type: 'text/html'
})
})
})
]);
} else {
// Fallback to text-only for older browsers
await navigator.clipboard.writeText(textToCopy);
}
setCopied(true);
// Reset copied state after 2 seconds
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
toast.error('Failed to copy: ' + String(err));
}
};
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={copyToClipboard}
variant="ghost"
size="icon"
className={`text-muted-foreground ${className}`}
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{copied
? t('chat.messages.actions.copy.success')
: t('chat.messages.actions.copy.button')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default CopyButton;
+114
View File
@@ -0,0 +1,114 @@
import { cn } from '@/lib/utils';
import { ArrowLeft } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { sideViewState } from '@chainlit/react-client';
import { Card, CardContent } from '@/components/ui/card';
import { ResizableHandle, ResizablePanel } from '@/components/ui/resizable';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { useIsMobile } from '@/hooks/use-mobile';
import { Element } from './Elements';
import { Button } from './ui/button';
export default function ElementSideView() {
const [sideView, setSideView] = useRecoilState(sideViewState);
const isMobile = useIsMobile();
const [isVisible, setIsVisible] = useState(false);
const isCanvas = sideView?.title === 'canvas';
useEffect(() => {
if (sideView) {
// Delay setting visibility to trigger animation
requestAnimationFrame(() => {
setIsVisible(true);
});
} else {
setIsVisible(false);
}
}, [sideView]);
if (!sideView) return null;
if (isMobile) {
return (
<Sheet open onOpenChange={(open) => !open && setSideView(undefined)}>
<SheetContent
className={cn('md:hidden flex flex-col', isCanvas && 'p-0')}
>
{!isCanvas ? (
<SheetHeader>
<SheetTitle id="side-view-title">{sideView.title}</SheetTitle>
</SheetHeader>
) : null}
<div
id="side-view-content"
className={cn(
'overflow-auto flex-grow flex flex-col',
isCanvas ? 'p-0' : 'gap-4 mt-4'
)}
>
{sideView.elements.map((e) => (
<Element key={e.id} element={e} />
))}
</div>
</SheetContent>
</Sheet>
);
}
return (
<>
<ResizableHandle className="sm:hidden md:block bg-transparent" />
<ResizablePanel
minSize={isCanvas ? 30 : 10}
defaultSize={isCanvas ? 50 : 40}
className={`md:flex flex-col flex-grow sm:hidden transform transition-transform duration-300 ease-in-out ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
>
<aside className="relative flex-grow overflow-auto mr-4 mb-4">
<Card className="overflow-auto h-full relative flex flex-col">
<div
id="side-view-title"
className={cn(
'text-lg font-semibold text-foreground px-6 py-4 flex items-center',
isCanvas && 'absolute top-0 z-10 bg-transparent'
)}
>
<Button
className="-ml-2"
onClick={() => setSideView(undefined)}
size="icon"
variant={isCanvas ? 'default' : 'ghost'}
>
<ArrowLeft />
</Button>
{isCanvas ? null : sideView.title}
</div>
<CardContent
id="side-view-content"
className={cn(
'flex flex-col flex-grow',
isCanvas ? 'p-0' : 'gap-4'
)}
>
{sideView.elements.map((e) => (
<Element key={e.id} element={e} />
))}
</CardContent>
</Card>
</aside>
</ResizablePanel>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { ArrowLeft } from 'lucide-react';
import type { IMessageElement } from '@chainlit/react-client';
import { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';
import { Element } from './Elements';
import { Button } from './ui/button';
interface ElementViewProps {
element: IMessageElement;
onGoBack?: () => void;
}
const ElementView = ({ element, onGoBack }: ElementViewProps) => {
const layoutMaxWidth = useLayoutMaxWidth();
return (
<div
className="flex flex-col flex-grow p-4 mx-auto gap-4 w-full"
style={{
maxWidth: layoutMaxWidth
}}
id="element-view"
>
<div className="flex items-center gap-1 -ml-2">
{onGoBack ? (
<Button size="icon" variant="ghost" onClick={onGoBack}>
<ArrowLeft />
</Button>
) : null}
<div className="text-lg font-semibold leading-none tracking-tight">
{element.name}
</div>
</div>
<Element element={element} />
</div>
);
};
export { ElementView };
@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { IAudioElement } from '@chainlit/react-client';
const AudioElement = ({ element }: { element: IAudioElement }) => {
if (!element.url) {
return null;
}
return (
<div className={cn('space-y-2', `${element.display}-audio`)}>
<p className="text-sm leading-7 text-muted-foreground">{element.name}</p>
<audio controls src={element.url} autoPlay={element.autoPlay} />
</div>
);
};
export { AudioElement };
@@ -0,0 +1,79 @@
import * as LucideIcons from 'lucide-react';
import React from 'react';
import * as ReactHookForm from 'react-hook-form';
import * as Recoil from 'recoil';
import * as Sonner from 'sonner';
import * as Zod from 'zod';
import * as ChainlitReactClient from '@chainlit/react-client';
import * as Markdown from '@/components/Markdown';
import * as AccordionComponents from '@/components/ui/accordion';
import * as AspectRatioComponents from '@/components/ui/aspect-ratio';
import * as AvatarComponents from '@/components/ui/avatar';
import * as BadgeComponents from '@/components/ui/badge';
import * as ButtonComponents from '@/components/ui/button';
import * as CardComponents from '@/components/ui/card';
import * as CarouselComponents from '@/components/ui/carousel';
import * as CheckboxComponents from '@/components/ui/checkbox';
import * as CommandComponents from '@/components/ui/command';
import * as DialogComponents from '@/components/ui/dialog';
import * as DropdownMenuComponents from '@/components/ui/dropdown-menu';
import * as FormComponents from '@/components/ui/form';
import * as HoverCardComponents from '@/components/ui/hover-card';
import * as InputComponents from '@/components/ui/input';
import * as LabelComponents from '@/components/ui/label';
import * as PaginationComponents from '@/components/ui/pagination';
import * as PopoverComponents from '@/components/ui/popover';
import * as ProgressComponents from '@/components/ui/progress';
import * as ScrollAreaComponents from '@/components/ui/scroll-area';
import * as SelectComponents from '@/components/ui/select';
import * as SeparatorComponents from '@/components/ui/separator';
import * as SheetComponents from '@/components/ui/sheet';
import * as SkeletonComponents from '@/components/ui/skeleton';
import * as SwitchComponents from '@/components/ui/switch';
import * as TableComponents from '@/components/ui/table';
import * as TabsComponents from '@/components/ui/tabs';
import * as TextareaComponents from '@/components/ui/textarea';
import * as TooltipComponents from '@/components/ui/tooltip';
const Imports = {
react: React,
sonner: Sonner,
zod: Zod,
recoil: Recoil,
'@chainlit/react-client': ChainlitReactClient,
'@/components/markdown': Markdown,
'react-hook-form': ReactHookForm,
'lucide-react': LucideIcons,
'@/components/ui/tabs': TabsComponents,
'@/components/ui/accordion': AccordionComponents,
'@/components/ui/aspect-ratio': AspectRatioComponents,
'@/components/ui/avatar': AvatarComponents,
'@/components/ui/badge': BadgeComponents,
'@/components/ui/button': ButtonComponents,
'@/components/ui/card': CardComponents,
'@/components/ui/carousel': CarouselComponents,
'@/components/ui/checkbox': CheckboxComponents,
'@/components/ui/command': CommandComponents,
'@/components/ui/dialog': DialogComponents,
'@/components/ui/dropdown-menu': DropdownMenuComponents,
'@/components/ui/form': FormComponents,
'@/components/ui/hover-card': HoverCardComponents,
'@/components/ui/input': InputComponents,
'@/components/ui/label': LabelComponents,
'@/components/ui/pagination': PaginationComponents,
'@/components/ui/popover': PopoverComponents,
'@/components/ui/progress': ProgressComponents,
'@/components/ui/scroll-area': ScrollAreaComponents,
'@/components/ui/separator': SeparatorComponents,
'@/components/ui/select': SelectComponents,
'@/components/ui/sheet': SheetComponents,
'@/components/ui/skeleton': SkeletonComponents,
'@/components/ui/switch': SwitchComponents,
'@/components/ui/table': TableComponents,
'@/components/ui/textarea': TextareaComponents,
'@/components/ui/tooltip': TooltipComponents
};
export default Imports;
@@ -0,0 +1,64 @@
import { memo, useState } from 'react';
import { Runner } from 'react-runner';
import Alert from '@/components/Alert';
import Imports from './Imports';
const createMockAPIs = () => {
return {
updateElement: async (
nextProps: Record<string, any>
): Promise<{ success: boolean }> => {
console.log('updateElement called with:', nextProps);
return { success: true };
},
deleteElement: async (): Promise<{ success: boolean }> => {
console.log('deleteElement called');
return { success: true };
},
callAction: async (action: {
name: string;
payload: Record<string, unknown>;
}): Promise<{ success: boolean }> => {
console.log('callAction called with:', action);
return { success: true };
},
sendUserMessage: (message: string, command?: string): void => {
console.log('sendUserMessage called with:', message, command);
}
};
};
const Renderer = memo(function ({
sourceCode,
props
}: {
sourceCode: string;
props: Record<string, unknown>;
}) {
const [error, setError] = useState<string>();
if (error) return <Alert variant="error">{error}</Alert>;
if (!sourceCode) return null;
const mockedApis = createMockAPIs();
return (
<Runner
code={sourceCode}
scope={{
import: Imports,
props,
...mockedApis
}}
onRendered={(error) => setError(error?.message)}
/>
);
});
export { Renderer };
@@ -0,0 +1,133 @@
import { MessageContext } from 'contexts/MessageContext';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from 'react';
import { Runner } from 'react-runner';
import { useRecoilValue } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import {
ChainlitContext,
IAction,
ICustomElement,
IElement,
sessionIdState,
useAuth,
useChatInteract
} from '@chainlit/react-client';
import Alert from '@/components/Alert';
import Imports from './Imports';
import * as Renderer from './Renderer';
const CustomElement = memo(function ({ element }: { element: ICustomElement }) {
const apiClient = useContext(ChainlitContext);
const sessionId = useRecoilValue(sessionIdState);
const { sendMessage } = useChatInteract();
const { user } = useAuth();
const { askUser } = useContext(MessageContext);
const [sourceCode, setSourceCode] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
apiClient
.get(`/public/elements/${element.name}.jsx`)
.then(async (res) => setSourceCode(await res.text()))
.catch((err) => setError(String(err)));
}, [element.name, apiClient]);
const updateElement = useCallback(
(nextProps: Record<string, unknown>) => {
if (!sessionId) return;
const nextElement: IElement = { ...element, props: nextProps };
return apiClient.updateElement(nextElement, sessionId);
},
[element, sessionId, apiClient]
);
const deleteElement = useCallback(() => {
if (!sessionId) return;
return apiClient.deleteElement(element, sessionId);
}, [element, sessionId, apiClient]);
const callAction = useCallback(
(action: IAction) => {
if (!sessionId) return;
return apiClient.callAction(action, sessionId);
},
[sessionId, apiClient]
);
const sendUserMessage = useCallback(
(message: string, command?: string) => {
return sendMessage({
threadId: '',
id: uuidv4(),
name: user?.identifier || 'User',
type: 'user_message',
output: message,
createdAt: new Date().toISOString(),
metadata: { location: window.location.href },
command
});
},
[sendMessage, user]
);
const submitElement = useCallback(
(props: Record<string, unknown>) => {
if (
askUser?.spec.type === 'element' &&
askUser.spec.step_id === element.forId
) {
askUser.callback({ ...props, submitted: true });
}
},
[askUser, element.forId]
);
const cancelElement = useCallback(() => {
if (
askUser?.spec.type === 'element' &&
askUser.spec.step_id === element.forId
) {
askUser.callback({ submitted: false });
}
}, [askUser, element.forId]);
const props = useMemo(() => {
return JSON.parse(JSON.stringify(element.props));
}, [element.props]);
if (error) return <Alert variant="error">{error}</Alert>;
if (!sourceCode) return null;
return (
<div className={`${element.display}-custom flex flex-col flex-grow`}>
<Runner
code={sourceCode}
scope={{
import: { ...Imports, '@/components/renderer': Renderer },
props,
apiClient,
updateElement,
deleteElement,
callAction,
sendUserMessage,
submitElement,
cancelElement
}}
onRendered={(error) => setError(error?.message)}
/>
</div>
);
});
export default CustomElement;
@@ -0,0 +1,199 @@
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { IDataframeElement } from '@chainlit/react-client';
import Alert from '@/components/Alert';
import { Loader } from '@/components/Loader';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { useFetch } from 'hooks/useFetch';
interface DataframeData {
index: (string | number)[];
columns: string[];
data: (string | number)[][];
}
const _DataframeElement = ({ data }: { data: DataframeData }) => {
const { index, columns, data: rowData } = data;
const tableColumns: ColumnDef<Record<string, string | number>>[] = useMemo(
() =>
columns.map((col: string) => ({
accessorKey: col,
header: ({ column }) => {
const sort = column.getIsSorted();
return (
<div
className="flex items-center cursor-pointer"
onClick={() => column.toggleSorting()}
>
{col}
{sort === 'asc' && <ArrowUp className="ml-2 !size-3" />}
{sort === 'desc' && <ArrowDown className="ml-2 !size-3" />}
</div>
);
}
})),
[columns]
);
const tableRows = useMemo(
() =>
rowData.map((row, idx) => {
const rowObj: Record<string, string | number> = { id: index[idx] };
columns.forEach((col, colIdx) => {
rowObj[col] = row[colIdx];
});
return rowObj;
}),
[rowData, columns, index]
);
const table = useReactTable({
data: tableRows,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
initialState: {
pagination: { pageSize: 10 }
}
});
const renderPaginationItems = useCallback(() => {
return Array.from({ length: table.getPageCount() }, (_, i) => (
<PaginationItem key={i}>
<PaginationLink
onClick={() => table.setPageIndex(i)}
isActive={table.getState().pagination.pageIndex === i}
>
{i + 1}
</PaginationLink>
</PaginationItem>
));
}, [table.getPageCount(), table.getState().pagination.pageIndex]);
return (
<div className="flex flex-col gap-2 h-full overflow-y-auto dataframe">
<div className="rounded-md border overflow-y-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Pagination>
<PaginationContent className="ml-auto">
<PaginationItem>
<PaginationPrevious
onClick={() => table.previousPage()}
className={
!table.getCanPreviousPage()
? 'pointer-events-none opacity-50'
: 'cursor-pointer'
}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => table.nextPage()}
className={
!table.getCanNextPage()
? 'pointer-events-none opacity-50'
: 'cursor-pointer'
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
};
function DataframeElement({ element }: { element: IDataframeElement }) {
const { data, isLoading, error } = useFetch(element.url || null);
const jsonData = useMemo(() => {
if (data) return JSON.parse(data);
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full w-full bg-muted">
<Loader />
</div>
);
}
if (error) {
return <Alert variant="error">{error.message}</Alert>;
}
return <_DataframeElement data={jsonData} />;
}
export default DataframeElement;
@@ -0,0 +1,30 @@
import { MessageContext } from '@/contexts/MessageContext';
import { useContext } from 'react';
import type { IMessageElement } from '@chainlit/react-client';
interface ElementRefProps {
element: IMessageElement;
}
const ElementRef = ({ element }: ElementRefProps) => {
const { onElementRefClick } = useContext(MessageContext);
// For inline elements, return a styled span
if (element.display === 'inline') {
return <span className="font-bold">{element.name}</span>;
}
// For other elements, return a clickable link
return (
<a
href="#"
className="cursor-pointer uppercase -translate-y-px inline-flex items-center rounded-xl bg-muted px-1.5 text-[0.7rem] font-medium text-muted-foreground element-link hover:bg-primary hover:text-primary-foreground"
onClick={() => onElementRefClick?.(element)}
>
{element.name}
</a>
);
};
export { ElementRef };
+22
View File
@@ -0,0 +1,22 @@
import { type IFileElement } from '@chainlit/react-client';
import { Attachment } from '@/components/chat/MessageComposer/Attachment';
const FileElement = ({ element }: { element: IFileElement }) => {
if (!element.url) {
return null;
}
return (
<a
className={`${element.display}-file no-underline`}
download={element.name}
href={element.url}
target="_blank"
>
<Attachment name={element.name} mime={element.mime!} />
</a>
);
};
export { FileElement };
@@ -0,0 +1,66 @@
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
import { useState } from 'react';
import { IImageElement } from '@chainlit/react-client';
import {
Dialog,
DialogContent,
DialogOverlay,
DialogPortal
} from '@/components/ui/dialog';
const ImageElement = ({ element }: { element: IImageElement }) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
if (!element.url) return null;
const handleClick = () => {
setLightboxOpen(true);
};
return (
<>
<div className="rounded-sm bg-accent overflow-hidden">
<img
className={cn(
'mx-auto block max-w-full h-auto',
element.display === 'inline' && 'cursor-pointer',
`${element.display}-image`
)}
src={element.url}
alt={element.name}
loading="lazy"
onClick={handleClick}
/>
</div>
<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogPortal>
<DialogOverlay className="bg-black/80" />
<DialogContent className="border-none bg-transparent shadow-none max-w-none p-0 max-h-screen overflow-auto [&>button]:hidden">
<div className="relative w-full h-full flex items-center justify-center">
<button
onClick={() => setLightboxOpen(false)}
className="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white"
aria-label="Close lightbox"
>
<X className="h-6 w-6" />
</button>
<img
src={element.url}
alt={element.name}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</>
);
};
export { ImageElement };
@@ -0,0 +1,20 @@
import { Suspense, lazy } from 'react';
import { IDataframeElement } from '@chainlit/react-client';
import { Skeleton } from '@/components/ui/skeleton';
interface Props {
element: IDataframeElement;
}
const DataframeElement = lazy(() => import('@/components/Elements/Dataframe'));
const LazyDataframe = ({ element }: Props) => {
return (
<Suspense fallback={<Skeleton className="h-full rounded-md" />}>
<DataframeElement element={element} />
</Suspense>
);
};
export { LazyDataframe };
+309
View File
@@ -0,0 +1,309 @@
import {
ChevronLeft,
ChevronRight,
Download,
Maximize2,
Printer,
X,
ZoomIn,
ZoomOut
} from 'lucide-react';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import { useEffect, useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle
} from '@/components/ui/dialog';
import { type IPdfElement } from 'client-types/';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
interface PDFViewerProps {
url: string;
className?: string;
startPage?: number;
persistentToolbar?: boolean;
}
export function PDFViewer({
url,
className,
startPage = 1,
persistentToolbar = false
}: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>();
const [pageNumber, setPageNumber] = useState(Math.max(1, startPage || 1));
const [scale, setScale] = useState(1.0);
const [isHovered, setIsHovered] = useState(false);
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
setPageNumber(
startPage && startPage >= 1 && startPage <= numPages ? startPage : 1
);
}
const zoomIn = (e: React.MouseEvent) => {
e.stopPropagation();
setScale((prev) => Math.min(prev + 0.25, 3));
};
const zoomOut = (e: React.MouseEvent) => {
e.stopPropagation();
setScale((prev) => Math.max(prev - 0.25, 0.5));
};
const prevPage = (e: React.MouseEvent) => {
e.stopPropagation();
setPageNumber((prev) => Math.max(prev - 1, 1));
};
const nextPage = (e: React.MouseEvent) => {
e.stopPropagation();
setPageNumber((prev) => Math.min(prev + 1, numPages || 1));
};
const handlePrint = (e: React.MouseEvent) => {
e.stopPropagation();
window.open(url, '_blank', 'noopener,noreferrer');
};
return (
<div
className={`flex flex-col bg-muted/20 border border-border rounded-md min-h-[50vh] ${
className || ''
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={() => setIsHovered(true)}
>
{/* Sticky toolbar — lives in the normal flex flow so it never overlaps content */}
<div
className={`sticky top-0 z-10 shrink-0 flex flex-wrap items-center justify-between p-2 gap-2 bg-background/80 backdrop-blur-sm border-b border-border rounded-t-md transition-opacity duration-200 ${
persistentToolbar || isHovered ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={prevPage}
disabled={pageNumber <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground w-16 text-center shadow-sm">
{pageNumber} / {numPages || '?'}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={nextPage}
disabled={pageNumber >= (numPages || 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
disabled={scale <= 0.5}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground w-12 text-center shadow-sm">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
disabled={scale >= 3}
>
<ZoomIn className="h-4 w-4" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hidden md:flex"
onClick={handlePrint}
title="Print/Open PDF"
>
<Printer className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
title="Download PDF"
>
<a
href={url}
download
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4 w-4" />
</a>
</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto w-full bg-muted/10">
<div className="w-fit min-w-full flex justify-center p-2 md:p-4">
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
loading={
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading PDF...
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="shadow-md bg-white max-w-full"
/>
</Document>
</div>
</div>
</div>
);
}
interface Props {
element: IPdfElement;
}
const PDFElement = ({ element }: Props) => {
const [modalOpen, setModalOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [previewWidth, setPreviewWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setPreviewWidth(el.clientWidth - 16);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
if (!element.url) {
return null;
}
// A standalone page or sidebar display
if (element.display === 'side' || element.display === 'page') {
return (
<PDFViewer
url={element.url}
startPage={element.page}
className={`${element.display}-pdf w-full h-[80vh] flex-1`}
/>
);
}
// Inline display
return (
<>
<div
ref={containerRef}
role="button"
tabIndex={0}
aria-label="Open PDF in a larger view"
className="inline-pdf relative group cursor-pointer border border-border rounded-md overflow-hidden bg-muted/20 w-full max-w-xs h-auto min-h-[160px] flex items-center justify-center hover:border-primary/50 transition-all p-2"
onClick={() => setModalOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setModalOpen(true);
}
}}
>
<div className="absolute top-2 right-2 p-1.5 bg-background/80 backdrop-blur-sm rounded-md opacity-0 group-hover:opacity-100 transition-opacity z-10 shadow-sm">
<Maximize2 className="h-4 w-4 text-foreground" />
</div>
<div className="flex flex-col items-center justify-center pointer-events-none">
<Document
file={element.url}
loading={
<div className="text-xs text-muted-foreground">
Loading preview...
</div>
}
>
<Page
pageNumber={element.page || 1}
width={previewWidth > 0 ? previewWidth : undefined}
renderTextLayer={false}
renderAnnotationLayer={false}
className="shadow-sm border border-border rounded-sm bg-white"
/>
</Document>
<div className="text-center mt-2">
<span className="text-xs font-medium text-muted-foreground truncate w-full px-2 max-w-[200px] inline-block">
{element.name || 'PDF Document'}
</span>
</div>
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogPortal>
<DialogOverlay className="bg-black/80 z-50" />
<DialogContent className="border-none bg-transparent shadow-none max-w-none p-2 md:p-4 w-[100vw] h-[100vh] md:w-[95vw] md:h-[95vh] flex flex-col [&>button]:hidden z-[60]">
<DialogTitle className="sr-only">View PDF</DialogTitle>
<DialogDescription className="sr-only">
A modal with a larger view of the PDF.
</DialogDescription>
<div className="flex justify-end mb-2">
<DialogClose asChild>
<Button
variant="ghost"
size="icon"
className="bg-background/80 backdrop-blur-sm hover:bg-background"
aria-label="Close"
>
<X className="h-5 w-5" />
</Button>
</DialogClose>
</div>
<div className="flex-1 w-full bg-background rounded-lg overflow-hidden flex flex-col">
<PDFViewer
url={element.url}
startPage={element.page}
persistentToolbar
className="w-full h-full"
/>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</>
);
};
export { PDFElement };
@@ -0,0 +1,67 @@
import { Suspense, lazy, useMemo } from 'react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { Skeleton } from '@/components/ui/skeleton';
import { useFetch } from 'hooks/useFetch';
import { type IPlotlyElement } from 'client-types/';
const Plot = lazy(() => import('react-plotly.js'));
interface Props {
element: IPlotlyElement;
}
const _PlotlyElement = ({ element }: Props) => {
const { data, error, isLoading } = useFetch(element.url || null);
// deep-clone SWR data so Plotly.js mutations don't corrupt the cache.
// keyed on the data reference so clones stay stable between re-renders,
// preserving react-plotly.js's prevProps === this.props skip check.
const plotly = useMemo(() => {
if (!data) return null;
return {
data: structuredClone(data.data),
layout: structuredClone(data.layout),
frames: data.frames ? structuredClone(data.frames) : undefined,
config: data.config ? structuredClone(data.config) : undefined,
height: data.layout?.height || 400
};
}, [data]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>An error occurred</div>;
if (!plotly) return null;
return (
<Suspense fallback={<Skeleton className="h-full rounded-md" />}>
<div style={{ width: '100%', height: `${plotly.height}px` }}>
<Plot
className={`${element.display}-plotly`}
data={plotly.data}
layout={plotly.layout}
frames={plotly.frames}
config={plotly.config}
style={{
width: '100%',
height: '100%',
borderRadius: '1rem',
overflow: 'hidden'
}}
useResizeHandler={true}
/>
</div>
</Suspense>
);
};
const PlotlyElement = (props: Props) => {
return (
<ErrorBoundary prefix="Failed to load chart.">
<_PlotlyElement {...props} />
</ErrorBoundary>
);
};
export { PlotlyElement };
+51
View File
@@ -0,0 +1,51 @@
import { type ITextElement, useConfig } from '@chainlit/react-client';
import Alert from '@/components/Alert';
import { Markdown } from '@/components/Markdown';
import { Skeleton } from '@/components/ui/skeleton';
import { useFetch } from 'hooks/useFetch';
interface TextElementProps {
element: ITextElement;
}
const TextElement = ({ element }: TextElementProps) => {
const { data, error, isLoading } = useFetch(element.url || null);
const { config } = useConfig();
const allowHtml = config?.features?.unsafe_allow_html;
const latex = config?.features?.latex;
let content = '';
if (isLoading) {
return <Skeleton className="h-4 w-full" />;
}
if (error) {
return (
<Alert variant="error">An error occurred while loading the content</Alert>
);
}
if (data) {
content = data;
}
if (element.language) {
content = `\`\`\`${element.language}\n${content}\n\`\`\``;
}
return (
<Markdown
allowHtml={allowHtml}
latex={latex}
renderMarkdown={true}
className={`${element.display}-text`}
>
{content}
</Markdown>
);
};
export { TextElement };
@@ -0,0 +1,21 @@
import ReactPlayer from 'react-player';
import { type IVideoElement } from '@chainlit/react-client';
const VideoElement = ({ element }: { element: IVideoElement }) => {
if (!element.url) {
return null;
}
return (
<ReactPlayer
className={`${element.display}-video`}
width="100%"
controls
url={element.url}
config={element.playerConfig || {}}
/>
);
};
export { VideoElement };
@@ -0,0 +1,42 @@
import type { IMessageElement } from '@chainlit/react-client';
import { AudioElement } from './Audio';
import CustomElement from './CustomElement';
import { FileElement } from './File';
import { ImageElement } from './Image';
import { LazyDataframe } from './LazyDataframe';
import { PDFElement } from './PDF';
import { PlotlyElement } from './Plotly';
import { TextElement } from './Text';
import { VideoElement } from './Video';
interface ElementProps {
element?: IMessageElement;
}
const Element = ({ element }: ElementProps): JSX.Element | null => {
switch (element?.type) {
case 'file':
return <FileElement element={element} />;
case 'image':
return <ImageElement element={element} />;
case 'text':
return <TextElement element={element} />;
case 'pdf':
return <PDFElement element={element} />;
case 'audio':
return <AudioElement element={element} />;
case 'video':
return <VideoElement element={element} />;
case 'plotly':
return <PlotlyElement element={element} />;
case 'dataframe':
return <LazyDataframe element={element} />;
case 'custom':
return <CustomElement element={element} />;
default:
return null;
}
};
export { Element };
+46
View File
@@ -0,0 +1,46 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import Alert from './Alert';
interface Props {
prefix?: string;
children?: ReactNode;
}
interface State {
hasError: boolean;
error?: string;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: undefined
};
public static getDerivedStateFromError(err: Error): State {
// Update state so the next render will show the fallback UI.
return { hasError: true, error: err.message };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
const msg = this.props.prefix
? `${this.props.prefix}: ${this.state.error}`
: this.state.error;
return (
<div className="flex-grow">
<Alert variant="error">{msg}</Alert>
</div>
);
}
return this.props.children;
}
}
export { ErrorBoundary };
+42
View File
@@ -0,0 +1,42 @@
import * as LucideIcons from 'lucide-react';
interface Props {
name: string;
className?: string;
size?: number;
color?: string;
}
const Icon = ({ name, ...props }: Props) => {
// Convert the name to proper case
const formatIconName = (name: string): string => {
//aggressively lowercase the parts to clean up inputs like "ChEvRoN-rIgHt"
if (name.includes('-')) {
return name
.split('-')
.map(
(part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
)
.join('');
}
if (name === name.toUpperCase()) {
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
return name.charAt(0).toUpperCase() + name.slice(1);
};
// Try to get the icon component using the formatted name
const formattedName = formatIconName(name);
const IconComponent = LucideIcons[
formattedName as keyof typeof LucideIcons
] as any;
if (!IconComponent) {
console.warn(`Icon "${name}" not found in Lucide icons`);
return null;
}
return <IconComponent {...props} />;
};
export default Icon;
+62
View File
@@ -0,0 +1,62 @@
import { cn } from '@/lib/utils';
import { Slot } from '@radix-ui/react-slot';
import { Command, CornerDownLeft } from 'lucide-react';
import { ForwardedRef, forwardRef } from 'react';
import { usePlatform } from '@/hooks/usePlatform';
export type KbdProps = React.HTMLAttributes<HTMLElement> & {
asChild?: boolean;
};
const Kbd = forwardRef(
(
{ asChild, children, className, ...kbdProps }: KbdProps,
forwardedRef: ForwardedRef<HTMLElement>
) => {
const { isMac } = usePlatform();
const Comp = asChild ? Slot : 'kbd';
const formatChildren = (child: React.ReactNode): React.ReactNode => {
if (typeof child === 'string') {
const lowerChild = child.toLowerCase();
if (lowerChild === 'enter') {
return <CornerDownLeft className="!size-4" />;
}
if (lowerChild === 'cmd+enter' || lowerChild === 'ctrl+enter') {
const cmdKey = isMac ? <Command className="!size-4" /> : 'Ctrl';
return (
<>
{cmdKey}
<CornerDownLeft className="!size-4 ml-0.5" />
</>
);
}
return isMac
? child.replace(/cmd/i, '⌘')
: child.replace(/cmd/i, 'Ctrl');
}
return child;
};
const formattedChildren = Array.isArray(children)
? children.map(formatChildren)
: formatChildren(children);
return (
<Comp
{...kbdProps}
className={cn(
'inline-flex select-none items-center justify-center whitespace-nowrap rounded-[4px] bg-muted px-1 py-[1px] font-mono text-xs tracking-tight text-muted-foreground shadow',
className
)}
ref={forwardedRef}
>
{formattedChildren}
</Comp>
);
}
);
Kbd.displayName = 'Kbd';
export { Kbd };
@@ -0,0 +1,159 @@
import _ from 'lodash';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { ChainlitContext, IThread } from '@chainlit/react-client';
import { Loader } from '@/components/Loader';
import { Search } from '@/components/icons/Search';
import { Button } from '@/components/ui/button';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import { DialogTitle } from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { Translator } from 'components/i18n';
import { Kbd } from '../Kbd';
export default function SearchChats() {
const { t } = useTranslation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [threads, setThreads] = useState<IThread[]>([]);
const [loading, setLoading] = useState(false);
const apiClient = useContext(ChainlitContext);
// Debounced search function
const debouncedSearch = useMemo(
() =>
_.debounce(async (query: string) => {
setLoading(true);
try {
const { data } = await apiClient.listThreads(
{ first: 20, cursor: undefined },
{ search: query || undefined }
);
setThreads(data || []);
} catch (error) {
toast.error('Error fetching threads: ' + error);
} finally {
setLoading(false);
}
}, 300),
[apiClient]
);
// Group threads by month and year
const groupedThreads = useMemo(() => {
return _.groupBy(threads, (thread) => {
const date = new Date(thread.createdAt);
return `${date.toLocaleString('default', {
month: 'long'
})} ${date.getFullYear()}`;
});
}, [threads]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
useEffect(() => {
debouncedSearch(searchQuery);
return () => {
debouncedSearch.cancel();
};
}, [searchQuery, debouncedSearch]);
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
id="search-chats-button"
onClick={() => setOpen(!open)}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-muted-foreground"
>
<Search className="!size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col items-center">
<Translator path="threadHistory.sidebar.filters.search" />
<Kbd>Cmd+k</Kbd>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">
{t('threadHistory.sidebar.filters.search')}
</DialogTitle>
<CommandInput
placeholder={t('threadHistory.sidebar.filters.placeholder')}
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="h-[300px] overflow-y-auto">
{loading ? (
<CommandEmpty className="p-4 flex items-center justify-center">
<Loader />
</CommandEmpty>
) : Object.keys(groupedThreads).length === 0 ? (
<CommandEmpty>
<Translator path="threadHistory.sidebar.empty" />
</CommandEmpty>
) : (
Object.entries(groupedThreads).map(([monthYear, monthThreads]) => (
<CommandGroup
key={`${searchQuery}-${monthYear}`}
heading={monthYear}
>
{monthThreads.map((thread) => (
<CommandItem
className="cursor-pointer"
key={`${searchQuery}-${thread.id}`}
value={`${searchQuery}-${thread.id}`}
onSelect={() => {
setOpen(false);
navigate(`/thread/${thread.id}`);
}}
>
<div className="line-clamp-2">
{thread.name || 'Untitled Conversation'}
</div>
</CommandItem>
))}
</CommandGroup>
))
)}
</CommandList>
</CommandDialog>
</>
);
}
@@ -0,0 +1,175 @@
import { uniqBy } from 'lodash';
import { useContext, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import {
ChainlitContext,
threadHistoryState,
useChatMessages
} from '@chainlit/react-client';
import {
SidebarContent,
SidebarGroup,
SidebarMenu
} from '@/components/ui/sidebar';
import { ThreadList } from './ThreadList';
const BATCH_SIZE = 35;
let _scrollTop = 0;
export function ThreadHistory() {
const navigate = useNavigate();
const scrollRef = useRef<HTMLDivElement>(null);
const apiClient = useContext(ChainlitContext);
const { firstInteraction, messages, threadId } = useChatMessages();
const [threadHistory, setThreadHistory] = useRecoilState(threadHistoryState);
const [error, setError] = useState<string>();
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [shouldLoadMore, setShouldLoadMore] = useState(false);
const prevMessageCountRef = useRef(0);
// Restore scroll position
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = _scrollTop;
}
}, []);
// Handle first interaction
useEffect(() => {
const handleFirstInteraction = async () => {
if (!firstInteraction) return;
const isActualResume =
firstInteraction === 'resume' &&
messages[0]?.output.toLowerCase() !== 'resume';
if (isActualResume) return;
await fetchThreads(undefined, true);
const currentPage = new URL(window.location.href);
if (threadId && currentPage.pathname === '/') {
navigate(`/thread/${threadId}`);
}
};
handleFirstInteraction();
}, [firstInteraction]);
// Reorder thread to top when a new message is sent in the current thread
useEffect(() => {
const currentCount = messages.length;
const prevCount = prevMessageCountRef.current;
prevMessageCountRef.current = currentCount;
if (
threadId &&
currentCount > prevCount &&
prevCount > 0 &&
threadHistory?.threads
) {
const lastMessage = messages[currentCount - 1];
if (lastMessage?.type === 'user_message') {
setThreadHistory((prev) => {
if (!prev?.threads) return prev;
const threadIndex = prev.threads.findIndex((t) => t.id === threadId);
if (threadIndex <= 0) return prev; // Already at top or not found
const updatedThreads = [...prev.threads];
updatedThreads[threadIndex] = {
...updatedThreads[threadIndex],
createdAt: new Date().toISOString()
};
return { ...prev, threads: updatedThreads };
});
}
}
}, [messages.length, threadId]);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollHeight, clientHeight, scrollTop } = scrollRef.current;
const atBottom = scrollTop + clientHeight >= scrollHeight - 10;
_scrollTop = scrollTop;
setShouldLoadMore(atBottom);
};
const fetchThreads = async (
cursor?: string | number,
isLoadingMore = false
) => {
try {
setIsLoadingMore(!!cursor || isLoadingMore);
setIsFetching(!cursor && !isLoadingMore);
const { pageInfo, data } = await apiClient.listThreads(
{ first: BATCH_SIZE, cursor },
{}
);
setError(undefined);
// Prevent duplicate threads
const allThreads = uniqBy(
cursor ? threadHistory?.threads?.concat(data) : data,
'id'
);
if (allThreads) {
setThreadHistory((prev) => ({
...prev,
pageInfo,
threads: allThreads
}));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally {
setShouldLoadMore(false);
setIsLoadingMore(false);
setIsFetching(false);
}
};
// Initial fetch
useEffect(() => {
if (!isFetching && !threadHistory?.threads && !error) {
fetchThreads();
}
}, [isFetching, threadHistory, error]);
// Handle infinite scroll
useEffect(() => {
if (threadHistory?.pageInfo) {
const { hasNextPage, endCursor } = threadHistory.pageInfo;
if (shouldLoadMore && !isLoadingMore && hasNextPage && endCursor) {
fetchThreads(endCursor);
}
}
}, [shouldLoadMore, isLoadingMore, threadHistory]);
return (
<SidebarContent onScroll={handleScroll} ref={scrollRef}>
<SidebarGroup>
<SidebarMenu>
{threadHistory ? (
<div id="thread-history" className="flex-grow">
<ThreadList
threadHistory={threadHistory}
error={error}
isFetching={isFetching}
isLoadingMore={isLoadingMore}
/>
</div>
) : null}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
);
}
@@ -0,0 +1,462 @@
import { cn } from '@/lib/utils';
import { size } from 'lodash';
import { Share2 } from 'lucide-react';
import { useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { toast } from 'sonner';
import {
ChainlitContext,
ClientError,
ThreadHistory, // sessionIdState,
threadHistoryState,
useChatInteract,
useChatMessages,
useChatSession,
useConfig
} from '@chainlit/react-client';
import Alert from '@/components/Alert';
import { Loader } from '@/components/Loader';
import ShareDialog from '@/components/share/ShareDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { Translator } from '../i18n';
import ThreadOptions from './ThreadOptions';
interface ThreadListProps {
threadHistory?: ThreadHistory;
error?: string;
isFetching: boolean;
isLoadingMore: boolean;
}
export function ThreadList({
threadHistory,
error,
isFetching,
isLoadingMore
}: ThreadListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { idToResume } = useChatSession();
const { clear } = useChatInteract();
const { threadId: currentThreadId } = useChatMessages();
const [threadIdToDelete, setThreadIdToDelete] = useState<string>();
const [threadIdToRename, setThreadIdToRename] = useState<string>();
const [threadNewName, setThreadNewName] = useState<string>();
const setThreadHistory = useSetRecoilState(threadHistoryState);
const apiClient = useContext(ChainlitContext);
const { config } = useConfig();
const dataPersistence = config?.dataPersistence;
const threadSharingReady = Boolean((config as any)?.threadSharing);
// sessionId not needed here
// Share thread state
const [threadIdToShare, setThreadIdToShare] = useState<string | undefined>();
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
// Share dialog state is centralized in ShareDialog; we only track which thread to share
const handleShareThread = (threadId: string) => {
if (!threadSharingReady) return;
setThreadIdToShare(threadId);
setIsShareDialogOpen(true);
// ShareDialog handles its own internal state; we just open it
};
type ParsedGroupLabel = {
month: string;
year: number;
raw: string;
};
const getMonthMap = (
locale = navigator.language
): { map: Record<string, number>; monthRegex: RegExp } => {
const map: Record<string, number> = {};
const monthNames: string[] = [];
for (let i = 0; i < 12; i++) {
const d = new Date(2020, i, 1);
const long = d
.toLocaleDateString(locale, { month: 'long' })
.toLocaleLowerCase(locale);
map[long] = i;
monthNames.push(long);
}
const monthRegex = new RegExp(`\\b(${monthNames.join('|')})\\b`, 'i');
return { map, monthRegex };
};
const { map: monthMap, monthRegex } = useMemo<{
map: Record<string, number>;
monthRegex: RegExp;
}>(() => getMonthMap(), []);
const parseGroupLabel = (label: string): ParsedGroupLabel | null => {
const locale = navigator.language;
const matchMonth = label.toLocaleLowerCase(locale).match(monthRegex);
if (!matchMonth) return null;
const month = matchMonth[0];
const matchYear = label.match(/\d{4}/);
if (!matchYear) return null;
const year = Number(matchYear[0]);
if (isNaN(year)) return null;
return { month, year, raw: label };
};
const sortGroupsByDate = (a: string, b: string): number => {
const aParsed = parseGroupLabel(a);
const bParsed = parseGroupLabel(b);
if (!aParsed || !bParsed) return a.localeCompare(b);
if (aParsed.year !== bParsed.year) {
return bParsed.year - aParsed.year;
}
const aMonth = monthMap[aParsed.month] ?? -1;
const bMonth = monthMap[bParsed.month] ?? -1;
return bMonth - aMonth;
};
const sortedTimeGroupKeys = useMemo(() => {
if (!threadHistory?.timeGroupedThreads) return [];
const fixedOrder = [
'Today',
'Yesterday',
'Previous 7 days',
'Previous 30 days'
];
return Object.keys(threadHistory.timeGroupedThreads).sort((a, b) => {
const aIndex = fixedOrder.indexOf(a);
const bIndex = fixedOrder.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return sortGroupsByDate(a, b);
});
}, [threadHistory?.timeGroupedThreads]);
if (isFetching || (!threadHistory?.timeGroupedThreads && isLoadingMore)) {
return (
<div className="flex items-center justify-center p-2">
<Loader />
</div>
);
}
if (error) {
return (
<Alert variant="error" className="m-3">
{error}
</Alert>
);
}
if (!threadHistory || size(threadHistory?.timeGroupedThreads) === 0) {
return (
<Alert variant="info" className="m-3">
<Translator path="threadHistory.sidebar.empty" />
</Alert>
);
}
const handleDeleteThread = async () => {
if (!threadIdToDelete) return;
if (
threadIdToDelete === idToResume ||
threadIdToDelete === currentThreadId
) {
clear();
await new Promise((resolve) => setTimeout(resolve, 300));
}
toast.promise(apiClient.deleteThread(threadIdToDelete), {
loading: (
<Translator path="threadHistory.thread.actions.delete.inProgress" />
),
success: () => {
setThreadHistory((prev) => ({
...prev,
threads: prev?.threads?.filter((t) => t.id !== threadIdToDelete)
}));
navigate('/');
return (
<Translator path="threadHistory.thread.actions.delete.success" />
);
},
error: (err) => {
if (err instanceof ClientError) {
return <span>{err.message}</span>;
} else {
return <span></span>;
}
}
});
};
const handleRenameThread = () => {
if (!threadIdToRename || !threadNewName) return;
toast.promise(apiClient.renameThread(threadIdToRename, threadNewName), {
loading: (
<Translator path="threadHistory.thread.actions.rename.inProgress" />
),
success: () => {
setThreadNewName(undefined);
setThreadIdToRename(undefined);
setThreadHistory((prev) => {
const next = {
...prev,
threads: prev?.threads ? [...prev.threads] : undefined
};
const threadIndex = next.threads?.findIndex(
(t) => t.id === threadIdToRename
);
if (typeof threadIndex === 'number' && next.threads) {
next.threads[threadIndex] = {
...next.threads[threadIndex],
name: threadNewName
};
}
return next;
});
return (
<div>
<Translator path="threadHistory.thread.actions.rename.success" />
</div>
);
},
error: (err) => {
if (err instanceof ClientError) {
return <span>{err.message}</span>;
} else {
return <span></span>;
}
}
});
};
const getTimeGroupLabel = (group: string) => {
const labels = {
Today: <Translator path="threadHistory.sidebar.timeframes.today" />,
Yesterday: (
<Translator path="threadHistory.sidebar.timeframes.yesterday" />
),
'Previous 7 days': (
<Translator path="threadHistory.sidebar.timeframes.previous7days" />
),
'Previous 30 days': (
<Translator path="threadHistory.sidebar.timeframes.previous30days" />
)
};
return labels[group as keyof typeof labels] || group;
};
return (
<>
<AlertDialog
open={!!threadIdToDelete}
onOpenChange={() => setThreadIdToDelete(undefined)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Translator path="threadHistory.thread.actions.delete.title" />
</AlertDialogTitle>
<AlertDialogDescription>
<Translator path="threadHistory.thread.actions.delete.description" />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row gap-2 sm:gap-0">
<AlertDialogCancel className="mt-0">
<Translator path="common.actions.cancel" />
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteThread}>
<Translator path="common.actions.confirm" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={!!threadIdToRename}
onOpenChange={() => setThreadIdToRename(undefined)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Translator path="threadHistory.thread.actions.rename.title" />
</DialogTitle>
<DialogDescription>
<Translator path="threadHistory.thread.actions.rename.description" />
</DialogDescription>
</DialogHeader>
<div className="my-6">
<Label htmlFor="name" className="text-right">
<Translator path="threadHistory.thread.actions.rename.form.name.label" />
</Label>
<Input
id="name"
required
value={threadNewName}
onChange={(e) => setThreadNewName(e.target.value)}
placeholder={t(
'threadHistory.thread.actions.rename.form.name.placeholder'
)}
autoFocus
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setThreadIdToRename(undefined)}
>
<Translator path="common.actions.cancel" />
</Button>
<Button type="button" onClick={handleRenameThread}>
<Translator path="common.actions.confirm" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ShareDialog
open={isShareDialogOpen}
onOpenChange={(open) => {
setIsShareDialogOpen(open);
if (!open) {
setThreadIdToShare(undefined);
}
}}
threadId={threadIdToShare || null}
/>
<TooltipProvider delayDuration={300}>
{sortedTimeGroupKeys.map((group) => {
const items = threadHistory!.timeGroupedThreads![group];
return (
<SidebarGroup key={group}>
<SidebarGroupLabel>{getTimeGroupLabel(group)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((thread) => {
const isResumed =
idToResume === thread.id &&
!threadHistory!.currentThreadId;
const isSelected =
isResumed || threadHistory!.currentThreadId === thread.id;
return (
<SidebarMenuItem
key={thread.id}
id={`thread-${thread.id}`}
>
<Tooltip>
<TooltipTrigger asChild>
<Link to={isResumed ? '' : `/thread/${thread.id}`}>
<SidebarMenuButton
isActive={isSelected}
className="relative h-9 group/thread"
>
<span className="flex min-w-0 items-center gap-2">
{thread.metadata?.is_shared ? (
<Share2
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
) : null}
<span className="truncate">
{thread.name || (
<Translator path="threadHistory.thread.untitled" />
)}
</span>
</span>
<div
className={cn(
'absolute w-10 bottom-0 top-0 right-0 bg-gradient-to-l from-[hsl(var(--sidebar-background))] to-transparent'
)}
/>
<ThreadOptions
onDelete={() =>
setThreadIdToDelete(thread.id)
}
onRename={() => {
setThreadIdToRename(thread.id);
setThreadNewName(thread.name);
}}
onShare={
dataPersistence && threadSharingReady
? () => handleShareThread(thread.id)
: undefined
}
className={cn(
'absolute z-20 bottom-0 top-0 right-0 bg-sidebar-accent hover:bg-sidebar-accent hover:text-primary flex opacity-0 group-hover/thread:opacity-100',
isSelected &&
'bg-sidebar-accent opacity-100'
)}
/>
</SidebarMenuButton>
</Link>
</TooltipTrigger>
<TooltipContent side="right" align="center">
<p>{thread.name}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</TooltipProvider>
{isLoadingMore ? (
<div className="flex items-center justify-center p-2">
<Loader />
</div>
) : null}
</>
);
}
@@ -0,0 +1,83 @@
import { cn } from '@/lib/utils';
import { Ellipsis, Share2, Trash2 } from 'lucide-react';
import { Pencil } from '@/components/icons/Pencil';
import { buttonVariants } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Translator } from '../i18n';
interface Props {
onDelete: () => void;
onRename: () => void;
onShare?: () => void;
className?: string;
}
export default function ThreadOptions({
onDelete,
onRename,
onShare,
className
}: Props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
id="thread-options"
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-muted-foreground',
className
)}
>
<Ellipsis />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-20" align="start" forceMount>
<DropdownMenuItem
id="rename-thread"
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Translator path="threadHistory.thread.menu.rename" />
<Pencil className="ml-auto" />
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
id="share-thread"
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Translator path="threadHistory.thread.menu.share" />
<Share2 className="ml-auto" />
</DropdownMenuItem>
)}
<DropdownMenuItem
id="delete-thread"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-red-500 focus:text-red-500"
>
<Translator path="threadHistory.thread.menu.delete" />
<Trash2 className="ml-auto" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom';
import SidebarTrigger from '@/components/header/SidebarTrigger';
import { Sidebar, SidebarHeader, SidebarRail } from '@/components/ui/sidebar';
import NewChatButton from '../header/NewChat';
import SearchChats from './Search';
import { ThreadHistory } from './ThreadHistory';
export default function LeftSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const navigate = useNavigate();
return (
<Sidebar {...props} className="border-none">
<SidebarHeader className="py-3">
<div className="flex items-center justify-between">
<SidebarTrigger />
<div className="flex items-center">
<SearchChats />
<NewChatButton navigate={navigate} />
</div>
</div>
</SidebarHeader>
<ThreadHistory />
<SidebarRail />
</Sidebar>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { cn } from '@/lib/utils';
import { LoaderIcon } from 'lucide-react';
interface LoaderProps {
className?: string;
}
const Loader = ({ className }: LoaderProps): JSX.Element => {
return (
<LoaderIcon
className={cn('h-4 w-4 animate-spin text-primary', className)}
/>
);
};
export { Loader };
+195
View File
@@ -0,0 +1,195 @@
import { cn } from '@/lib/utils';
import { Eye, EyeOff } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ClientError } from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import Translator, { useTranslation } from 'components/i18n/Translator';
import Alert from './Alert';
import { ProviderButton } from './ProviderButton';
interface Props {
error?: string;
providers: string[];
callbackUrl: string;
onPasswordSignIn?: (
email: string,
password: string,
callbackUrl: string
) => Promise<any>;
onOAuthSignIn?: (provider: string, callbackUrl: string) => Promise<any>;
}
interface FormValues {
email: string;
password: string;
}
export function LoginForm({
providers,
onPasswordSignIn,
onOAuthSignIn,
callbackUrl,
error
}: Props) {
const [loading, setLoading] = useState(false);
const [errorState, setErrorState] = useState(error);
const [showPassword, setShowPassword] = useState(false);
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { errors, touchedFields }
} = useForm<FormValues>({
defaultValues: {
email: '',
password: ''
}
});
useEffect(() => {
setErrorState(error);
}, [error]);
const onSubmit = async (data: FormValues) => {
if (!onPasswordSignIn) return;
setLoading(true);
try {
await onPasswordSignIn(data.email, data.password, callbackUrl);
} catch (err) {
if (err instanceof ClientError && err.detail) {
setErrorState(err.detail);
} else if (err instanceof Error) {
setErrorState(err.message);
}
} finally {
setLoading(false);
}
};
const oAuthReady = onOAuthSignIn && providers.length;
return (
<form
onSubmit={handleSubmit(onSubmit)}
className={cn('flex flex-col gap-6')}
>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">
<Translator path="auth.login.title" />
</h1>
</div>
{errorState && (
<Alert variant="error">
{t([
`auth.login.errors.${errorState.toLowerCase()}`,
`auth.login.errors.default`
])}
</Alert>
)}
<div className="grid gap-6">
{onPasswordSignIn && (
<>
<div className="grid gap-2">
<Label htmlFor="email">
<Translator path="auth.login.form.email.label" />
</Label>
<Input
id="email"
disabled={loading}
autoFocus
placeholder={t('auth.login.form.email.placeholder')}
{...register('email', {
required: t('auth.login.form.email.required')
})}
className={cn(
touchedFields.email && errors.email && 'border-destructive'
)}
/>
{touchedFields.email && errors.email && (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">
<Translator path="auth.login.form.password.label" />
</Label>
</div>
<div className="relative">
<Input
id="password"
disabled={loading}
type={showPassword ? 'text' : 'password'}
{...register('password', {
required: t('auth.login.form.password.required')
})}
className={cn(
touchedFields.password &&
errors.password &&
'border-destructive'
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{touchedFields.password && errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={loading}>
<Translator path="auth.login.form.actions.signin" />
</Button>
</>
)}
{onPasswordSignIn && oAuthReady ? (
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span className="relative z-10 bg-background px-2 text-muted-foreground">
<Translator path="auth.login.form.alternativeText.or" />
</span>
</div>
) : null}
{oAuthReady ? (
<div className="grid gap-2">
{providers.map((provider, index) => (
<ProviderButton
key={`provider-${index}`}
provider={provider}
onClick={() => onOAuthSignIn?.(provider, callbackUrl)}
/>
))}
</div>
) : null}
</div>
</form>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import { useContext } from 'react';
import { ChainlitContext, useConfig } from '@chainlit/react-client';
import { useTheme } from './ThemeProvider';
interface Props {
className?: string;
}
export const Logo = ({ className }: Props) => {
const { variant } = useTheme();
const { config } = useConfig();
const apiClient = useContext(ChainlitContext);
return (
<img
src={apiClient.getLogoEndpoint(variant, config?.ui?.logo_file_url)}
alt="logo"
className={cn('logo', className)}
/>
);
};
+334
View File
@@ -0,0 +1,334 @@
import { cn } from '@/lib/utils';
import { omit } from 'lodash';
import { useContext, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { PluggableList } from 'react-markdown/lib';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { visit } from 'unist-util-visit';
import { ChainlitContext, type IMessageElement } from '@chainlit/react-client';
import { AspectRatio } from '@/components/ui/aspect-ratio';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import BlinkingCursor from './BlinkingCursor';
import CodeSnippet from './CodeSnippet';
import { ElementRef } from './Elements/ElementRef';
import {
type AlertProps,
MarkdownAlert,
alertComponents,
normalizeAlertType
} from './MarkdownAlert';
interface Props {
allowHtml?: boolean;
latex?: boolean;
renderMarkdown?: boolean;
refElements?: IMessageElement[];
children: string;
className?: string;
}
const cursorPlugin = () => {
return (tree: any) => {
visit(tree, 'text', (node: any, index, parent) => {
const placeholderPattern = /\u200B/g;
const matches = [...(node.value?.matchAll(placeholderPattern) || [])];
if (matches.length > 0) {
const newNodes: any[] = [];
let lastIndex = 0;
matches.forEach((match) => {
const [fullMatch] = match;
const startIndex = match.index!;
const endIndex = startIndex + fullMatch.length;
if (startIndex > lastIndex) {
newNodes.push({
type: 'text',
value: node.value!.slice(lastIndex, startIndex)
});
}
newNodes.push({
type: 'blinkingCursor',
data: {
hName: 'blinkingCursor',
hProperties: { text: 'Blinking Cursor' }
}
});
lastIndex = endIndex;
});
if (lastIndex < node.value!.length) {
newNodes.push({
type: 'text',
value: node.value!.slice(lastIndex)
});
}
parent!.children.splice(index, 1, ...newNodes);
}
});
};
};
const Markdown = ({
allowHtml,
latex,
renderMarkdown,
refElements,
className,
children
}: Props) => {
const apiClient = useContext(ChainlitContext);
if (renderMarkdown === false) {
return (
<pre
className={cn('whitespace-pre-wrap break-words', className)}
style={{ fontFamily: 'inherit' }}
>
{children}
</pre>
);
}
const rehypePlugins = useMemo(() => {
let rehypePlugins: PluggableList = [];
if (allowHtml) {
rehypePlugins = [rehypeRaw as any, ...rehypePlugins];
}
if (latex) {
rehypePlugins = [rehypeKatex as any, ...rehypePlugins];
}
return rehypePlugins;
}, [allowHtml, latex]);
const remarkPlugins = useMemo(() => {
let remarkPlugins: PluggableList = [
cursorPlugin,
remarkGfm as any,
remarkDirective as any,
MarkdownAlert
];
if (latex) {
remarkPlugins = [...remarkPlugins, remarkMath as any];
}
return remarkPlugins;
}, [latex]);
return (
<ReactMarkdown
className={cn('prose lg:prose-xl', className)}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={{
...alertComponents, // add alert components
code(props) {
return (
<code
{...omit(props, ['node'])}
className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold"
/>
);
},
pre({ children, ...props }: any) {
return <CodeSnippet {...props} />;
},
a({ children, ...props }) {
const name = children as string;
const element = refElements?.find((e) => e.name === name);
if (element) {
return <ElementRef element={element} />;
} else {
return (
<a
{...props}
className="text-primary hover:underline"
target="_blank"
>
{children}
</a>
);
}
},
img: (image: any) => {
// Check if the image source is actually a video file
const src = image.src.startsWith('/public')
? apiClient.buildEndpoint(image.src)
: image.src;
const videoExtensions = [
'.mp4',
'.webm',
'.mov',
'.avi',
'.ogv',
'.m4v'
];
const isVideo = videoExtensions.some((ext) =>
src.toLowerCase().split(/[?#]/)[0].endsWith(ext)
);
if (isVideo) {
return (
<div className="sm:max-w-sm md:max-w-md">
<video
src={src}
controls
className="w-full h-auto rounded-md"
style={{ maxWidth: '100%' }}
>
Your browser does not support the video tag.
</video>
</div>
);
}
return (
<div className="sm:max-w-sm md:max-w-md">
<AspectRatio
ratio={16 / 9}
className="bg-muted rounded-md overflow-hidden"
>
<img
src={src}
alt={image.alt}
className="h-full w-full object-contain"
/>
</AspectRatio>
</div>
);
},
blockquote(props) {
return (
<blockquote
{...omit(props, ['node'])}
className="mt-6 border-l-2 pl-6 italic"
/>
);
},
em(props) {
return <span {...omit(props, ['node'])} className="italic" />;
},
strong(props) {
return <span {...omit(props, ['node'])} className="font-bold" />;
},
hr() {
return <Separator />;
},
ul(props) {
return (
<ul
{...omit(props, ['node'])}
className="my-3 ml-3 list-disc pl-2 [&>li]:mt-1"
/>
);
},
ol(props) {
return (
<ol
{...omit(props, ['node'])}
className="my-3 ml-3 list-decimal pl-2 [&>li]:mt-1"
/>
);
},
h1(props) {
return (
<h1
{...omit(props, ['node'])}
className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mt-8 first:mt-0"
/>
);
},
h2(props) {
return (
<h2
{...omit(props, ['node'])}
className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight mt-8 first:mt-0"
/>
);
},
h3(props) {
return (
<h3
{...omit(props, ['node'])}
className="scroll-m-20 text-2xl font-semibold tracking-tight mt-6 first:mt-0"
/>
);
},
h4(props) {
return (
<h4
{...omit(props, ['node'])}
className="scroll-m-20 text-xl font-semibold tracking-tight mt-6 first:mt-0"
/>
);
},
p(props) {
return (
<div
{...omit(props, ['node'])}
className="leading-7 [&:not(:first-child)]:mt-4 whitespace-pre-wrap break-words"
role="article"
/>
);
},
table({ children, ...props }) {
return (
<Card className="[&:not(:first-child)]:mt-2 [&:not(:last-child)]:mb-2">
<Table {...(props as any)}>{children}</Table>
</Card>
);
},
thead({ children, ...props }) {
return <TableHeader {...(props as any)}>{children}</TableHeader>;
},
tr({ children, ...props }) {
return <TableRow {...(props as any)}>{children}</TableRow>;
},
th({ children, ...props }) {
return <TableHead {...(props as any)}>{children}</TableHead>;
},
td({ children, ...props }) {
return <TableCell {...(props as any)}>{children}</TableCell>;
},
tbody({ children, ...props }) {
return <TableBody {...(props as any)}>{children}</TableBody>;
},
// @ts-expect-error custom plugin
blinkingCursor: () => <BlinkingCursor whitespace />,
alert: ({
type,
children,
...props
}: AlertProps & { type?: string }) => {
const alertType = normalizeAlertType(type || props.variant || 'info');
return alertComponents.Alert({ variant: alertType, children });
}
}}
>
{children}
</ReactMarkdown>
);
};
export { Markdown };
+341
View File
@@ -0,0 +1,341 @@
import { cn } from '@/lib/utils';
import {
AlertCircle,
AlertTriangle,
BellRing,
BookOpen,
Bug,
CheckCircle,
Clock,
Heart,
HelpCircle,
Info,
Lightbulb,
Rocket,
Shield
} from 'lucide-react';
// unist-util-visit is a utility for walking AST (Abstract Syntax Tree) nodes in markdown processing,
// used here to find and transform ::: alert syntax into Alert components
import { visit } from 'unist-util-visit';
import { useConfig } from '@chainlit/react-client';
import { useTranslation } from '@/components/i18n/Translator';
export interface AlertProps {
variant: AlertVariant;
children?: React.ReactNode;
}
// Alert type definition
export const AlertTypes = [
'info',
'note',
'tip',
'important',
'warning',
'caution',
'debug',
'example',
'success',
'help',
'idea',
'pending',
'security',
'beta',
'best-practice'
// 'your-new-type';
] as const;
export type AlertVariant = (typeof AlertTypes)[number];
// Styles and icon configuration
const variantStyles = {
// Basic alerts
info: {
container:
'bg-blue-50 border-l-4 border-l-blue-400 dark:bg-blue-950 dark:border-l-blue-500',
icon: 'text-blue-500 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-200',
Icon: Info
},
note: {
container:
'bg-gray-50 border-l-4 border-l-gray-400 dark:bg-gray-900 dark:border-l-gray-500',
icon: 'text-gray-500 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-200',
Icon: BellRing
},
tip: {
container:
'bg-green-50 border-l-4 border-l-green-400 dark:bg-green-950 dark:border-l-green-500',
icon: 'text-green-500 dark:text-green-400',
text: 'text-green-700 dark:text-green-200',
Icon: CheckCircle
},
important: {
container:
'bg-purple-50 border-l-4 border-l-purple-400 dark:bg-purple-950 dark:border-l-purple-500',
icon: 'text-purple-500 dark:text-purple-400',
text: 'text-purple-700 dark:text-purple-200',
Icon: AlertCircle
},
warning: {
container:
'bg-yellow-50 border-l-4 border-l-yellow-400 dark:bg-yellow-950 dark:border-l-yellow-500',
icon: 'text-yellow-500 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-200',
Icon: AlertTriangle
},
caution: {
container:
'bg-red-50 border-l-4 border-l-red-400 dark:bg-red-950 dark:border-l-red-500',
icon: 'text-red-500 dark:text-red-400',
text: 'text-red-700 dark:text-red-200',
Icon: AlertTriangle
},
// Development related
debug: {
container:
'bg-gray-50 border-l-4 border-l-gray-400 dark:bg-gray-900 dark:border-l-gray-500',
icon: 'text-gray-500 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-200',
Icon: Bug
},
example: {
container:
'bg-indigo-50 border-l-4 border-l-indigo-400 dark:bg-indigo-950 dark:border-l-indigo-500',
icon: 'text-indigo-500 dark:text-indigo-400',
text: 'text-indigo-700 dark:text-indigo-200',
Icon: BookOpen
},
// Functional alerts
success: {
container:
'bg-green-50 border-l-4 border-l-green-400 dark:bg-green-950 dark:border-l-green-500',
icon: 'text-green-500 dark:text-green-400',
text: 'text-green-700 dark:text-green-200',
Icon: CheckCircle
},
help: {
container:
'bg-blue-50 border-l-4 border-l-blue-400 dark:bg-blue-950 dark:border-l-blue-500',
icon: 'text-blue-500 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-200',
Icon: HelpCircle
},
idea: {
container:
'bg-yellow-50 border-l-4 border-l-yellow-400 dark:bg-yellow-950 dark:border-l-yellow-500',
icon: 'text-yellow-500 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-200',
Icon: Lightbulb
},
// Status alerts
pending: {
container:
'bg-orange-50 border-l-4 border-l-orange-400 dark:bg-orange-950 dark:border-l-orange-500',
icon: 'text-orange-500 dark:text-orange-400',
text: 'text-orange-700 dark:text-orange-200',
Icon: Clock
},
security: {
container:
'bg-slate-50 border-l-4 border-l-slate-400 dark:bg-slate-950 dark:border-l-slate-500',
icon: 'text-slate-500 dark:text-slate-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Shield
},
beta: {
container:
'bg-violet-50 border-l-4 border-l-violet-400 dark:bg-violet-950 dark:border-l-violet-500',
icon: 'text-violet-500 dark:text-violet-400',
text: 'text-violet-700 dark:text-violet-200',
Icon: Rocket
},
'best-practice': {
container:
'bg-teal-50 border-l-4 border-l-teal-400 dark:bg-teal-950 dark:border-l-teal-500',
icon: 'text-teal-500 dark:text-teal-400',
text: 'text-teal-700 dark:text-teal-200',
Icon: Heart
}
// we can add new types here later, but remember to update translation.json file under "alerts".
// 'your-new-type': {
// container: 'bg-teal-50 border-l-4 border-l-teal-400 dark:bg-teal-950 dark:border-l-teal-500',
// icon: 'text-teal-500 dark:text-teal-400',
// text: 'text-teal-700 dark:text-teal-200',
// Icon: Heart
// }
};
const modernVariantStyles = {
// Basic alerts
info: {
container:
'bg-blue-50/80 rounded-2xl border border-blue-200 dark:bg-slate-800/30 dark:border-slate-500/40',
icon: 'text-blue-500 dark:text-blue-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Info
},
note: {
container:
'bg-gray-50/80 rounded-2xl border border-gray-300 dark:bg-gray-800/30 dark:border-gray-500/40',
icon: 'text-gray-500 dark:text-gray-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: BellRing
},
tip: {
container:
'bg-green-50/80 rounded-2xl border border-green-200 dark:bg-green-800/30 dark:border-green-600/30',
icon: 'text-green-500 dark:text-green-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: CheckCircle
},
important: {
container:
'bg-purple-50/80 rounded-2xl border border-purple-200 dark:bg-purple-800/20 dark:border-purple-600/30',
icon: 'text-purple-500 dark:text-purple-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: AlertCircle
},
warning: {
container:
'bg-yellow-50/80 rounded-2xl border border-yellow-200 dark:bg-yellow-800/30 dark:border-yellow-600/30',
icon: 'text-yellow-500 dark:text-yellow-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: AlertTriangle
},
caution: {
container:
'bg-red-50/80 rounded-2xl border border-red-200 dark:bg-red-900/30 dark:border-red-600/30',
icon: 'text-red-500 dark:text-red-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: AlertTriangle
},
debug: {
container:
'bg-gray-50/80 rounded-2xl border border-gray-300 dark:bg-gray-800/30 dark:border-gray-500/40',
icon: 'text-gray-500 dark:text-gray-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Bug
},
example: {
container:
'bg-indigo-50/80 rounded-2xl border border-indigo-200 dark:bg-indigo-800/30 dark:border-indigo-600/30',
icon: 'text-indigo-500 dark:text-indigo-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: BookOpen
},
success: {
container:
'bg-green-50/80 rounded-2xl border border-green-200 dark:bg-green-800/30 dark:border-green-600/30',
icon: 'text-green-500 dark:text-green-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: CheckCircle
},
help: {
container:
'bg-blue-50/80 rounded-2xl border border-blue-200 dark:bg-blue-800/30 dark:border-blue-600/30',
icon: 'text-blue-500 dark:text-blue-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: HelpCircle
},
idea: {
container:
'bg-yellow-50/80 rounded-2xl border border-yellow-200 dark:bg-yellow-800/30 dark:border-yellow-600/30',
icon: 'text-yellow-500 dark:text-yellow-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Lightbulb
},
pending: {
container:
'bg-orange-50/80 rounded-2xl border border-orange-200 dark:bg-orange-900/30 dark:border-orange-600/30',
icon: 'text-orange-500 dark:text-orange-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Clock
},
security: {
container:
'bg-slate-50/80 rounded-2xl border border-slate-300 dark:bg-slate-800/30 dark:border-slate-500/40',
icon: 'text-slate-500 dark:text-slate-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Shield
},
beta: {
container:
'bg-violet-50/80 rounded-2xl border border-violet-200 dark:bg-violet-800/20 dark:border-violet-600/30',
icon: 'text-violet-500 dark:text-violet-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Rocket
},
'best-practice': {
container:
'bg-teal-50/80 rounded-2xl border border-teal-200 dark:bg-teal-800/30 dark:border-teal-600/30',
icon: 'text-teal-500 dark:text-teal-400',
text: 'text-slate-700 dark:text-slate-200',
Icon: Heart
}
};
// Alert component
const AlertComponent = ({
variant,
children
}: {
variant: AlertVariant;
children: React.ReactNode;
}) => {
const { t } = useTranslation();
const configData = useConfig();
const useModernStyle = configData?.config?.ui?.alert_style === 'modern';
const styleSet = useModernStyle ? modernVariantStyles : variantStyles;
const style = styleSet[variant];
const Icon = style.Icon;
return (
<div className={cn('rounded-lg p-4 mb-4', style.container)}>
<div className="flex">
<div className={cn('flex-shrink-0', style.icon)}>
<Icon className="w-5 h-5" />
</div>
<div className="ml-3">
<div className={cn('text-sm font-medium mb-1', style.text)}>
{t(`alerts.${variant}`)}
</div>
<div className={cn('text-sm', style.text)}>{children}</div>
</div>
</div>
</div>
);
};
// MarkdownAlert plugin
export const MarkdownAlert = () => {
return (tree: any) => {
visit(tree, 'text', (node) => {
const regex = /^:::\s*([\w-]+)\n([\s\S]*?)\n:::/i;
const match = node.value.match(regex);
if (match) {
const [, type, content] = match;
node.type = 'element';
node.data = {
hName: 'Alert',
hProperties: { variant: normalizeAlertType(type) }
};
node.children = [{ type: 'text', value: content.trim() }];
}
});
};
};
export const normalizeAlertType = (type: string): AlertVariant => {
if (!type) return 'info';
const normalized = type.toLowerCase().replace(/[-_\s]/g, '-');
if (!AlertTypes.includes(normalized as AlertVariant)) {
console.warn(`Invalid alert type "${type}", falling back to "info"`);
return 'info';
}
return normalized as AlertVariant;
};
export const alertComponents = {
Alert: AlertComponent
};
@@ -0,0 +1,79 @@
import { useTranslation } from 'components/i18n/Translator';
import { Auth0 } from 'components/icons/Auth0';
import { Cognito } from 'components/icons/Cognito';
import { Descope } from 'components/icons/Descope';
import { GitHub } from 'components/icons/Github';
import { Gitlab } from 'components/icons/Gitlab';
import { Google } from 'components/icons/Google';
import { Microsoft } from 'components/icons/Microsoft';
import { Okta } from 'components/icons/Okta';
import { Button } from './ui/button';
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function getProviderName(provider: string) {
switch (provider) {
case 'azure-ad':
case 'azure-ad-hybrid':
return 'Microsoft';
case 'github':
return 'GitHub';
case 'okta':
return 'Okta';
case 'descope':
return 'Descope';
case 'aws-cognito':
return 'Cognito';
default:
return capitalizeFirstLetter(provider);
}
}
function renderProviderIcon(provider: string) {
switch (provider) {
case 'google':
return <Google />;
case 'github':
return <GitHub />;
case 'azure-ad':
case 'azure-ad-hybrid':
return <Microsoft />;
case 'okta':
return <Okta />;
case 'auth0':
return <Auth0 />;
case 'descope':
return <Descope />;
case 'aws-cognito':
return <Cognito />;
case 'gitlab':
return <Gitlab />;
default:
return null;
}
}
interface ProviderButtonProps {
provider: string;
onClick: () => void;
}
const ProviderButton = ({
provider,
onClick
}: ProviderButtonProps): JSX.Element => {
const { t } = useTranslation();
return (
<Button type="button" variant="outline" onClick={onClick}>
{renderProviderIcon(provider.toLowerCase())}
{t('auth.provider.continue', {
provider: getProviderName(provider)
})}
</Button>
);
};
export { ProviderButton };
+89
View File
@@ -0,0 +1,89 @@
import { cn } from '@/lib/utils';
import { IImageElement, IVideoElement } from '@chainlit/react-client';
const sizeToUnit = (element: IImageElement | IVideoElement) => {
switch (element.size) {
case 'small':
return 1;
case 'medium':
return 2;
case 'large':
return 4;
default:
return 2;
}
};
interface QuiltedGridProps<T extends IImageElement | IVideoElement> {
elements: T[];
renderElement: ({ element }: { element: T }) => JSX.Element | null;
className?: string;
}
const QuiltedGrid = <T extends IImageElement | IVideoElement>({
elements,
renderElement: Renderer,
className
}: QuiltedGridProps<T>) => {
// If there's only one element, use a simpler layout
if (elements.length === 1) {
const element = elements[0];
const size = sizeToUnit(element);
return (
<div
className={cn(
'w-full',
// Adjust max-width based on size
size === 1
? 'max-w-[150px]'
: size === 2
? 'max-w-[300px]'
: 'max-w-[600px]',
className
)}
>
<Renderer element={element} />
</div>
);
}
return (
<div
className={cn(
'grid grid-cols-4 gap-2 w-full max-w-[600px]',
'transform-gpu',
className
)}
>
{elements.map((element, i) => {
const cols = sizeToUnit(element);
const rows = sizeToUnit(element);
return (
<div
key={i}
className={cn(
'relative',
cols === 1
? 'col-span-1'
: cols === 2
? 'col-span-2'
: 'col-span-4',
rows === 1
? 'row-span-1'
: rows === 2
? 'row-span-2'
: 'row-span-4'
)}
>
<Renderer element={element} />
</div>
);
})}
</div>
);
};
export { QuiltedGrid };
+217
View File
@@ -0,0 +1,217 @@
import { MessageContext } from '@/contexts/MessageContext';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { toast } from 'sonner';
import {
ChainlitContext,
IAction,
IFeedback,
IMessageElement,
IStep,
IThread,
nestMessages,
sessionIdState,
sideViewState,
useApi,
useConfig
} from '@chainlit/react-client';
import { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';
import { ErrorBoundary } from './ErrorBoundary';
import { Loader } from './Loader';
import { Messages } from './chat/Messages';
type Props = {
id: string;
};
const ReadOnlyThread = ({ id }: Props) => {
const { config } = useConfig();
const location = useLocation();
const isSharedRoute = location.pathname.startsWith('/share/');
const {
data: thread,
error: threadError,
isLoading
} = useApi<IThread>(
id
? isSharedRoute
? `/project/share/${id}`
: `/project/thread/${id}`
: null,
{
revalidateOnFocus: false
}
);
const navigate = useNavigate();
const setSideView = useSetRecoilState(sideViewState);
const [steps, setSteps] = useState<IStep[]>([]);
const apiClient = useContext(ChainlitContext);
const { t } = useTranslation();
const layoutMaxWidth = useLayoutMaxWidth();
const sessionId = useRecoilValue(sessionIdState);
useEffect(() => {
if (!thread) {
setSteps([]);
return;
}
setSteps(thread.steps);
}, [thread]);
useEffect(() => {
if (threadError) {
navigate('/');
toast.error('Failed to load thread: ' + threadError.message);
}
}, [threadError]);
const onFeedbackUpdated = useCallback(
async (message: IStep, onSuccess: () => void, feedback: IFeedback) => {
toast.promise(apiClient.setFeedback(feedback, sessionId), {
loading: 'Updating',
success: (res) => {
setSteps((prev) =>
prev.map((step) => {
if (step.id === message.id) {
return {
...step,
feedback: {
...feedback,
id: res.feedbackId
}
};
}
return step;
})
);
onSuccess();
return 'Feedback updated!';
},
error: (err) => {
return <span>{err.message}</span>;
}
});
},
[setSteps]
);
const onFeedbackDeleted = useCallback(
async (message: IStep, onSuccess: () => void, feedbackId: string) => {
toast.promise(apiClient.deleteFeedback(feedbackId), {
loading: t('chat.messages.feedback.status.updating'),
success: () => {
setSteps((prev) =>
prev.map((step) => {
if (step.id === message.id) {
return {
...step,
feedback: undefined
};
}
return step;
})
);
onSuccess();
return t('chat.messages.feedback.status.updated');
},
error: (err) => {
return <span>{err.message}</span>;
}
});
},
[setSteps]
);
const onElementRefClick = useCallback(
(element: IMessageElement) => {
if (element.display === 'side') {
setSideView({ title: element.name, elements: [element] });
return;
}
let path = `/element/${element.id}`;
if (element.threadId) {
path += `?thread=${element.threadId}`;
}
return navigate(element.display === 'page' ? path : '#');
},
[setSideView, navigate]
);
const onError = useCallback((error: string) => toast.error(error), [toast]);
const elements = thread?.elements || [];
const actions: IAction[] = [];
const messages = nestMessages(steps);
const memoizedContext = useMemo(() => {
return {
allowHtml: config?.features?.unsafe_allow_html,
latex: config?.features?.latex,
renderMarkdown: config?.features?.user_message_markdown,
editable: false,
loading: false,
showFeedbackButtons: !!config?.dataPersistence,
uiName: config?.ui?.name || '',
cot: config?.ui?.cot || 'hidden',
onElementRefClick,
onError,
onFeedbackUpdated,
onFeedbackDeleted
};
}, [
config?.ui?.name,
config?.ui?.cot,
config?.features?.unsafe_allow_html,
config?.features?.user_message_markdown,
onElementRefClick,
onError,
onFeedbackUpdated,
onFeedbackDeleted
]);
if (!isSharedRoute && isLoading) {
return (
<div className="flex flex-col h-full w-full items-center justify-center">
<Loader className="!size-6" />
</div>
);
}
if (!isSharedRoute && !thread) {
return null;
}
return (
<div className="flex w-full flex-col flex-grow relative overflow-y-auto">
<ErrorBoundary>
<MessageContext.Provider value={memoizedContext}>
<div
className="flex flex-col mx-auto w-full flex-grow p-4"
style={{
maxWidth: layoutMaxWidth
}}
>
<Messages
indent={0}
messages={messages}
elements={elements as any}
actions={actions}
/>
</div>
</MessageContext.Provider>
</ErrorBoundary>
</div>
);
};
export { ReadOnlyThread };
+78
View File
@@ -0,0 +1,78 @@
import { Markdown } from '@/components/Markdown';
import { TaskStatusIcon } from './TaskStatusIcon';
export interface ITask {
title: string;
status: 'ready' | 'running' | 'done' | 'failed';
forId?: string;
}
export interface ITaskList {
status: 'ready' | 'running' | 'done';
tasks: ITask[];
}
interface TaskProps {
index: number;
task: ITask;
allowHtml?: boolean;
latex?: boolean;
}
export const Task = ({ index, task, allowHtml, latex }: TaskProps) => {
const statusStyles = {
ready: '',
running: 'font-semibold',
done: 'text-muted-foreground',
failed: 'text-muted-foreground'
};
const handleClick = () => {
if (task.forId) {
const parent = document.getElementById(`step-${task.forId}`);
if (parent) {
// Find the child div below the main step container
const child = parent.querySelector('div');
if (child) {
child.classList.add('bg-card', 'rounded');
parent.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'start'
});
setTimeout(() => {
child.classList.remove('bg-card', 'rounded');
}, 600); // 2 blinks at 0.3s each
}
}
}
};
return (
<div className={`task task-status-${task.status}`}>
<div
className={`w-full grid grid-cols-[auto_auto_1fr] items-start gap-1.5 font-medium py-0.5 px-1 text-sm leading-tight ${
statusStyles[task.status]
} ${task.forId ? 'cursor-pointer' : 'cursor-default'}`}
onClick={handleClick}
>
<div className="text-xs text-muted-foreground text-right pr-1 pt-[1px]">
{index}
</div>
<div className="flex items-start pt-[1px]">
<TaskStatusIcon status={task.status} />
</div>
<div className="min-w-0">
<Markdown
allowHtml={allowHtml}
latex={latex}
className="max-w-none prose-sm text-left break-words [&_p]:m-0 [&_p]:leading-snug [&_div]:leading-snug [&_div]:mt-0 [&_strong]:font-semibold"
>
{task.title}
</Markdown>
</div>
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import { Check, Dot, X } from 'lucide-react';
import { Loader } from '@/components/Loader';
import type { ITask } from './Task';
export const TaskStatusIcon = ({ status }: { status: ITask['status'] }) => {
if (status === 'running') {
return <Loader className="!size-5" />;
}
return (
<>
{status === 'done' && (
<Check className="!size-4 text-green-500 mt-[1px]" />
)}
{status === 'ready' && <Dot className="!size-4 mt-[1px]" />}
{status === 'failed' && <X className="!size-4 text-red-500 mt-[1px]" />}
</>
);
};
+111
View File
@@ -0,0 +1,111 @@
import { cn } from '@/lib/utils';
import useSWR from 'swr';
import { useChatData, useConfig } from '@chainlit/react-client';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { ITaskList, Task } from './Task';
interface HeaderProps {
status: string;
}
const fetcher = (url: string) =>
fetch(url, { credentials: 'include' }).then((r) => r.json());
const Header = ({ status }: HeaderProps) => {
return (
<CardHeader className="flex flex-row items-center justify-between gap-2 p-3">
<div className="font-semibold">Tasks</div>
<Badge variant="secondary">{status || '?'}</Badge>
</CardHeader>
);
};
interface TaskListProps {
isMobile: boolean;
isCopilot?: boolean;
}
const TaskList = ({ isMobile, isCopilot }: TaskListProps) => {
const { tasklists } = useChatData();
const tasklist = tasklists[tasklists.length - 1];
const { config } = useConfig();
const allowHtml = config?.features?.unsafe_allow_html;
const latex = config?.features?.latex;
const { error, data, isLoading } = useSWR<ITaskList>(tasklist?.url, fetcher, {
keepPreviousData: true
});
if (!tasklist?.url) return null;
if (isLoading && !data) {
return null;
}
if (error) {
return null;
}
const content = data as ITaskList;
if (!content) return null;
const tasks = content.tasks;
if (isMobile) {
// Get the first running or ready task, or the latest task
let highlightedTaskIndex = tasks.length - 1;
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].status === 'running' || tasks[i].status === 'ready') {
highlightedTaskIndex = i;
break;
}
}
const highlightedTask = tasks?.[highlightedTaskIndex];
return (
<aside
className={cn('w-full tasklist-mobile', !isCopilot && 'md:hidden')}
>
<Card>
<Header status={content.status} />
{highlightedTask && (
<CardContent className="p-2.5">
<Task
index={highlightedTaskIndex + 1}
task={highlightedTask}
allowHtml={allowHtml}
latex={latex}
/>
</CardContent>
)}
</Card>
</aside>
);
}
return (
<aside className="hidden tasklist max-w-[21rem] flex-grow md:block overflow-y-auto mr-3 mb-3">
<Card className="overflow-y-auto h-full">
<Header status={content?.status} />
<CardContent className="flex flex-col gap-1 p-2.5">
{tasks?.map((task, index) => (
<Task
key={index}
index={index + 1}
task={task}
allowHtml={allowHtml}
latex={latex}
/>
))}
</CardContent>
</Card>
</aside>
);
};
export { TaskList };
+95
View File
@@ -0,0 +1,95 @@
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 root = window.document.documentElement;
// Apply new theme variables
Object.entries(variables).forEach(([key, value]) => {
root.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 root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
applyThemeVariables(systemTheme);
return;
} else {
applyThemeVariables(theme);
}
root.classList.add(theme);
}, [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 };
};
+21
View File
@@ -0,0 +1,21 @@
import { Markdown } from '@/components/Markdown';
import { useTranslation } from '@/components/i18n/Translator';
export default function WaterMark() {
const { t } = useTranslation();
return (
<div
className="watermark"
style={{
display: 'flex',
alignItems: 'center',
textDecoration: 'none'
}}
>
<Markdown className="[&_p]:m-0 [&_p]:leading-snug [&_div]:leading-snug [&_div]:mt-0 [&_strong]:font-semibold text-xs text-muted-foreground">
{t('chat.watermark')}
</Markdown>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { cn, hasMessage } from '@/lib/utils';
import { MutableRefObject } from 'react';
import { FileSpec, useChatMessages } from '@chainlit/react-client';
import WaterMark from '@/components/WaterMark';
import MessageComposer from './MessageComposer';
interface Props {
fileSpec: FileSpec;
onFileUpload: (payload: File[]) => void;
onFileUploadError: (error: string) => void;
autoScrollRef: MutableRefObject<boolean>;
showIfEmptyThread?: boolean;
}
export default function ChatFooter({ showIfEmptyThread, ...props }: Props) {
const { messages } = useChatMessages();
if (!hasMessage(messages) && !showIfEmptyThread) return null;
return (
<div className={cn('relative flex flex-col items-center gap-2 w-full')}>
<MessageComposer {...props} />
<WaterMark />
</div>
);
}
@@ -0,0 +1,99 @@
import React, { useEffect, useMemo } from 'react';
import { DefaultExtensionType, FileIcon, defaultStyles } from 'react-file-icon';
import { Card } from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface AttachmentProps {
name: string;
mime: string;
children?: React.ReactNode;
file?: File;
}
const Attachment: React.FC<AttachmentProps> = ({
name,
mime,
children,
file
}) => {
const isImage = useMemo(() => mime.startsWith('image/'), [mime]);
const imageUrl = useMemo(() => {
if (isImage && file) {
return URL.createObjectURL(file);
}
return undefined;
}, [isImage, file]);
// Cleanup Object URL on unmount or when imageUrl changes
useEffect(() => {
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
let extension: DefaultExtensionType;
if (name.includes('.')) {
extension = name.split('.').pop()!.toLowerCase() as DefaultExtensionType;
} else {
extension = mime
? ((mime.split('/').pop() || 'txt') as DefaultExtensionType)
: ('txt' as DefaultExtensionType);
}
if (isImage && imageUrl) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative h-[58px] w-[58px]">
{children}
<Card className="h-full p-1 flex items-center justify-center rounded-lg border overflow-hidden">
<img
src={imageUrl}
alt={name}
className="h-full w-full object-cover"
/>
</Card>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative h-[58px]">
{children}
<Card className="h-full p-2 flex flex-row items-center gap-3 rounded-lg w-full max-w-[200px] border">
<div className="w-10">
<FileIcon {...defaultStyles[extension]} extension={extension} />
</div>
<span className="truncate w-[80%] font-medium text-sm font-medium">
{name}
</span>
</Card>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export { Attachment };
@@ -0,0 +1,143 @@
import { X } from 'lucide-react';
import React from 'react';
import { useRecoilValue } from 'recoil';
import { useTranslation } from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { attachmentsState } from '@/state/chat';
import { Attachment } from './Attachment';
const CircularProgressButton = ({
progress,
onClick,
children
}: {
progress: number;
onClick: () => void;
children: React.ReactNode;
}) => {
const size = 24; // 6 * 4 (w-6 = 1.5rem = 24px)
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg
className="absolute"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
className="text-muted-foreground/20"
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
stroke="currentColor"
/>
<circle
className="text-primary transition-all duration-300 ease-in-out"
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
stroke="currentColor"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<Button
size="icon"
className="w-6 h-6 rounded-full bg-card hover:bg-card text-foreground"
onClick={onClick}
>
{children}
</Button>
</div>
);
};
const Attachments = () => {
const { t } = useTranslation();
const attachments = useRecoilValue(attachmentsState);
if (attachments.length === 0) return null;
return (
<div id="attachments" className="flex flex-row flex-wrap gap-4 w-fit">
{attachments.map((attachment) => {
const showProgress = !attachment.uploaded && attachment.cancel;
const progress = showProgress ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute -right-2 -top-2">
<CircularProgressButton
progress={attachment.uploadProgress || 0}
onClick={() => attachment.cancel?.()}
>
<X className="!size-3" />
</CircularProgressButton>
</div>
</TooltipTrigger>
<TooltipContent>
{t('chat.fileUpload.actions.cancelUpload')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null;
const remove =
!showProgress && attachment.remove ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute -right-2 -top-2">
<Button
size="icon"
className="w-6 h-6 shadow-sm rounded-full border-4 bg-card hover:bg-card text-foreground light:border-muted"
onClick={attachment.remove}
>
<X className="!size-3" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
{t('chat.fileUpload.actions.removeAttachment')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null;
return (
<Attachment
key={attachment.id}
name={attachment.name}
mime={attachment.type}
file={attachment.file}
>
{progress}
{remove}
</Attachment>
);
})}
</div>
);
};
export { Attachments };
@@ -0,0 +1,165 @@
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ICommand, commandsState } from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface Props {
disabled?: boolean;
selectedCommandId?: string;
onCommandSelect: (command?: ICommand) => void;
}
interface AnimatedCommandButtonProps {
command: ICommand;
isSelected: boolean;
disabled: boolean;
onCommandSelect: (command?: ICommand) => void;
index: number;
}
const AnimatedCommandButton = ({
command,
isSelected,
disabled,
onCommandSelect,
index
}: AnimatedCommandButtonProps) => {
const [isAnimating, setIsAnimating] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Prevent initial animation on mount
const timer = setTimeout(() => setHasInitialized(true), 100);
return () => clearTimeout(timer);
}, []);
const handleClick = () => {
setIsAnimating(true);
onCommandSelect(isSelected ? undefined : command);
setTimeout(() => setIsAnimating(false), 300);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={buttonRef}
id={`command-${command.id}`}
variant="ghost"
disabled={disabled}
className={cn(
'command-button relative p-2 h-9 text-[13px] font-medium rounded-full',
'transition-all duration-300 ease-out',
'transform-gpu overflow-hidden',
// Same hover background for both selected and unselected
'hover:bg-muted',
// Selected state: blue text color that persists on hover
isSelected && 'text-command hover:text-command',
isAnimating && 'animate-bounce-subtle',
!hasInitialized && 'opacity-0',
hasInitialized && 'opacity-100',
// Underline animation for selected state
isSelected &&
'after:content-[""] after:absolute after:bottom-[-2px] after:left-1/2 after:-translate-x-1/2 after:w-[30%] after:h-[2px] after:bg-command after:rounded-[1px] after:animate-expand-width'
)}
onClick={handleClick}
style={{
animationDelay: hasInitialized ? '0ms' : `${index * 50}ms`
}}
>
<div className="flex items-center">
<Icon
name={command.icon}
className={cn(
'!h-5 !w-5 transition-colors duration-200',
isSelected && 'text-command'
)}
/>
<span
className={cn(
'ml-1.5 transition-all duration-300',
isSelected
? 'max-w-[200px] overflow-visible'
: 'max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap max-sm:hidden'
)}
>
{command.id}
</span>
<div
className={cn(
'ml-1 transition-all duration-300 flex items-center',
isSelected ? 'w-4 opacity-60' : 'w-0 opacity-0'
)}
>
<X className="!size-4 text-command" />
</div>
</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{command.description}</p>
</TooltipContent>
</Tooltip>
);
};
export const CommandButtons = ({
disabled = false,
selectedCommandId,
onCommandSelect
}: Props) => {
const commands = useRecoilValue(commandsState);
const commandButtons = commands.filter((c) => !!c.button);
// Find the selected command if it's not a button command
const selectedCommand = commands.find(
(c) => c.id === selectedCommandId && !c.button
);
// If no button commands and no selected non-button command, don't render
if (!commandButtons.length && !selectedCommand) return null;
return (
<div className="flex gap-1 ml-1 flex-wrap command-buttons-container">
<TooltipProvider>
{/* Show selected non-button command as a button */}
{selectedCommand && (
<AnimatedCommandButton
key={selectedCommand.id}
command={selectedCommand}
isSelected={true}
disabled={disabled}
onCommandSelect={onCommandSelect}
index={0}
/>
)}
{/* Show button commands */}
{commandButtons.map((command, index) => (
<AnimatedCommandButton
key={command.id}
command={command}
isSelected={selectedCommandId === command.id}
disabled={disabled}
onCommandSelect={onCommandSelect}
index={selectedCommand ? index + 1 : index}
/>
))}
</TooltipProvider>
</div>
);
};
export default CommandButtons;
@@ -0,0 +1,244 @@
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@radix-ui/react-popover';
import { every } from 'lodash';
import { Settings2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ICommand, commandsState } from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Button } from '@/components/ui/button';
import {
Command,
CommandGroup,
CommandItemAnimated,
CommandListScrollable
} from '@/components/ui/command';
import {
TOOLTIP_DELAY_MS,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { useTranslation } from 'components/i18n/Translator';
import { useCommandNavigation } from '@/hooks/useCommandNavigation';
interface Props {
disabled?: boolean;
selectedCommandId?: string;
onCommandSelect: (command: ICommand) => void;
}
export const CommandPopoverButton = ({
disabled = false,
selectedCommandId,
onCommandSelect
}: Props) => {
const { t } = useTranslation();
const commands = useRecoilValue(commandsState);
const [open, setOpen] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const hoverTimerRef = useRef<number | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const allButtons = every(commands.map((c) => !!c.button));
// Check if there's a selected non-button command
const hasSelectedNonButtonCommand = commands.some(
(c) => c.id === selectedCommandId && !c.button
);
const nonButtonCommands = commands.filter((c) => !c.button);
// Handle direct command selection (for mouse clicks)
const handleCommandSelect = (command: ICommand) => {
onCommandSelect(command);
setOpen(false);
cancelTooltipOpen();
};
const { selectedIndex, handleMouseMove, handleMouseLeave, handleKeyDown } =
useCommandNavigation({
items: nonButtonCommands,
isOpen: open,
onSelect: handleCommandSelect, // This will be used for keyboard selection
onClose: () => {
setOpen(false);
cancelTooltipOpen();
buttonRef.current?.focus();
}
});
// Handle animation when selection changes
useEffect(() => {
if (hasSelectedNonButtonCommand) {
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), 300);
return () => clearTimeout(timer);
}
}, [hasSelectedNonButtonCommand]);
// Ensure timers are cleared on unmount
useEffect(() => {
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
};
}, []);
// Reset selection when opening and never show tooltip while popover is open
useEffect(() => {
if (open) {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setTooltipOpen(false);
}
}, [open]);
const scheduleTooltipOpen = () => {
if (disabled) return;
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
hoverTimerRef.current = window.setTimeout(() => {
setTooltipOpen(true);
}, TOOLTIP_DELAY_MS);
};
const cancelTooltipOpen = () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setTooltipOpen(false);
};
if (!commands.length || allButtons) return null;
return (
<div
className={cn(
'command-popover-wrapper',
'transition-all duration-300 ease-out',
isAnimating && 'animate-command-shift'
)}
>
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
if (v) cancelTooltipOpen(); // suppress tooltip while popover is open
}}
>
<TooltipProvider>
{/* Controlled tooltip so it only opens after our delay and never on focus */}
<Tooltip open={!open && tooltipOpen}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
id="command-button"
variant="ghost"
size="sm"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="command-popover"
className={cn(
'flex items-center h-9 rounded-full font-medium text-[13px]',
'hover:bg-muted hover:dark:bg-muted transition-all duration-200 transition-width-padding',
'focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
open && 'bg-muted/50',
hasSelectedNonButtonCommand
? 'min-w-[36px] px-0 gap-0'
: 'px-3 gap-1.5'
)}
disabled={disabled}
onMouseEnter={scheduleTooltipOpen}
onMouseLeave={cancelTooltipOpen}
>
<Settings2
className={cn(
'!size-5 transition-transform duration-200',
open && 'rotate-45'
)}
/>
{!hasSelectedNonButtonCommand && (
<span className="overflow-hidden transition-all duration-300 opacity-100 w-auto max-w-[100px]">
{t('chat.commands.button')}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
<p>
{hasSelectedNonButtonCommand
? t('chat.commands.changeTool')
: t('chat.commands.availableTools')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent
id="command-popover"
align="start"
sideOffset={12}
data-popover-content
tabIndex={0}
className={cn(
'p-2 rounded-lg border shadow-md bg-background',
'animate-in fade-in-0 zoom-in-95 duration-200',
'focus:outline-none'
)}
onKeyDown={handleKeyDown}
onMouseLeave={handleMouseLeave}
>
<Command className="overflow-hidden bg-transparent">
<CommandListScrollable maxItems={5} className="custom-scrollbar">
<CommandGroup className="p-0">
{nonButtonCommands.map((command, index) => (
<CommandItemAnimated
key={command.id}
index={index}
isSelected={index === selectedIndex}
onMouseMove={() => handleMouseMove(index)}
onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks
className="space-x-2"
>
<Icon
name={command.icon}
className={cn(
'!size-5 text-muted-foreground transition-transform duration-150',
index === selectedIndex && 'scale-110'
)}
/>
<div className="flex-1">
<div className="font-medium">{command.id}</div>
<div className="text-sm text-muted-foreground">
{command.description}
</div>
</div>
</CommandItemAnimated>
))}
</CommandGroup>
</CommandListScrollable>
</Command>
</PopoverContent>
</Popover>
</div>
);
};
export default CommandPopoverButton;
@@ -0,0 +1,188 @@
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@radix-ui/react-popover';
import { Star, Trash } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
favoriteMessagesState,
useChatInteract,
useConfig
} from '@chainlit/react-client';
import { useTranslation } from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandListScrollable
} from '@/components/ui/command';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
const TOOLTIP_DELAY_MS = 700;
interface Props {
disabled?: boolean;
onSelect: (content: string) => void;
}
export const FavoriteButton = ({ disabled = false, onSelect }: Props) => {
const favorites = useRecoilValue(favoriteMessagesState);
const { toggleMessageFavorite } = useChatInteract();
const { config } = useConfig();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const hoverTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
};
}, []);
useEffect(() => {
if (open) {
cancelTooltipOpen();
}
}, [open]);
const scheduleTooltipOpen = () => {
if (disabled || open) return;
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
hoverTimerRef.current = window.setTimeout(() => {
setTooltipOpen(true);
}, TOOLTIP_DELAY_MS);
};
const cancelTooltipOpen = () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setTooltipOpen(false);
};
if (!config?.features?.favorites) return null;
return (
<div className={cn('favorite-popover-wrapper')}>
<Popover
open={open}
onOpenChange={(val) => {
setOpen(val);
if (val) cancelTooltipOpen();
}}
>
<TooltipProvider>
<Tooltip open={!open && tooltipOpen}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'flex items-center h-9 px-3 rounded-full font-medium text-[13px] gap-1.5',
'hover:bg-muted hover:dark:bg-muted transition-all duration-200',
open && 'bg-muted/50'
)}
disabled={disabled}
onMouseEnter={scheduleTooltipOpen}
onMouseLeave={cancelTooltipOpen}
onFocus={cancelTooltipOpen}
>
<Star className="!size-5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
<p>{t('chat.favorites.use')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent
align="start"
sideOffset={12}
className="p-2 w-[300px] rounded-lg border shadow-md bg-background"
>
<Command>
<CommandListScrollable className="max-h-[300px] custom-scrollbar">
{favorites.length === 0 ? (
<CommandEmpty className="py-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm font-medium text-foreground">
{t('chat.favorites.empty.title')}
</p>
<p className="text-xs text-muted-foreground">
{t('chat.favorites.empty.description')}
</p>
</div>
</CommandEmpty>
) : (
<CommandGroup heading={t('chat.favorites.headline')}>
{favorites.map((step) => (
<CommandItem
key={step.id}
value={step.id}
onSelect={() => {
onSelect(step.output);
setOpen(false);
cancelTooltipOpen();
}}
className="cursor-pointer group"
>
<div className="flex items-center justify-between gap-2 w-full overflow-hidden">
<div className="flex flex-col gap-1 overflow-hidden">
<span className="truncate text-sm">
{step.output}
</span>
<span className="text-xs text-muted-foreground">
{new Date(step.createdAt).toLocaleDateString()}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={t('chat.favorites.remove')}
disabled={disabled}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
toggleMessageFavorite(step);
}}
>
<Trash className="h-3.5 w-3.5" />
</Button>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandListScrollable>
</Command>
</PopoverContent>
</Popover>
</div>
);
};
export default FavoriteButton;
@@ -0,0 +1,232 @@
import { cn } from '@/lib/utils';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import { useRecoilValue } from 'recoil';
import { ICommand, commandsState } from '@chainlit/react-client';
import AutoResizeTextarea from '@/components/AutoResizeTextarea';
import Icon from '@/components/Icon';
import {
Command,
CommandGroup,
CommandItemAnimated,
CommandListScrollable
} from '@/components/ui/command';
import { useCommandNavigation } from '@/hooks/useCommandNavigation';
interface Props {
id?: string;
className?: string;
autoFocus?: boolean;
placeholder?: string;
selectedCommand?: ICommand;
setSelectedCommand: (command: ICommand | undefined) => void;
onChange: (value: string) => void;
onPaste?: (event: any) => void;
onEnter?: () => void;
}
export interface InputMethods {
reset: () => void;
setValueExtern: (value: string) => void;
}
const Input = forwardRef<InputMethods, Props>(
(
{
placeholder,
id,
className,
autoFocus,
selectedCommand,
setSelectedCommand,
onChange,
onEnter,
onPaste
},
ref
) => {
const commands = useRecoilValue(commandsState);
const [isComposing, setIsComposing] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [commandInput, setCommandInput] = useState('');
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const normalizedInput = commandInput.toLowerCase().slice(1);
const filteredCommands = commands
.filter((command) => command.id.toLowerCase().includes(normalizedInput))
.sort((a, b) => {
const indexA = a.id.toLowerCase().indexOf(normalizedInput);
const indexB = b.id.toLowerCase().indexOf(normalizedInput);
return indexA - indexB;
});
const {
selectedIndex,
handleMouseMove,
handleMouseLeave,
handleKeyDown: navigationKeyDown
} = useCommandNavigation({
items: filteredCommands,
isOpen: showCommands,
onSelect: (command) => {
handleCommandSelect(command);
},
onClose: () => {
setShowCommands(false);
setCommandInput('');
}
});
const reset = () => {
setValue('');
if (!selectedCommand?.persistent) {
setSelectedCommand(undefined);
}
setCommandInput('');
setShowCommands(false);
onChange('');
};
useImperativeHandle(ref, () => ({
reset,
setValueExtern: (value: string) => {
setValue(value);
onChange(value);
}
}));
useEffect(() => {
if (textareaRef.current && autoFocus) {
textareaRef.current.focus();
}
}, [autoFocus]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setValue(newValue);
onChange(newValue);
// Command detection for dropdown
const words = newValue.split(' ');
if (words.length === 1 && words[0].startsWith('/')) {
setShowCommands(true);
setCommandInput(words[0]);
} else {
setShowCommands(false);
setCommandInput('');
}
};
const handleCommandSelect = (command: ICommand) => {
setShowCommands(false);
setSelectedCommand(command);
// Remove the command text from the input
const newValue = value.replace(commandInput, '').trimStart();
setValue(newValue);
onChange(newValue);
setCommandInput('');
// Focus back on textarea
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle command selection - check this FIRST before other key handling
if (showCommands && filteredCommands.length > 0) {
navigationKeyDown(e);
// If the navigation handled the key, don't process further
if (e.defaultPrevented) {
return;
}
}
// Handle regular enter only if command dropdown is actually visible
if (
e.key === 'Enter' &&
!e.shiftKey &&
onEnter &&
!isComposing &&
!(showCommands && filteredCommands.length > 0)
) {
e.preventDefault();
onEnter();
}
};
return (
<div className="relative w-full">
<AutoResizeTextarea
ref={textareaRef}
id={id}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={onPaste}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
placeholder={placeholder}
className={cn(
'w-full resize-none bg-transparent placeholder:text-muted-foreground focus:outline-none',
className
)}
maxHeight={250}
/>
{showCommands && filteredCommands.length > 0 && (
<div
className="absolute z-50 left-0 bottom-full mb-3 animate-slide-up"
onMouseLeave={handleMouseLeave}
>
<Command className="rounded-lg border shadow-md bg-background">
<CommandListScrollable maxItems={5} className="custom-scrollbar">
<CommandGroup className="p-2">
{filteredCommands.map((command, index) => (
<CommandItemAnimated
key={command.id}
index={index}
isSelected={index === selectedIndex}
onMouseMove={() => handleMouseMove(index)}
onSelect={() => handleCommandSelect(command)}
className="command-item space-x-2"
>
<Icon
name={command.icon}
className={cn(
'!size-5 text-muted-foreground transition-transform duration-150',
index === selectedIndex && 'scale-110'
)}
/>
<div className="flex-1">
<div className="font-medium">{command.id}</div>
<div className="text-sm text-muted-foreground">
{command.description}
</div>
</div>
</CommandItemAnimated>
))}
</CommandGroup>
</CommandListScrollable>
</Command>
</div>
)}
</div>
);
}
);
export default Input;
@@ -0,0 +1,304 @@
import { useContext, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { toast } from 'sonner';
import {
ChainlitContext,
mcpState,
sessionIdState
} from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Translator } from 'components/i18n';
interface McpAddFormProps {
onSuccess: () => void;
onCancel: () => void;
allowStdio?: boolean;
allowSse?: boolean;
allowHttp?: boolean;
}
export const McpAddForm = ({
onSuccess,
onCancel,
allowStdio,
allowSse,
allowHttp
}: McpAddFormProps) => {
const apiClient = useContext(ChainlitContext);
const sessionId = useRecoilValue(sessionIdState);
const setMcps = useSetRecoilState(mcpState);
const [serverName, setServerName] = useState('');
// Pick the first protocol enabled by the parent component.
const defaultType: 'stdio' | 'sse' | 'streamable-http' = allowStdio
? 'stdio'
: allowSse
? 'sse'
: allowHttp
? 'streamable-http'
: 'stdio';
const [serverType, setServerType] = useState<
'stdio' | 'sse' | 'streamable-http'
>(defaultType);
const [serverUrl, setServerUrl] = useState('');
const [httpUrl, setHttpUrl] = useState('');
const [serverCommand, setServerCommand] = useState('');
const [headersInput, setHeadersInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Form validation function
const isFormValid = () => {
if (!serverName.trim()) return false;
if (serverType === 'stdio') {
return !!serverCommand.trim();
} else if (serverType === 'sse') {
return !!serverUrl.trim();
} else if (serverType === 'streamable-http') {
return !!httpUrl.trim();
}
return false;
};
const resetForm = () => {
setServerName('');
setServerType(defaultType);
setServerUrl('');
setServerCommand('');
setHttpUrl('');
setHeadersInput('');
};
const addMcp = () => {
setIsLoading(true);
// Helper to parse the optional headers JSON
let headersObj: Record<string, string> | undefined;
if (headersInput.trim()) {
try {
headersObj = JSON.parse(headersInput.trim());
} catch (_err) {
toast.error('Headers must be valid JSON');
setIsLoading(false);
return;
}
}
if (serverType === 'stdio') {
toast.promise(
apiClient
.connectStdioMCP(sessionId, serverName, serverCommand)
.then(async (resp: any) => {
const { success, mcp, error } = resp;
if (!success) {
throw new Error(error || 'Could not connect to the MCP server');
}
if (mcp) {
setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);
}
resetForm();
onSuccess();
})
.finally(() => setIsLoading(false)),
{
loading: 'Adding MCP...',
success: () => 'MCP added!',
error: (err) => <span>{err.message}</span>
}
);
} else if (serverType === 'sse') {
toast.promise(
(apiClient as any)
.connectSseMCP(sessionId, serverName, serverUrl, headersObj)
.then(async (resp: any) => {
const { success, mcp, error } = resp;
if (!success) {
throw new Error(error || 'Could not connect to the MCP server');
}
if (mcp) {
setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);
}
resetForm();
onSuccess();
})
.finally(() => setIsLoading(false)),
{
loading: 'Adding MCP...',
success: () => 'MCP added!',
error: (err) => <span>{err.message}</span>
}
);
} else if (serverType === 'streamable-http') {
toast.promise(
(apiClient as any)
.connectStreamableHttpMCP(sessionId, serverName, httpUrl, headersObj)
.then(async (resp: any) => {
const { success, mcp, error } = resp;
if (!success) {
throw new Error(error || 'Could not connect to the MCP server');
}
if (mcp) {
setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);
}
resetForm();
onSuccess();
})
.finally(() => setIsLoading(false)),
{
loading: 'Adding MCP...',
success: () => 'MCP added!',
error: (err) => <span>{err.message}</span>
}
);
}
};
return (
<>
<div className="flex flex-col gap-4">
<div className="flex gap-2 w-full">
<div className="flex flex-col flex-grow gap-2">
<Label htmlFor="server-name" className="text-foreground/70 text-sm">
Name *
</Label>
<Input
id="server-name"
placeholder="Example: Stripe"
className="w-full bg-background text-foreground border-input"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="server-type" className="text-foreground/70 text-sm">
Type *
</Label>
<Select
value={serverType}
onValueChange={setServerType as any}
disabled={isLoading}
>
<SelectTrigger
id="server-type"
className="w-full bg-background text-foreground border-input"
>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{allowSse ? <SelectItem value="sse">sse</SelectItem> : null}
{allowStdio ? (
<SelectItem value="stdio">stdio</SelectItem>
) : null}
{allowHttp ? (
<SelectItem value="streamable-http">
streamable-http
</SelectItem>
) : null}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-2">
{serverType === 'stdio' && (
<>
<Label
htmlFor="server-command"
className="text-foreground/70 text-sm"
>
Command *
</Label>
<Input
id="server-command"
placeholder="Example: npx -y @stripe/mcp --tools=all --api-key=YOUR_STRIPE_SECRET_KEY"
className="w-full bg-background text-foreground border-input"
value={serverCommand}
onChange={(e) => setServerCommand(e.target.value)}
required
disabled={isLoading}
/>
</>
)}
{serverType === 'sse' && (
<>
<Label
htmlFor="server-url"
className="text-foreground/70 text-sm"
>
Server URL *
</Label>
<Input
id="server-url"
placeholder="Example: http://localhost:5000"
className="w-full bg-background text-foreground border-input"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
required
disabled={isLoading}
/>
</>
)}
{serverType === 'streamable-http' && (
<>
<Label htmlFor="http-url" className="text-foreground/70 text-sm">
HTTP URL *
</Label>
<Input
id="http-url"
placeholder="Example: http://localhost:8000/mcp"
className="w-full bg-background text-foreground border-input"
value={httpUrl}
onChange={(e) => setHttpUrl(e.target.value)}
required
disabled={isLoading}
/>
</>
)}
{(serverType === 'sse' || serverType === 'streamable-http') && (
<>
<Label htmlFor="headers" className="text-foreground/70 text-sm">
Headers (JSON, optional)
</Label>
<Input
id="headers"
placeholder='Example: {"Authorization": "Bearer TOKEN"}'
className="w-full bg-background text-foreground border-input font-mono"
value={headersInput}
onChange={(e) => setHeadersInput(e.target.value)}
disabled={isLoading}
/>
</>
)}
</div>
</div>
<div className="flex justify-end items-center gap-2 mt-auto">
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
<Translator path="common.actions.cancel" />
</Button>
<Button
variant="default"
onClick={addMcp}
disabled={!isFormValid() || isLoading}
>
<Translator path="common.actions.confirm" />
</Button>
</div>
</>
);
};
@@ -0,0 +1,48 @@
import { Plug } from 'lucide-react';
import { useEffect, useRef } from 'react';
interface AnimatedPlugIconProps {
duration?: number;
strokeWidth?: number;
className?: string;
}
const AnimatedPlugIcon: React.FC<AnimatedPlugIconProps> = ({
duration = 1500,
strokeWidth = 2,
className = ''
}) => {
const iconRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (iconRef.current) {
// Get all SVG paths inside the icon
const paths = iconRef.current.querySelectorAll('path');
paths.forEach((path: SVGPathElement) => {
// Get the total length of the path
const length = path.getTotalLength();
// Set up the starting position
path.style.strokeDasharray = `${length}`;
path.style.strokeDashoffset = `${length}`;
// Create the animation
path.animate([{ strokeDashoffset: length }, { strokeDashoffset: 0 }], {
duration: duration,
easing: 'ease-in-out',
iterations: Infinity,
direction: 'alternate'
});
});
}
}, [duration]);
return (
<div ref={iconRef}>
<Plug className={className} strokeWidth={strokeWidth} />
</div>
);
};
export default AnimatedPlugIcon;
@@ -0,0 +1,303 @@
import { cn } from '@/lib/utils';
import { Link, RefreshCw, SquareTerminal, Trash2, Wrench } from 'lucide-react';
import { useContext, useState } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { toast } from 'sonner';
import {
ChainlitContext,
IMcp,
mcpState,
sessionIdState
} from '@chainlit/react-client';
import CopyButton from '@/components/CopyButton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Translator } from 'components/i18n';
interface McpListProps {
onAddNewClick: () => void;
}
export const McpList = ({ onAddNewClick }: McpListProps) => {
const apiClient = useContext(ChainlitContext);
const sessionId = useRecoilValue(sessionIdState);
const [mcps, setMcps] = useRecoilState(mcpState);
const [isLoading, setIsLoading] = useState(false);
const deleteMcp = (mcp: IMcp) => {
if (mcp.status === 'connected') {
setIsLoading(true);
toast.promise(
apiClient
.disconnectMcp(sessionId, mcp.name)
.then(() => {})
.finally(() => setIsLoading(false)),
{
loading: 'Removing MCP...',
success: () => 'MCP removed!',
error: (err) => <span>{err.message}</span>
}
);
}
setMcps((prev) => prev.filter((_mcp) => _mcp.name !== mcp.name));
};
if (!mcps || mcps.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p>No MCP servers connected</p>
<Button variant="outline" className="mt-4" onClick={onAddNewClick}>
Add your first MCP server
</Button>
</div>
);
}
return (
<>
{mcps.map((mcp, index) => (
<McpItem
key={index}
mcp={mcp}
onDelete={deleteMcp}
isLoading={isLoading}
/>
))}
</>
);
};
interface McpItemProps {
mcp: IMcp;
onDelete: (mcp: IMcp) => void;
isLoading: boolean;
}
const McpItem = ({ mcp, onDelete, isLoading }: McpItemProps) => {
return (
<div className="border rounded-lg p-4 flex flex-col gap-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div
className={cn(
'h-2 w-2 rounded-full',
mcp.status === 'connected' && 'bg-green-500',
mcp.status === 'connecting' && 'bg-yellow-500',
mcp.status === 'failed' && 'bg-red-500'
)}
/>
<h3 className="font-medium">{mcp.name}</h3>
<Badge variant="outline">{mcp.clientType}</Badge>
</div>
<div className="flex items-center">
<ReconnectMcpButton mcp={mcp} />
<DeleteMcpButton mcp={mcp} onDelete={onDelete} disabled={isLoading} />
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="font-medium text-sm text-muted-foreground flex items-center">
{mcp.clientType === 'stdio' ? (
<SquareTerminal className="h-4 w-4 mr-2" />
) : mcp.clientType === 'streamable-http' ? (
<Link className="h-4 w-4 mr-2 text-blue-500" />
) : (
<Link className="h-4 w-4 mr-2" />
)}
{mcp.clientType === 'stdio'
? 'Command'
: mcp.clientType === 'streamable-http'
? 'HTTP URL'
: 'URL'}
</div>
<div className="flex items-center w-full bg-accent px-3 py-1 rounded gap-2">
<pre className="text-sm font-mono flex-grow truncate">
{mcp.command || mcp.url || 'N/A'}
</pre>
<CopyButton content={mcp.command || mcp.url} />
</div>
</div>
<div className="font-medium text-sm text-muted-foreground flex items-center">
<Wrench className="h-4 w-4 mr-2" />
Tools
</div>
<div className="flex flex-wrap gap-2">
{mcp.tools &&
mcp.tools.map((tool, toolIndex) => (
<Badge key={toolIndex} variant="secondary">
{tool.name}
</Badge>
))}
</div>
</div>
);
};
interface DeleteMcpButtonProps {
mcp: IMcp;
onDelete: (mcp: IMcp) => void;
disabled: boolean;
}
const DeleteMcpButton = ({ mcp, onDelete, disabled }: DeleteMcpButtonProps) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive"
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will disconnect the MCP server "{mcp.name}". This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Translator path="common.actions.cancel" />
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => onDelete(mcp)}
>
<Translator path="common.actions.confirm" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const ReconnectMcpButton = ({ mcp }: { mcp: IMcp }) => {
const apiClient = useContext(ChainlitContext);
const setMcps = useSetRecoilState(mcpState);
const sessionId = useRecoilValue(sessionIdState);
const [isLoading, setIsLoading] = useState(false);
const reconnectMcp = () => {
setIsLoading(true);
setMcps((prev) =>
prev.map((existingMcp) => {
if (existingMcp.name === mcp.name) {
return {
...existingMcp,
status: 'connecting'
};
}
return existingMcp;
})
);
const updateMcpStatus = (success: boolean, updatedMcp?: any) => {
setMcps((prev) =>
prev.map((existingMcp) => {
if (existingMcp.name === mcp.name) {
return {
...existingMcp,
status: success ? 'connected' : 'failed',
tools: updatedMcp ? updatedMcp.tools : existingMcp.tools
};
}
return existingMcp;
})
);
};
if (mcp.clientType === 'stdio') {
toast.promise(
apiClient
.connectStdioMCP(sessionId, mcp.name, mcp.command!)
.then(async (resp: any) => {
const { success, mcp: updatedMcp } = resp;
updateMcpStatus(success, updatedMcp);
})
.catch(() => {
updateMcpStatus(false);
})
.finally(() => setIsLoading(false)),
{
loading: 'Reconnecting MCP...',
success: () => 'MCP reconnected!',
error: (err) => <span>{err.message}</span>
}
);
} else if (mcp.clientType === 'streamable-http') {
toast.promise(
(apiClient as any)
.connectStreamableHttpMCP(
sessionId,
mcp.name,
mcp.url!,
(mcp as any).headers
)
.then(async (resp: any) => {
const { success, mcp: updatedMcp } = resp;
updateMcpStatus(success, updatedMcp);
})
.catch(() => {
updateMcpStatus(false);
})
.finally(() => setIsLoading(false)),
{
loading: 'Reconnecting MCP...',
success: () => 'MCP reconnected!',
error: (err) => <span>{err.message}</span>
}
);
} else {
toast.promise(
(apiClient as any)
.connectSseMCP(sessionId, mcp.name, mcp.url!, (mcp as any).headers)
.then(async (resp: any) => {
const { success, mcp: updatedMcp } = resp;
updateMcpStatus(success, updatedMcp);
})
.catch(() => {
updateMcpStatus(false);
})
.finally(() => setIsLoading(false)),
{
loading: 'Reconnecting MCP...',
success: () => 'MCP reconnected!',
error: (err) => <span>{err.message}</span>
}
);
}
};
return (
<Button
variant="ghost"
size="icon"
disabled={isLoading}
onClick={reconnectMcp}
>
<RefreshCw className="h-4 w-4" />
</Button>
);
};
@@ -0,0 +1,114 @@
import { Plug } from 'lucide-react';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { mcpState, useConfig } from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { McpAddForm } from './AddForm';
import AnimatedPlugIcon from './AnimatedPlugIcon';
import { McpList } from './List';
interface Props {
disabled?: boolean;
}
const McpButton = ({ disabled }: Props) => {
const { config } = useConfig();
const [mcps] = useRecoilState(mcpState);
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState('add');
const allowSse = !!config?.features.mcp?.sse?.enabled;
const allowStdio = !!config?.features.mcp?.stdio?.enabled;
const allowHttp = !!config?.features.mcp?.streamable_http?.enabled;
const allowMcp = !!config?.features.mcp?.enabled;
if (!allowMcp || (!allowSse && !allowStdio && !allowHttp)) return null;
const connectedMcps = mcps.filter((mcp) => mcp.status === 'connected');
const mcpLoading = mcps.find((mcp) => mcp.status === 'connecting');
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={disabled}
variant="ghost"
size="icon"
className="hover:bg-muted relative"
>
{mcpLoading ? (
<AnimatedPlugIcon className="!size-5" />
) : (
<Plug className="!size-5" />
)}
{connectedMcps.length > 0 && (
<span className="absolute top-0.5 right-0.5 bg-primary text-primary-foreground text-[8px] font-medium rounded-full w-3 h-3 flex items-center justify-center">
{connectedMcps.length}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>MCP Servers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DialogTrigger>
<DialogContent
id="mcp-servers"
className="min-w-[50vw] max-h-[85vh] flex flex-col gap-6 bg-background overflow-y-auto"
>
<DialogHeader>
<DialogTitle>MCP Servers</DialogTitle>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 mb-4">
<TabsTrigger value="add">Connect an MCP</TabsTrigger>
<TabsTrigger value="list">My MCPs</TabsTrigger>
</TabsList>
<TabsContent
value="add"
className="flex flex-col flex-grow gap-6 p-1"
>
<McpAddForm
allowSse={allowSse}
allowStdio={allowStdio}
allowHttp={allowHttp}
onSuccess={() => setActiveTab('list')}
onCancel={() => setOpen(false)}
/>
</TabsContent>
<TabsContent value="list" className="flex flex-col gap-4">
<McpList onAddNewClick={() => setActiveTab('add')} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
export default McpButton;
@@ -0,0 +1,214 @@
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@radix-ui/react-popover';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useContext, useRef, useState } from 'react';
import { ChainlitContext, IMode, IModeOption } from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Button } from '@/components/ui/button';
import {
Command,
CommandGroup,
CommandItemAnimated,
CommandListScrollable
} from '@/components/ui/command';
interface Props {
mode: IMode;
disabled?: boolean;
selectedOptionId?: string;
onOptionSelect: (modeId: string, optionId: string) => void;
}
/**
* ModePicker displays a single mode category and allows selection from its options.
* Multiple ModePicker instances can be rendered for different mode categories.
*/
export const ModePicker = ({
mode,
disabled = false,
selectedOptionId,
onOptionSelect
}: Props) => {
const apiClient = useContext(ChainlitContext);
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const options = mode.options;
const selectedOption =
options.find((opt) => opt.id === selectedOptionId) || options[0];
// Handle option selection
const handleOptionSelect = (option: IModeOption) => {
onOptionSelect(mode.id, option.id);
setOpen(false);
};
// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % options.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(
(prev) => (prev - 1 + options.length) % options.length
);
break;
case 'Enter':
e.preventDefault();
if (options[selectedIndex]) {
handleOptionSelect(options[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setOpen(false);
break;
}
};
const handleMouseMove = (index: number) => {
setSelectedIndex(index);
};
const handleMouseLeave = () => {
// Keep current selection on mouse leave
};
// Helper to render icon - supports Lucide names, local paths, and URLs
const renderIcon = (icon: string | undefined, className: string) => {
if (!icon) return null;
// Local public file path
if (icon.startsWith('/public')) {
return (
<img
className={cn('rounded-md', className)}
src={apiClient.buildEndpoint(icon)}
alt="Mode option icon"
/>
);
}
// Remote URL
if (icon.startsWith('http://') || icon.startsWith('https://')) {
return (
<img
className={cn('rounded-md', className)}
src={icon}
alt="Mode option icon"
/>
);
}
// Lucide icon name
return <Icon name={icon} className={className} />;
};
if (!options.length) return null;
const Chevron = open ? ChevronUp : ChevronDown;
return (
<div
className="mode-picker-wrapper inline-flex items-center"
ref={popoverRef}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={`mode-picker-trigger-${mode.id}`}
variant="ghost"
size="sm"
disabled={disabled}
className={cn(
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md',
'text-xs font-medium',
'hover:bg-muted transition-colors',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
open && 'bg-muted'
)}
onKeyDown={handleKeyDown}
>
{renderIcon(selectedOption?.icon, '!size-4')}
<span className="max-w-[120px] truncate">
{selectedOption?.name || mode.name}
</span>
<Chevron className="!size-3.5 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
id={`mode-picker-popover-${mode.id}`}
align="start"
side="top"
sideOffset={4}
className={cn(
'p-1 rounded-md border shadow-lg bg-popover',
'animate-in fade-in-0 zoom-in-95 duration-150',
'w-[280px]'
)}
onKeyDown={handleKeyDown}
onMouseLeave={handleMouseLeave}
>
<Command className="overflow-hidden bg-transparent">
<CommandListScrollable maxItems={6} className="custom-scrollbar">
<CommandGroup className="p-0">
{options.map((option, index) => (
<CommandItemAnimated
key={option.id}
index={index}
isSelected={index === selectedIndex}
onMouseMove={() => handleMouseMove(index)}
onSelect={() => handleOptionSelect(option)}
className={cn(
'flex items-start gap-2 px-2 py-2 cursor-pointer',
selectedOptionId === option.id && 'bg-accent'
)}
>
{renderIcon(
option.icon,
cn(
'!size-5 mt-0.5 text-muted-foreground flex-shrink-0',
index === selectedIndex && 'text-foreground'
)
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm leading-tight">
{option.name}
</div>
{option.description && (
<div className="text-xs text-muted-foreground mt-0.5 leading-tight">
{option.description}
</div>
)}
</div>
</CommandItemAnimated>
))}
</CommandGroup>
</CommandListScrollable>
</Command>
</PopoverContent>
</Popover>
</div>
);
};
export default ModePicker;
@@ -0,0 +1,73 @@
import {
useChatData,
useChatInteract,
useChatMessages
} from '@chainlit/react-client';
import { Send } from '@/components/icons/Send';
import { Stop } from '@/components/icons/Stop';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { Translator } from 'components/i18n';
interface SubmitButtonProps {
disabled?: boolean;
onSubmit: () => void;
}
export default function SubmitButton({
disabled,
onSubmit
}: SubmitButtonProps) {
const { loading } = useChatData();
const { firstInteraction } = useChatMessages();
const { stopTask } = useChatInteract();
return (
<TooltipProvider>
{loading && firstInteraction ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
id="stop-button"
onClick={stopTask}
size="icon"
className="rounded-full h-8 w-8"
>
<Stop className="!size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Translator path="chat.input.actions.stop" />
</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
id="chat-submit"
disabled={disabled}
onClick={onSubmit}
size="icon"
className="rounded-full h-8 w-8"
>
<Send className="!size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Translator path="chat.input.actions.send" />
</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
);
}
@@ -0,0 +1,73 @@
import { FileSpec, useConfig } from '@chainlit/react-client';
import { Translator } from '@/components/i18n';
import { PaperClip } from '@/components/icons/PaperClip';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { useUpload } from '@/hooks/useUpload';
interface UploadButtonProps {
disabled?: boolean;
fileSpec: FileSpec;
onFileUpload: (files: File[]) => void;
onFileUploadError: (error: string) => void;
}
export const UploadButton = ({
disabled = false,
fileSpec,
onFileUpload,
onFileUploadError
}: UploadButtonProps) => {
const { config } = useConfig();
const upload = useUpload({
spec: fileSpec,
onResolved: (payloads: File[]) => onFileUpload(payloads),
onError: onFileUploadError,
options: { noDrag: true }
});
if (!upload) return null;
const { getRootProps, getInputProps } = upload;
if (!config?.features.spontaneous_file_upload?.enabled) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<input
id="upload-button-input"
className="hidden"
{...getInputProps()}
/>
<Button
id={disabled ? 'upload-button-loading' : 'upload-button'}
variant="ghost"
size="icon"
className="hover:bg-muted"
disabled={disabled}
{...getRootProps()}
>
<PaperClip className="!size-6" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
<Translator path="chat.input.actions.attachFiles" />
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default UploadButton;
@@ -0,0 +1,125 @@
import { X } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAudio, useConfig } from '@chainlit/react-client';
import AudioPresence from '@/components/AudioPresence';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { Translator } from 'components/i18n';
import { Loader } from '../../Loader';
import { VoiceLines } from '../../icons/VoiceLines';
import { Button } from '../../ui/button';
interface Props {
disabled?: boolean;
}
const VoiceButton = ({ disabled }: Props) => {
const { config } = useConfig();
const { startConversation, endConversation, audioConnection } = useAudio();
const isEnabled = !!config?.features.audio.enabled;
useHotkeys(
'p',
() => {
if (!isEnabled) return;
// Double-check at execution time that we're not in a form field
const getDeepActiveElement = (): Element | null => {
let activeElement = document.activeElement;
while (
activeElement &&
activeElement.shadowRoot &&
activeElement.shadowRoot.activeElement
) {
activeElement = activeElement.shadowRoot.activeElement;
}
return activeElement;
};
const activeElement = getDeepActiveElement();
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
const isFormField = ['input', 'textarea', 'select'].includes(tagName);
const isContentEditable =
activeElement.getAttribute('contenteditable') === 'true';
if (isFormField || isContentEditable) {
return; // Don't execute the hotkey
}
}
if (audioConnection === 'on') return endConversation();
return startConversation();
},
{
enableOnFormTags: false,
preventDefault: false // Don't prevent default - let letters be typed
},
[isEnabled, audioConnection, startConversation, endConversation]
);
if (!isEnabled) return null;
return (
<div className="flex items-center gap-1">
{audioConnection === 'on' ? (
<AudioPresence
type="client"
height={18}
width={36}
barCount={4}
barSpacing={2}
/>
) : null}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={disabled}
variant="ghost"
size="icon"
className="hover:bg-muted"
onClick={
audioConnection === 'on'
? endConversation
: audioConnection === 'off'
? startConversation
: undefined
}
>
{audioConnection === 'on' ? <X className="!size-5" /> : null}
{audioConnection === 'off' ? (
<VoiceLines className="!size-6" />
) : null}
{audioConnection === 'connecting' ? (
<Loader className="!size-5" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Translator
path={
audioConnection === 'on'
? 'chat.speech.stop'
: audioConnection === 'off'
? 'chat.speech.start'
: 'chat.speech.connecting'
}
suffix=" (P)"
/>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
export default VoiceButton;
@@ -0,0 +1,348 @@
import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState
} from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import {
FileSpec,
IStep,
commandsState,
useAuth,
useChatData,
useChatInteract,
useConfig
} from '@chainlit/react-client';
import type { IMode, IModeOption } from '@chainlit/react-client';
import { modesState } from '@chainlit/react-client';
import { Settings } from '@/components/icons/Settings';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { useTranslation } from 'components/i18n/Translator';
import { useQuery } from '@/hooks/query';
import { useIsMobile } from '@/hooks/use-mobile';
import { chatSettingsOpenState } from '@/state/project';
import {
IAttachment,
attachmentsState,
persistentCommandState
} from 'state/chat';
import { Attachments } from './Attachments';
import CommandButtons from './CommandButtons';
import CommandButton from './CommandPopoverButton';
import FavoriteButton from './FavoriteButton';
import Input, { InputMethods } from './Input';
import McpButton from './Mcp';
import ModePicker from './ModePicker';
import SubmitButton from './SubmitButton';
import UploadButton from './UploadButton';
import VoiceButton from './VoiceButton';
interface Props {
fileSpec: FileSpec;
onFileUpload: (payload: File[]) => void;
onFileUploadError: (error: string) => void;
autoScrollRef: MutableRefObject<boolean>;
}
export default function MessageComposer({
fileSpec,
onFileUpload,
onFileUploadError,
autoScrollRef
}: Props) {
const inputRef = useRef<InputMethods>(null);
const [value, setValue] = useState('');
const [selectedCommand, setSelectedCommand] = useRecoilState(
persistentCommandState
);
const commands = useRecoilValue(commandsState);
const setChatSettingsOpen = useSetRecoilState(chatSettingsOpenState);
// Pre-select the command marked as selected by the backend
useEffect(() => {
const defaultSelected = commands.find((c) => c.selected);
if (defaultSelected && !selectedCommand) {
setSelectedCommand(defaultSelected);
}
}, [commands]);
const [attachments, setAttachments] = useRecoilState(attachmentsState);
const { t } = useTranslation();
const { user } = useAuth();
const { sendMessage, replyMessage } = useChatInteract();
const { askUser, chatSettingsInputs, disabled: _disabled } = useChatData();
const disabled = _disabled || !!attachments.find((a) => !a.uploaded);
const { config } = useConfig();
const showSettingsInComposer =
config?.ui?.chat_settings_location !== 'sidebar' &&
chatSettingsInputs.length > 0;
const isMobile = useIsMobile();
// Get/set available modes from state - selections are tracked via the 'default' flag on options
const [modes, setModes] = useRecoilState(modesState);
const handleModeSelect = useCallback(
(modeId: string, optionId: string) => {
setModes((prevModes) =>
prevModes.map((mode) => {
if (mode.id !== modeId) return mode;
return {
...mode,
options: mode.options.map((opt: IModeOption) => ({
...opt,
default: opt.id === optionId
}))
};
})
);
},
[setModes]
);
// Helper to get selected option for a mode (the one with default=true, or first option)
const getSelectedOptionId = useCallback((mode: IMode): string | undefined => {
const defaultOpt = mode.options.find((opt) => opt.default);
return defaultOpt?.id || mode.options[0]?.id;
}, []);
let promptValue = '';
try {
const query = useQuery();
promptValue = query.get('prompt') || '';
} catch {
console.warn('Could not parse query parameters');
}
const [promptUsed, setPromptUsed] = useState(false);
const onFavoriteSelect = useCallback((content: string) => {
setValue(content);
if (inputRef.current) {
inputRef.current.setValueExtern(content);
}
}, []);
const onPaste = useCallback(
(event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.items) {
const items = Array.from(event.clipboardData.items);
// If no text data, check for files (e.g., images)
items.forEach((item) => {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
onFileUpload([file]);
}
}
});
}
},
[onFileUpload]
);
const onSubmit = useCallback(
async (
msg: string,
attachments?: IAttachment[],
selectedCommand?: string
) => {
// Build modes dict: only include modes that have selections
const modesDict: Record<string, string> = {};
modes.forEach((mode) => {
const selectedId = getSelectedOptionId(mode);
if (selectedId) {
modesDict[mode.id] = selectedId;
}
});
const message: IStep = {
threadId: '',
command: selectedCommand,
modes: Object.keys(modesDict).length > 0 ? modesDict : undefined,
id: uuidv4(),
name: user?.identifier || 'User',
type: 'user_message',
output: msg,
createdAt: new Date().toISOString(),
metadata: { location: window.location.href }
};
const fileReferences = attachments
?.filter((a) => !!a.serverId)
.map((a) => ({ id: a.serverId! }));
if (autoScrollRef) {
autoScrollRef.current = true;
}
sendMessage(message, fileReferences);
},
[user, sendMessage, autoScrollRef, modes, getSelectedOptionId]
);
const onReply = useCallback(
async (msg: string) => {
const message: IStep = {
threadId: '',
id: uuidv4(),
name: user?.identifier || 'User',
type: 'user_message',
output: msg,
createdAt: new Date().toISOString(),
metadata: { location: window.location.href }
};
replyMessage(message);
if (autoScrollRef) {
autoScrollRef.current = true;
}
},
[user, replyMessage, autoScrollRef]
);
const submit = useCallback(() => {
if (
disabled ||
(value.trim() === '' && attachments.length === 0 && !selectedCommand)
) {
return;
}
if (askUser) {
onReply(value);
} else {
onSubmit(value, attachments, selectedCommand?.id);
}
setAttachments([]);
setValue(''); // Clear the value state
inputRef.current?.reset();
}, [
value,
disabled,
askUser,
attachments,
selectedCommand,
setAttachments,
onSubmit,
onReply
]);
useEffect(() => {
if (inputRef.current && promptValue && !promptUsed) {
const prompt = promptValue;
if (prompt) {
if (prompt.length > 1000) {
inputRef.current?.setValueExtern(prompt.slice(0, 1000));
} else {
inputRef.current?.setValueExtern(prompt);
}
setPromptUsed(true);
}
}
}, [promptValue, promptUsed]);
return (
<div
id="message-composer"
className="bg-accent dark:bg-card rounded-3xl p-3 px-4 w-full min-h-24 flex flex-col"
>
{attachments.length > 0 ? (
<div className="mb-1">
<Attachments />
</div>
) : null}
<Input
ref={inputRef}
id="chat-input"
autoFocus={!isMobile}
selectedCommand={selectedCommand}
setSelectedCommand={setSelectedCommand}
onChange={setValue}
onPaste={onPaste}
onEnter={submit}
placeholder={t('chat.input.placeholder')}
/>
<div className="flex items-center justify-between">
<div className="flex items-center -ml-1.5">
<VoiceButton disabled={disabled} />
<UploadButton
disabled={disabled}
fileSpec={fileSpec}
onFileUploadError={onFileUploadError}
onFileUpload={onFileUpload}
/>
{showSettingsInComposer && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
id="chat-settings-open-modal"
disabled={disabled}
onClick={() => setChatSettingsOpen(true)}
className="hover:bg-muted rounded-full"
variant="ghost"
size="icon"
>
<Settings className="!size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('navigation.user.menu.settings')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<McpButton disabled={disabled} />
{modes.map((mode) => (
<ModePicker
key={mode.id}
mode={mode}
disabled={disabled}
selectedOptionId={getSelectedOptionId(mode)}
onOptionSelect={handleModeSelect}
/>
))}
<CommandButton
disabled={disabled}
selectedCommandId={selectedCommand?.id}
onCommandSelect={setSelectedCommand}
/>
<CommandButtons
disabled={disabled}
selectedCommandId={selectedCommand?.id}
onCommandSelect={setSelectedCommand}
/>
<FavoriteButton disabled={disabled} onSelect={onFavoriteSelect} />
</div>
<div className="flex items-center gap-1">
<SubmitButton
onSubmit={submit}
disabled={
disabled ||
(!value.trim() && !selectedCommand && attachments.length === 0)
}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { MessageContext } from 'contexts/MessageContext';
import { useContext, useMemo } from 'react';
import { type IAction } from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
const AskActionButton = ({ action }: { action: IAction }) => {
const { loading, askUser } = useContext(MessageContext);
const content = useMemo(() => {
return action.icon
? action.label
: action.label
? action.label
: action.name;
}, [action]);
const icon = useMemo(() => {
if (action.icon) return <Icon name={action.icon as any} />;
return null;
}, [action]);
const button = (
<Button
className="break-words h-auto min-h-10 whitespace-normal"
id={action.id}
onClick={() => {
askUser?.callback(action);
}}
variant="outline"
disabled={loading}
>
{icon}
{content}
</Button>
);
if (action.tooltip) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{action.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
} else {
return button;
}
};
const AskActionButtons = ({
messageId,
actions
}: {
messageId: string;
actions: IAction[];
}) => {
const { askUser } = useContext(MessageContext);
const belongsToMessage = askUser?.spec.step_id === messageId;
const isAskingAction = askUser?.spec.type === 'action';
const filteredActions = actions.filter((a) => {
return a.forId === messageId && askUser?.spec.keys?.includes(a.id);
});
if (!belongsToMessage || !isAskingAction || !actions.length) return null;
return (
<div className="flex items-center gap-1 flex-wrap">
{filteredActions.map((a) => (
<AskActionButton key={a.id} action={a} />
))}
</div>
);
};
export { AskActionButtons };
@@ -0,0 +1,202 @@
import { MessageContext } from 'contexts/MessageContext';
import { Upload } from 'lucide-react';
import { useContext, useState } from 'react';
import { IAsk, IFileRef } from '@chainlit/react-client';
import { Translator } from '@/components/i18n';
import { useTranslation } from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useUpload } from 'hooks/useUpload';
interface UploadState {
progress: number;
uploaded: boolean;
cancel: () => void;
fileRef?: IFileRef;
}
interface _AskFileButtonProps {
askUser: IAsk;
parentId?: string;
uploadFile: (
file: File,
onProgress: (progress: number) => void,
parentId?: string
) => {
xhr: XMLHttpRequest;
promise: Promise<IFileRef>;
};
onError: (error: string) => void;
}
const CircularProgress = ({ value }: { value: number }) => {
const size = 24;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (value / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg
className="absolute"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
className="text-muted-foreground/20"
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
stroke="currentColor"
/>
<circle
className="text-primary transition-all duration-300 ease-in-out"
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
stroke="currentColor"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
</div>
);
};
const _AskFileButton = ({
askUser,
uploadFile,
onError
}: _AskFileButtonProps) => {
const { t } = useTranslation();
const [uploads, setUploads] = useState<UploadState[]>([]);
const uploading = uploads.some((upload) => !upload.uploaded);
const progress = uploads.reduce(
(acc, upload) => acc + upload.progress / uploads.length,
0
);
const onResolved = (files: File[]) => {
if (uploading) return;
const promises: Promise<IFileRef>[] = [];
const newUploads = files.map((file, index) => {
const { xhr, promise } = uploadFile(
file,
(progress) => {
setUploads((prev) =>
prev.map((upload, i) => {
if (i === index) {
return { ...upload, progress };
}
return upload;
})
);
},
askUser?.parentId
);
promises.push(promise);
return { progress: 0, uploaded: false, cancel: () => xhr.abort() };
});
Promise.all(promises)
.then((fileRefs) => askUser.callback(fileRefs))
.catch((error) => {
onError(
`${t('chat.fileUpload.errors.failed')}: ${
typeof error === 'object' && error !== null
? (error.message ?? error)
: error
}`
);
setUploads((prev) => {
prev.forEach((u) => u.cancel());
return [];
});
});
setUploads(newUploads);
};
const upload = useUpload({
spec: askUser.spec,
onResolved: onResolved,
onError: (error: string) => onError(error)
});
if (!upload) return null;
const { getRootProps, getInputProps } = upload;
return (
<Card className="w-full mt-2">
<div
{...getRootProps({ className: 'dropzone' })}
className="flex items-center p-4"
>
<input id="ask-button-input" {...getInputProps()} />
<div className="flex flex-col">
<p className="text-sm font-medium">
<Translator path="chat.fileUpload.dragDrop" />
</p>
<p className="text-sm text-muted-foreground">
<Translator path="chat.fileUpload.sizeLimit" />{' '}
{askUser.spec.max_size_mb}mb
</p>
</div>
<Button
id={uploading ? 'ask-upload-button-loading' : 'ask-upload-button'}
disabled={uploading}
className="ml-auto"
variant={uploading ? 'ghost' : 'default'}
>
{uploading ? (
<CircularProgress value={progress} />
) : (
<>
<Upload className="w-4 h-4 mr-2" />
<Translator path="chat.fileUpload.browse" />
</>
)}
</Button>
</div>
</Card>
);
};
interface AskFileButtonProps {
messageId: string;
onError: (error: string) => void;
}
const AskFileButton = ({ messageId, onError }: AskFileButtonProps) => {
const messageContext = useContext(MessageContext);
const belongsToMessage = messageContext.askUser?.spec.step_id === messageId;
const isAskFile = messageContext.askUser?.spec.type === 'file';
if (!belongsToMessage || !isAskFile || !messageContext?.uploadFile)
return null;
return (
<_AskFileButton
onError={onError}
uploadFile={messageContext.uploadFile}
askUser={messageContext.askUser!}
/>
);
};
export { AskFileButton };
@@ -0,0 +1,95 @@
import { cn } from '@/lib/utils';
import { AlertCircle } from 'lucide-react';
import { useContext, useMemo } from 'react';
import {
ChainlitContext,
useChatSession,
useConfig
} from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface Props {
author?: string;
hide?: boolean;
isError?: boolean;
iconName?: string;
}
const MessageAvatar = ({ author, hide, isError, iconName }: Props) => {
const apiClient = useContext(ChainlitContext);
const { chatProfile } = useChatSession();
const { config } = useConfig();
const selectedChatProfile = useMemo(() => {
return config?.chatProfiles.find((profile) => profile.name === chatProfile);
}, [config, chatProfile]);
const avatarUrl = useMemo(() => {
if (config?.ui?.default_avatar_file_url)
return config?.ui?.default_avatar_file_url;
const isAssistant = !author || author === config?.ui.name;
if (isAssistant && selectedChatProfile?.icon) {
return selectedChatProfile.icon;
}
return apiClient?.buildEndpoint(`/avatars/${author || 'default'}`);
}, [apiClient, selectedChatProfile, config, author]);
const avatarSize = config?.ui?.avatar_size;
const sizeStyle = avatarSize
? { width: `${avatarSize}px`, height: `${avatarSize}px` }
: undefined;
if (isError) {
return (
<span className={cn('inline-block', hide && 'invisible')}>
<AlertCircle className="h-5 w-5 fill-destructive mt-[5px] text-destructive-foreground" />
</span>
);
}
// Render icon or avatar based on iconName
const avatarContent = iconName ? (
<span className="inline-flex mt-[3px]">
<Icon name={iconName} size={avatarSize ?? 20} /> {/* 20 => h-5 w-5 */}
</span>
) : (
<Avatar
className={avatarSize ? 'mt-[3px]' : 'h-5 w-5 mt-[3px]'}
style={sizeStyle}
>
<AvatarImage
src={avatarUrl}
alt={`Avatar for ${author || 'default'}`}
className="bg-transparent"
/>
<AvatarFallback className="bg-transparent">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
</Avatar>
);
return (
<span className={cn('inline-block', hide && 'invisible')}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{avatarContent}</TooltipTrigger>
<TooltipContent>
<p>{author}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
};
export { MessageAvatar };
@@ -0,0 +1,92 @@
import { MessageContext } from 'contexts/MessageContext';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { toast } from 'sonner';
import {
ChainlitContext,
type IAction,
sessionIdState
} from '@chainlit/react-client';
import Icon from '@/components/Icon';
import { Loader } from '@/components/Loader';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface ActionProps {
action: IAction;
}
const ActionButton = ({ action }: ActionProps) => {
const { loading, askUser } = useContext(MessageContext);
const apiClient = useContext(ChainlitContext);
const sessionId = useRecoilValue(sessionIdState);
const [isRunning, setIsRunning] = useState(false);
const content = useMemo(() => {
return action.icon
? action.label
: action.label
? action.label
: action.name;
}, [action]);
const icon = useMemo(() => {
if (isRunning) return <Loader />;
if (action.icon) return <Icon name={action.icon as any} />;
return null;
}, [action, isRunning]);
const handleClick = useCallback(async () => {
try {
setIsRunning(true);
await apiClient.callAction(action, sessionId);
} catch (err) {
toast.error(String(err));
} finally {
setIsRunning(false);
}
}, [action, sessionId, apiClient]);
const isAskingAction = askUser?.spec.type === 'action';
const ignore = isAskingAction && askUser?.spec.keys?.includes(action.id);
if (ignore) return null;
const button = (
<Button
id={action.id}
onClick={handleClick}
size="sm"
variant="ghost"
className="text-muted-foreground"
disabled={loading || isRunning}
>
{icon}
{content}
</Button>
);
if (action.tooltip) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{action.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
} else {
return button;
}
};
export { ActionButton };
@@ -0,0 +1,17 @@
import { IAction } from '@chainlit/react-client';
import { ActionButton } from './ActionButton';
interface Props {
actions: IAction[];
}
export default function MessageActions({ actions }: Props) {
return (
<>
{actions.map((a) => (
<ActionButton action={a} key={a.id} />
))}
</>
);
}
@@ -0,0 +1,46 @@
import { BugIcon } from 'lucide-react';
import { IStep } from '@chainlit/react-client';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface DebugButtonProps {
debugUrl: string;
step: IStep;
}
const DebugButton = ({ step, debugUrl }: DebugButtonProps) => {
let stepId = step.id;
if (stepId.startsWith('wrap_')) {
stepId = stepId.replace('wrap_', '');
}
const href = debugUrl
.replace('[thread_id]', step.threadId ?? '')
.replace('[step_id]', stepId);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 p-0" asChild>
<a href={href} target="_blank" rel="noopener noreferrer">
<BugIcon />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Debug in Literal AI</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export { DebugButton };
@@ -0,0 +1,204 @@
import { MessageContext } from '@/contexts/MessageContext';
import { MessageCircle, ThumbsDown, ThumbsUp } from 'lucide-react';
import { useCallback, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
IStep,
firstUserInteraction,
useChatSession
} from '@chainlit/react-client';
import { useTranslation } from '@/components/i18n/Translator';
import Translator from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface FeedbackButtonsProps {
message: IStep;
}
export function FeedbackButtons({ message }: FeedbackButtonsProps) {
const { onFeedbackUpdated, onFeedbackDeleted, showFeedbackButtons } =
useContext(MessageContext);
const { t } = useTranslation();
const [feedback, setFeedback] = useState<number | undefined>(
message.feedback?.value
);
const [comment, setComment] = useState<string | undefined>(
message.feedback?.comment
);
const [showDialog, setShowDialog] = useState<number>();
const [commentInput, setCommentInput] = useState<string>();
const firstInteraction = useRecoilValue(firstUserInteraction);
const { idToResume } = useChatSession();
if (!showFeedbackButtons) {
return null;
}
const handleFeedbackChange = useCallback(
(newFeedback?: number, newComment?: string) => {
if (newFeedback === undefined) {
if (onFeedbackDeleted && message.feedback?.id) {
onFeedbackDeleted(
message,
() => {
setFeedback(undefined);
setComment(undefined);
},
message.feedback.id
);
}
} else if (onFeedbackUpdated) {
onFeedbackUpdated(
message,
() => {
setFeedback(newFeedback);
setComment(newComment);
},
{
...(message.feedback || {}),
forId: message.id,
threadId: message.threadId,
value: newFeedback,
comment: newComment
}
);
}
},
[message, onFeedbackDeleted, onFeedbackUpdated]
);
const handleFeedbackClick = useCallback(
(nextValue: number) => {
if (feedback === nextValue) {
handleFeedbackChange(undefined);
} else {
setShowDialog(nextValue);
}
},
[feedback, handleFeedbackChange]
);
const isDisabled = message.streaming || !(firstInteraction || idToResume);
return (
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isDisabled}
onClick={() => handleFeedbackClick(1)}
className={
feedback === 1
? 'text-green-600 positive-feedback-on'
: 'text-muted-foreground positive-feedback-off'
}
>
<ThumbsUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<Translator path="chat.messages.feedback.positive" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isDisabled}
onClick={() => handleFeedbackClick(0)}
className={
feedback === 0
? 'text-red-600 negative-feedback-on'
: 'text-muted-foreground negative-feedback-off'
}
>
<ThumbsDown />
</Button>
</TooltipTrigger>
<TooltipContent>
<Translator path="chat.messages.feedback.negative" />
</TooltipContent>
</Tooltip>
{comment && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isDisabled}
onClick={() => {
setShowDialog(feedback);
setCommentInput(comment);
}}
>
<MessageCircle />
</Button>
</TooltipTrigger>
<TooltipContent>
<Translator path="chat.messages.feedback.edit" />
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
<Dialog
open={showDialog !== undefined}
onOpenChange={() => setShowDialog(undefined)}
>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{showDialog === 0 ? <ThumbsDown /> : <ThumbsUp />}
<Translator path="chat.messages.feedback.dialog.title" />
</DialogTitle>
</DialogHeader>
<Textarea
value={commentInput}
onChange={(e) => setCommentInput(e.target.value || undefined)}
placeholder={t('chat.messages.feedback.dialog.yourFeedback')}
className="min-h-[100px]"
/>
<DialogFooter>
<Button
id="submit-feedback"
onClick={() => {
if (showDialog !== undefined) {
handleFeedbackChange(showDialog, commentInput);
}
setShowDialog(undefined);
setCommentInput(undefined);
}}
>
<Translator path="chat.messages.feedback.dialog.submit" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,57 @@
import {
IAction,
type IStep,
useChatMessages,
useConfig
} from '@chainlit/react-client';
import CopyButton from '@/components/CopyButton';
import MessageActions from './Actions';
import { DebugButton } from './DebugButton';
import { FeedbackButtons } from './FeedbackButtons';
interface Props {
message: IStep;
actions: IAction[];
run?: IStep;
contentRef?: React.RefObject<HTMLDivElement>;
}
const MessageButtons = ({ message, actions, run, contentRef }: Props) => {
const { config } = useConfig();
const { firstInteraction } = useChatMessages();
const isUser = message.type === 'user_message';
const isAsk = message.waitForAnswer;
const hasContent = !!message.output;
const showCopyButton = !!run && hasContent && !isUser && !isAsk;
const messageActions = actions.filter((a) => a.forId === message.id);
const showDebugButton =
!!config?.debugUrl && !!message.threadId && !!firstInteraction && !!run;
const show = showCopyButton || showDebugButton || messageActions?.length;
if (!show || message.streaming) {
return null;
}
return (
<div className="-ml-1.5 flex items-center flex-wrap">
{showCopyButton ? (
<CopyButton content={message.output} contentRef={contentRef} />
) : null}
{run ? <FeedbackButtons message={run} /> : null}
{messageActions.length ? (
<MessageActions actions={messageActions} />
) : null}
{showDebugButton ? (
<DebugButton debugUrl={config.debugUrl!} step={message} />
) : null}
</div>
);
};
export { MessageButtons };
@@ -0,0 +1,17 @@
import type { ICustomElement } from '@chainlit/react-client';
import CustomElement from '@/components/Elements/CustomElement';
interface Props {
items: ICustomElement[];
}
const InlinedCustomElementList = ({ items }: Props) => (
<div className="flex flex-col gap-2">
{items.map((customElement) => {
return <CustomElement key={customElement.id} element={customElement} />;
})}
</div>
);
export { InlinedCustomElementList };
@@ -0,0 +1,21 @@
import type { IAudioElement } from '@chainlit/react-client';
import { AudioElement } from '@/components/Elements/Audio';
interface InlinedAudioListProps {
items: IAudioElement[];
}
const InlinedAudioList = ({ items }: InlinedAudioListProps) => {
return (
<div className="flex flex-col space-y-4">
{items.map((audio, i) => (
<div key={i} className="pt-2">
<AudioElement element={audio} />
</div>
))}
</div>
);
};
export { InlinedAudioList };
@@ -0,0 +1,21 @@
import type { IDataframeElement } from '@chainlit/react-client';
import { LazyDataframe } from '@/components/Elements/LazyDataframe';
interface Props {
items: IDataframeElement[];
}
const InlinedDataframeList = ({ items }: Props) => (
<div className="flex gap-1">
{items.map((element, i) => {
return (
<div key={i} className="max-h-[450px] w-full">
<LazyDataframe element={element} />
</div>
);
})}
</div>
);
export { InlinedDataframeList };
@@ -0,0 +1,23 @@
import type { IFileElement } from '@chainlit/react-client';
import { FileElement } from '@/components/Elements/File';
interface Props {
items: IFileElement[];
}
const InlinedFileList = ({ items }: Props) => {
return (
<div className="flex items-center gap-2">
{items.map((file, i) => {
return (
<div key={i}>
<FileElement element={file} />
</div>
);
})}
</div>
);
};
export { InlinedFileList };
@@ -0,0 +1,17 @@
import { ImageElement } from '@/components/Elements/Image';
import { QuiltedGrid } from '@/components/QuiltedGrid';
import type { IImageElement } from 'client-types/';
interface Props {
items: IImageElement[];
}
const InlinedImageList = ({ items }: Props) => (
<QuiltedGrid
elements={items}
renderElement={(ctx) => <ImageElement element={ctx.element} />}
/>
);
export { InlinedImageList };
@@ -0,0 +1,21 @@
import type { IPdfElement } from '@chainlit/react-client';
import { PDFElement } from '@/components/Elements/PDF';
interface Props {
items: IPdfElement[];
}
const InlinedPDFList = ({ items }: Props) => (
<div className="flex flex-col gap-2">
{items.map((pdf, i) => {
return (
<div key={i}>
<PDFElement element={pdf} />
</div>
);
})}
</div>
);
export { InlinedPDFList };
@@ -0,0 +1,27 @@
import type { IPlotlyElement } from '@chainlit/react-client';
import { PlotlyElement } from '@/components/Elements/Plotly';
interface Props {
items: IPlotlyElement[];
}
const InlinedPlotlyList = ({ items }: Props) => (
<div className="flex flex-col gap-2">
{items.map((element, i) => {
return (
<div
key={i}
className="inline-plotly-container max-w-[600px]"
style={{
maxWidth: 'fit-content'
}}
>
<PlotlyElement element={element} />
</div>
);
})}
</div>
);
export { InlinedPlotlyList };
@@ -0,0 +1,27 @@
import type { ITextElement } from '@chainlit/react-client';
import { TextElement } from '@/components/Elements/Text';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Props {
items: ITextElement[];
}
const InlinedTextList = ({ items }: Props) => (
<div className="flex flex-col gap-2">
{items.map((el) => {
return (
<Card key={el.id}>
<CardHeader>
<CardTitle>{el.name}</CardTitle>
</CardHeader>
<CardContent>
<TextElement element={el} />
</CardContent>
</Card>
);
})}
</div>
);
export { InlinedTextList };
@@ -0,0 +1,17 @@
import { VideoElement } from '@/components/Elements/Video';
import type { IVideoElement } from 'client-types/';
interface Props {
items: IVideoElement[];
}
const InlinedVideoList = ({ items }: Props) => (
<div className="flex flex-col gap-2">
{items.map((i) => (
<VideoElement key={i.id} element={i} />
))}
</div>
);
export { InlinedVideoList };
@@ -0,0 +1,80 @@
import { cn } from '@/lib/utils';
import type { ElementType, IMessageElement } from '@chainlit/react-client';
import { InlinedCustomElementList } from './InlineCustomElementList';
import { InlinedAudioList } from './InlinedAudioList';
import { InlinedDataframeList } from './InlinedDataframeList';
import { InlinedFileList } from './InlinedFileList';
import { InlinedImageList } from './InlinedImageList';
import { InlinedPDFList } from './InlinedPDFList';
import { InlinedPlotlyList } from './InlinedPlotlyList';
import { InlinedTextList } from './InlinedTextList';
import { InlinedVideoList } from './InlinedVideoList';
interface Props {
elements: IMessageElement[];
className?: string;
}
const InlinedElements = ({ elements, className }: Props) => {
if (!elements.length) {
return null;
}
/**
* Categorize the elements by element type
* The TypeScript dance is needed to make sure we can do elementsByType.image
* and get an array of IImageElement.
*/
const elementsByType = elements.reduce(
(acc, el: IMessageElement) => {
if (!acc[el.type]) {
acc[el.type] = [];
}
const array = acc[el.type] as Extract<
IMessageElement,
{ type: typeof el.type }
>[];
array.push(el);
return acc;
},
{} as {
[K in ElementType]: Extract<IMessageElement, { type: K }>[];
}
);
return (
<div className={cn('flex flex-col gap-4', className)}>
{elementsByType.custom?.length ? (
<InlinedCustomElementList items={elementsByType.custom} />
) : null}
{elementsByType.image?.length ? (
<InlinedImageList items={elementsByType.image} />
) : null}
{elementsByType.text?.length ? (
<InlinedTextList items={elementsByType.text} />
) : null}
{elementsByType.pdf?.length ? (
<InlinedPDFList items={elementsByType.pdf} />
) : null}
{elementsByType.audio?.length ? (
<InlinedAudioList items={elementsByType.audio} />
) : null}
{elementsByType.video?.length ? (
<InlinedVideoList items={elementsByType.video} />
) : null}
{elementsByType.file?.length ? (
<InlinedFileList items={elementsByType.file} />
) : null}
{elementsByType.plotly?.length ? (
<InlinedPlotlyList items={elementsByType.plotly} />
) : null}
{elementsByType.dataframe?.length ? (
<InlinedDataframeList items={elementsByType.dataframe} />
) : null}
</div>
);
};
export { InlinedElements };

Some files were not shown because too many files have changed in this diff Show More