Files
chainlit--chainlit/libs/react-client/src/useChatSession.ts
T
wehub-resource-sync b7f52be4c9
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
chore: import upstream snapshot with attribution
2026-07-13 12:48:47 +08:00

520 lines
15 KiB
TypeScript

import { debounce } from 'lodash';
import { useCallback, useContext, useEffect } from 'react';
import {
useRecoilState,
useRecoilValue,
useResetRecoilState,
useSetRecoilState
} from 'recoil';
import io from 'socket.io-client';
import { toast } from 'sonner';
import {
actionState,
askUserState,
audioConnectionState,
callFnState,
chatProfileState,
chatSettingsInputsState,
chatSettingsValueState,
commandsState,
currentThreadIdState,
elementState,
favoriteMessagesState,
firstUserInteraction,
isAiSpeakingState,
loadingState,
mcpState,
messagesState,
modesState,
resumeThreadErrorState,
sessionIdState,
sessionState,
sideViewState,
tasklistState,
threadIdToResumeState,
tokenCountState,
wavRecorderState,
wavStreamPlayerState
} from 'src/state';
import {
IAction,
ICommand,
IElement,
IMessageElement,
IMode,
IStep,
ITasklistElement,
IThread
} from 'src/types';
import {
addMessage,
deleteMessageById,
updateMessageById,
updateMessageContentById
} from 'src/utils/message';
import { OutputAudioChunk } from './types/audio';
import { ChainlitContext } from './context';
import type { IToken } from './useChatData';
const useChatSession = () => {
const client = useContext(ChainlitContext);
const sessionId = useRecoilValue(sessionIdState);
const [session, setSession] = useRecoilState(sessionState);
const setIsAiSpeaking = useSetRecoilState(isAiSpeakingState);
const setAudioConnection = useSetRecoilState(audioConnectionState);
const resetChatSettingsValue = useResetRecoilState(chatSettingsValueState);
const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);
const setFirstUserInteraction = useSetRecoilState(firstUserInteraction);
const setLoading = useSetRecoilState(loadingState);
const setMcps = useSetRecoilState(mcpState);
const wavStreamPlayer = useRecoilValue(wavStreamPlayerState);
const wavRecorder = useRecoilValue(wavRecorderState);
const setMessages = useSetRecoilState(messagesState);
const setAskUser = useSetRecoilState(askUserState);
const setCallFn = useSetRecoilState(callFnState);
const setCommands = useSetRecoilState(commandsState);
const setModes = useSetRecoilState(modesState);
const setSideView = useSetRecoilState(sideViewState);
const setElements = useSetRecoilState(elementState);
const setTasklists = useSetRecoilState(tasklistState);
const setActions = useSetRecoilState(actionState);
const setChatSettingsInputs = useSetRecoilState(chatSettingsInputsState);
const setTokenCount = useSetRecoilState(tokenCountState);
const [chatProfile, setChatProfile] = useRecoilState(chatProfileState);
const idToResume = useRecoilValue(threadIdToResumeState);
const setThreadResumeError = useSetRecoilState(resumeThreadErrorState);
const setFavoriteMessages = useSetRecoilState(favoriteMessagesState);
const [currentThreadId, setCurrentThreadId] =
useRecoilState(currentThreadIdState);
// Use currentThreadId as thread id in websocket header
useEffect(() => {
if (session?.socket) {
session.socket.auth['threadId'] = currentThreadId || '';
}
}, [currentThreadId]);
const _connect = useCallback(
async ({
transports,
userEnv
}: {
transports?: string[];
userEnv: Record<string, string>;
}) => {
const { protocol, host, pathname } = new URL(client.httpEndpoint);
const uri = `${protocol}//${host}`;
const path =
pathname && pathname !== '/'
? `${pathname}/ws/socket.io`
: '/ws/socket.io';
try {
await client.stickyCookie(sessionId);
} catch (err) {
console.error(`Failed to set sticky session cookie: ${err}`);
}
const socket = io(uri, {
path,
withCredentials: true,
transports,
auth: {
clientType: client.type,
sessionId,
threadId: idToResume || '',
userEnv: JSON.stringify(userEnv),
chatProfile: chatProfile ? encodeURIComponent(chatProfile) : ''
}
});
setSession((old) => {
old?.socket?.removeAllListeners();
old?.socket?.close();
return {
socket
};
});
socket.on('connect', () => {
socket.emit('connection_successful');
setSession((s) => ({ ...s!, error: false }));
socket.emit('fetch_favorites');
setMcps((prev) =>
prev.map((mcp) => {
let promise;
if (mcp.clientType === 'sse') {
promise = client.connectSseMCP(sessionId, mcp.name, mcp.url!);
} else if (mcp.clientType === 'streamable-http') {
promise = client.connectStreamableHttpMCP(
sessionId,
mcp.name,
mcp.url!,
mcp.headers || {}
);
} else {
promise = client.connectStdioMCP(
sessionId,
mcp.name,
mcp.command!
);
}
promise
.then(async ({ success, mcp }) => {
setMcps((prev) =>
prev.map((existingMcp) => {
if (existingMcp.name === mcp.name) {
return {
...existingMcp,
status: success ? 'connected' : 'failed',
tools: mcp ? mcp.tools : existingMcp.tools
};
}
return existingMcp;
})
);
})
.catch(() => {
setMcps((prev) =>
prev.map((existingMcp) => {
if (existingMcp.name === mcp.name) {
return {
...existingMcp,
status: 'failed'
};
}
return existingMcp;
})
);
});
return { ...mcp, status: 'connecting' };
})
);
});
socket.on('connect_error', (_) => {
setSession((s) => ({ ...s!, error: true }));
});
socket.on('task_start', () => {
setLoading(true);
});
socket.on('task_end', () => {
setLoading(false);
});
socket.on('reload', () => {
socket.emit('clear_session');
window.location.reload();
});
socket.on('audio_connection', async (state: 'on' | 'off') => {
if (state === 'on') {
let isFirstChunk = true;
const startTime = Date.now();
const mimeType = 'pcm16';
try {
await wavRecorder.begin();
await wavStreamPlayer.connect();
await wavRecorder.record(async (data) => {
const elapsedTime = Date.now() - startTime;
socket.emit('audio_chunk', {
isStart: isFirstChunk,
mimeType,
elapsedTime,
data: data.mono
});
isFirstChunk = false;
});
wavStreamPlayer.onStop = () => setIsAiSpeaking(false);
} catch {
try {
await wavRecorder.end();
} catch {
// ignored
}
await wavStreamPlayer.interrupt();
socket.emit('audio_end');
setAudioConnection('off');
return;
}
} else {
await wavRecorder.end();
await wavStreamPlayer.interrupt();
}
setAudioConnection(state);
});
socket.on('audio_chunk', (chunk: OutputAudioChunk) => {
wavStreamPlayer.add16BitPCM(chunk.data, chunk.track);
setIsAiSpeaking(true);
});
socket.on('audio_interrupt', () => {
wavStreamPlayer.interrupt();
});
socket.on('resume_thread', (thread: IThread) => {
const isReadOnlyView = Boolean(
(thread as any)?.metadata?.viewer_read_only
);
if (!isReadOnlyView && idToResume && thread.id !== idToResume) {
window.location.href = `/thread/${thread.id}`;
}
if (!isReadOnlyView && idToResume) {
setCurrentThreadId(thread.id);
}
let messages: IStep[] = [];
for (const step of thread.steps) {
messages = addMessage(messages, step);
}
if (thread.metadata?.chat_profile) {
setChatProfile(thread.metadata?.chat_profile);
}
if (thread.metadata?.chat_settings) {
setChatSettingsValue(thread.metadata?.chat_settings);
}
setMessages(messages);
const elements = thread.elements || [];
setTasklists(
(elements as ITasklistElement[]).filter((e) => e.type === 'tasklist')
);
setElements(
(elements as IMessageElement[]).filter(
(e) => ['avatar', 'tasklist'].indexOf(e.type) === -1
)
);
});
socket.on('resume_thread_error', (error?: string) => {
setThreadResumeError(error);
});
socket.on('new_message', (message: IStep) => {
setMessages((oldMessages) => addMessage(oldMessages, message));
});
socket.on(
'first_interaction',
(event: { interaction: string; thread_id: string }) => {
setFirstUserInteraction(event.interaction);
setCurrentThreadId(event.thread_id);
}
);
socket.on('update_message', (message: IStep) => {
setMessages((oldMessages) =>
updateMessageById(oldMessages, message.id, message)
);
});
socket.on('delete_message', (message: IStep) => {
setMessages((oldMessages) =>
deleteMessageById(oldMessages, message.id)
);
});
socket.on('stream_start', (message: IStep) => {
setMessages((oldMessages) => addMessage(oldMessages, message));
});
socket.on(
'stream_token',
({ id, token, isSequence, isInput }: IToken) => {
setMessages((oldMessages) =>
updateMessageContentById(
oldMessages,
id,
token,
isSequence,
isInput
)
);
}
);
socket.on('ask', ({ msg, spec }, callback) => {
setAskUser({ spec, callback, parentId: msg.parentId });
setMessages((oldMessages) => addMessage(oldMessages, msg));
setLoading(false);
});
socket.on('ask_timeout', () => {
setAskUser(undefined);
setLoading(false);
});
socket.on('clear_ask', () => {
setAskUser(undefined);
});
socket.on('call_fn', ({ name, args }, callback) => {
setCallFn({ name, args, callback });
});
socket.on('clear_call_fn', () => {
setCallFn(undefined);
});
socket.on('call_fn_timeout', () => {
setCallFn(undefined);
});
socket.on('chat_settings', (inputs: any) => {
setChatSettingsInputs(inputs);
resetChatSettingsValue();
});
socket.on('set_commands', (commands: ICommand[]) => {
setCommands(commands);
});
socket.on('set_modes', (modes: IMode[]) => {
setModes(modes);
});
socket.on('set_favorites', (steps: IStep[]) => {
setFavoriteMessages(steps);
});
socket.on('set_sidebar_title', (title: string) => {
setSideView((prev) => {
if (prev?.title === title) return prev;
return { title, elements: prev?.elements || [] };
});
});
socket.on(
'set_sidebar_elements',
({ elements, key }: { elements: IMessageElement[]; key?: string }) => {
if (!elements.length) {
setSideView(undefined);
} else {
elements.forEach((element) => {
if (!element.url && element.chainlitKey) {
element.url = client.getElementUrl(
element.chainlitKey,
sessionId
);
}
});
setSideView((prev) => {
if (prev?.key === key) return prev;
return { title: prev?.title || '', elements: elements, key };
});
}
}
);
socket.on('element', (element: IElement) => {
if (!element.url && element.chainlitKey) {
element.url = client.getElementUrl(element.chainlitKey, sessionId);
}
if (element.type === 'tasklist') {
setTasklists((old) => {
const index = old.findIndex((e) => e.id === element.id);
if (index === -1) {
return [...old, element];
} else {
return [...old.slice(0, index), element, ...old.slice(index + 1)];
}
});
} else {
setElements((old) => {
const index = old.findIndex((e) => e.id === element.id);
if (index === -1) {
return [...old, element];
} else {
return [...old.slice(0, index), element, ...old.slice(index + 1)];
}
});
}
});
socket.on('remove_element', (remove: { id: string }) => {
setElements((old) => {
return old.filter((e) => e.id !== remove.id);
});
setTasklists((old) => {
return old.filter((e) => e.id !== remove.id);
});
});
socket.on('action', (action: IAction) => {
setActions((old) => [...old, action]);
});
socket.on('remove_action', (action: IAction) => {
setActions((old) => {
const index = old.findIndex((a) => a.id === action.id);
if (index === -1) return old;
return [...old.slice(0, index), ...old.slice(index + 1)];
});
});
socket.on('token_usage', (count: number) => {
setTokenCount((old) => old + count);
});
socket.on('window_message', (data: any) => {
if (window.parent) {
window.parent.postMessage(data, '*');
}
});
socket.on('toast', (data: { message: string; type: string }) => {
if (!data.message) {
console.warn('No message received for toast.');
return;
}
switch (data.type) {
case 'info':
toast.info(data.message);
break;
case 'error':
toast.error(data.message);
break;
case 'success':
toast.success(data.message);
break;
case 'warning':
toast.warning(data.message);
break;
default:
toast(data.message);
break;
}
});
},
[setSession, sessionId, idToResume, chatProfile]
);
const connect = useCallback(debounce(_connect, 200), [_connect]);
const disconnect = useCallback(() => {
if (session?.socket) {
session.socket.removeAllListeners();
session.socket.close();
}
}, [session]);
return {
connect,
disconnect,
session,
sessionId,
chatProfile,
idToResume,
setChatProfile
};
};
export { useChatSession };