8c4a6cd663
## Summary This PR refactors the AI chat thread state management to use `null` instead of a sentinel string value (`AGENT_CHAT_UNKNOWN_THREAD_ID`) to represent an uninitialized or new chat thread. This improves type safety and makes the code more idiomatic by using `null` to represent the absence of a value. ## Key Changes - **Removed sentinel constant**: Deleted `AGENT_CHAT_UNKNOWN_THREAD_ID` constant and replaced all usages with `null` - **Updated state types**: Changed `currentAIChatThreadState`, `agentChatLastDiffSyncedThreadState`, and `agentChatDisplayedThreadState` to use `string | null` type with `null` as default value - **Updated component family states**: Modified message-related state families to accept `threadId: string | null` instead of `threadId: string` - **Refined null checks**: Added explicit `null` checks in: - `AgentChatMessagesFetchEffect`: Updated `isNewThread` logic to check for `null` first - `useAIChatThreadClick` and `useSwitchToNewAIChat`: Added guards to only save drafts when `currentAIChatThread !== null` - `AgentChatThreadInitializationEffect`: Added null check before UUID validation - `useEnsureAgentChatThreadIdForSend`: Added null check before comparing with draft key - **Updated fallback logic**: Used nullish coalescing operator (`??`) in `AIChatTab` and `useAIChatEditor` to default to `AGENT_CHAT_NEW_THREAD_DRAFT_KEY` when thread is null - **Enhanced refetch safety**: Added early return in `handleRefetchMessages` to prevent refetching when in new thread state ## Implementation Details - The change maintains backward compatibility by treating `null` the same way the code previously treated `AGENT_CHAT_UNKNOWN_THREAD_ID` - All draft saving operations now safely check for null before attempting to store drafts - The nullish coalescing pattern (`currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY`) ensures proper fallback behavior when accessing draft storage https://claude.ai/code/session_01Pz8KCygSNgBPYsndbMq8f7 --------- Co-authored-by: Claude <noreply@anthropic.com>
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { useEffect } from 'react';
|
|
|
|
import { readUIMessageStream, type UIMessageChunk } from 'ai';
|
|
import { print, type ExecutionResult } from 'graphql';
|
|
import { useStore } from 'jotai';
|
|
import {
|
|
type AgentChatSubscriptionEvent,
|
|
type ExtendedUIMessage,
|
|
} from 'twenty-shared/ai';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
|
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
|
import { ON_AGENT_CHAT_EVENT } from '@/ai/graphql/subscriptions/OnAgentChatEvent';
|
|
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
|
import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqState';
|
|
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
|
|
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
|
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
|
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
|
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
|
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { sseClientState } from '@/sse-db-event/states/sseClientState';
|
|
|
|
const THROTTLE_MS = 100;
|
|
|
|
// readUIMessageStream requires initialization chunks (start, start-step,
|
|
// text-start) before content chunks. When reconnecting to a thread mid-stream,
|
|
// those chunks were already sent before we subscribed. This adapter injects
|
|
// synthetic initialization chunks so the reader can process mid-stream content.
|
|
const createMidStreamAdapter = () => {
|
|
let hasSeenStart = false;
|
|
const knownTextPartIds = new Set<string>();
|
|
const knownReasoningPartIds = new Set<string>();
|
|
const knownToolCallIds = new Set<string>();
|
|
|
|
return new TransformStream<UIMessageChunk, UIMessageChunk>({
|
|
transform(chunk, controller) {
|
|
if (!hasSeenStart) {
|
|
hasSeenStart = true;
|
|
if (chunk.type !== 'start') {
|
|
controller.enqueue({ type: 'start', messageId: v4() });
|
|
controller.enqueue({ type: 'start-step' });
|
|
}
|
|
}
|
|
|
|
if (chunk.type === 'text-start') {
|
|
knownTextPartIds.add(chunk.id);
|
|
} else if (
|
|
(chunk.type === 'text-delta' || chunk.type === 'text-end') &&
|
|
!knownTextPartIds.has(chunk.id)
|
|
) {
|
|
controller.enqueue({ type: 'text-start', id: chunk.id });
|
|
knownTextPartIds.add(chunk.id);
|
|
}
|
|
|
|
if (chunk.type === 'reasoning-start') {
|
|
knownReasoningPartIds.add(chunk.id);
|
|
} else if (
|
|
(chunk.type === 'reasoning-delta' || chunk.type === 'reasoning-end') &&
|
|
!knownReasoningPartIds.has(chunk.id)
|
|
) {
|
|
controller.enqueue({ type: 'reasoning-start', id: chunk.id });
|
|
knownReasoningPartIds.add(chunk.id);
|
|
}
|
|
|
|
if (chunk.type === 'tool-input-start') {
|
|
knownToolCallIds.add(chunk.toolCallId);
|
|
} else if (
|
|
chunk.type === 'tool-input-delta' &&
|
|
!knownToolCallIds.has(chunk.toolCallId)
|
|
) {
|
|
controller.enqueue({
|
|
type: 'tool-input-start',
|
|
toolCallId: chunk.toolCallId,
|
|
toolName: 'unknown',
|
|
});
|
|
knownToolCallIds.add(chunk.toolCallId);
|
|
}
|
|
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
};
|
|
|
|
type AgentChatEventPayload = {
|
|
onAgentChatEvent: {
|
|
threadId: string;
|
|
event: AgentChatSubscriptionEvent;
|
|
};
|
|
};
|
|
|
|
export const useAgentChatSubscription = (threadId: string | null) => {
|
|
const store = useStore();
|
|
const sseClient = useAtomStateValue(sseClientState);
|
|
|
|
useEffect(() => {
|
|
if (!isDefined(threadId) || !isDefined(sseClient)) {
|
|
return;
|
|
}
|
|
|
|
let bridge: TransformStream<UIMessageChunk> | null = null;
|
|
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let latestMessage: ExtendedUIMessage | null = null;
|
|
let writer: WritableStreamDefaultWriter<UIMessageChunk> | null = null;
|
|
let disposed = false;
|
|
|
|
store.set(agentChatFirstLiveSeqState.atom, null);
|
|
|
|
const closeWriter = () => {
|
|
if (isDefined(writer)) {
|
|
writer.close().catch(() => {});
|
|
writer = null;
|
|
}
|
|
};
|
|
|
|
const cleanupStream = () => {
|
|
closeWriter();
|
|
|
|
if (store.get(agentChatIsStreamingState.atom)) {
|
|
store.set(agentChatIsStreamingState.atom, false);
|
|
}
|
|
};
|
|
|
|
const flushToAtom = () => {
|
|
const messageToFlush = latestMessage;
|
|
|
|
if (!isDefined(messageToFlush)) {
|
|
return;
|
|
}
|
|
|
|
const atomKey = {
|
|
instanceId: AGENT_CHAT_INSTANCE_ID,
|
|
familyKey: { threadId },
|
|
};
|
|
|
|
const currentMessages = store.get(
|
|
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
|
);
|
|
|
|
const streamingMsgIndex = currentMessages.findIndex(
|
|
(message) => message.id === messageToFlush.id,
|
|
);
|
|
|
|
if (streamingMsgIndex >= 0) {
|
|
const updatedMessages = [...currentMessages];
|
|
|
|
updatedMessages[streamingMsgIndex] = messageToFlush;
|
|
store.set(
|
|
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
|
updatedMessages,
|
|
);
|
|
} else {
|
|
store.set(agentChatMessagesComponentFamilyState.atomFamily(atomKey), [
|
|
...currentMessages,
|
|
messageToFlush,
|
|
]);
|
|
}
|
|
};
|
|
|
|
const scheduleAtomUpdate = (message: ExtendedUIMessage) => {
|
|
latestMessage = message;
|
|
|
|
if (!isDefined(throttleTimer)) {
|
|
flushToAtom();
|
|
|
|
throttleTimer = setTimeout(() => {
|
|
throttleTimer = null;
|
|
flushToAtom();
|
|
}, THROTTLE_MS);
|
|
}
|
|
};
|
|
|
|
const startReadLoop = async (readable: ReadableStream<UIMessageChunk>) => {
|
|
const messageStream = readUIMessageStream({ stream: readable });
|
|
|
|
for await (const message of messageStream) {
|
|
const extendedMessage = message as ExtendedUIMessage;
|
|
|
|
const titlePart = extendedMessage.parts.find(
|
|
(part) => part.type === 'data-thread-title',
|
|
);
|
|
|
|
if (isDefined(titlePart) && titlePart.type === 'data-thread-title') {
|
|
store.set(currentAIChatThreadTitleState.atom, titlePart.data.title);
|
|
}
|
|
|
|
const metadata = extendedMessage.metadata as
|
|
| {
|
|
usage?: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cachedInputTokens: number;
|
|
inputCredits: number;
|
|
outputCredits: number;
|
|
conversationSize: number;
|
|
};
|
|
model?: {
|
|
contextWindowTokens: number;
|
|
};
|
|
}
|
|
| undefined;
|
|
|
|
if (isDefined(metadata?.usage) && isDefined(metadata?.model)) {
|
|
const usage = metadata.usage;
|
|
const model = metadata.model;
|
|
|
|
store.set(agentChatUsageState.atom, (prev) => ({
|
|
lastMessage: {
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cachedInputTokens: usage.cachedInputTokens,
|
|
inputCredits: usage.inputCredits,
|
|
outputCredits: usage.outputCredits,
|
|
},
|
|
conversationSize: usage.conversationSize,
|
|
contextWindowTokens: model.contextWindowTokens,
|
|
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
|
|
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
|
|
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
|
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
|
}));
|
|
}
|
|
|
|
scheduleAtomUpdate(extendedMessage);
|
|
}
|
|
|
|
if (isDefined(throttleTimer)) {
|
|
clearTimeout(throttleTimer);
|
|
throttleTimer = null;
|
|
}
|
|
flushToAtom();
|
|
|
|
if (!disposed) {
|
|
store.set(agentChatIsStreamingState.atom, false);
|
|
}
|
|
};
|
|
|
|
const handleEvent = (event: AgentChatSubscriptionEvent) => {
|
|
switch (event.type) {
|
|
case 'stream-chunk': {
|
|
if (
|
|
isDefined(event.seq) &&
|
|
store.get(agentChatFirstLiveSeqState.atom) === null
|
|
) {
|
|
store.set(agentChatFirstLiveSeqState.atom, event.seq);
|
|
}
|
|
|
|
if (!store.get(agentChatIsStreamingState.atom)) {
|
|
store.set(agentChatIsStreamingState.atom, true);
|
|
|
|
bridge = new TransformStream<UIMessageChunk>();
|
|
writer = bridge.writable.getWriter();
|
|
|
|
const adaptedReadable = bridge.readable.pipeThrough(
|
|
createMidStreamAdapter(),
|
|
);
|
|
|
|
startReadLoop(adaptedReadable).catch(() => {
|
|
if (!disposed) {
|
|
store.set(agentChatIsStreamingState.atom, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (isDefined(writer)) {
|
|
writer.write(event.chunk as UIMessageChunk).catch(() => {});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'message-persisted': {
|
|
closeWriter();
|
|
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
|
break;
|
|
}
|
|
|
|
case 'queue-updated': {
|
|
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
|
break;
|
|
}
|
|
|
|
case 'stream-error': {
|
|
const streamError = new Error(event.message) as Error & {
|
|
code?: string;
|
|
};
|
|
|
|
streamError.code = event.code;
|
|
store.set(agentChatErrorState.atom, streamError);
|
|
|
|
closeWriter();
|
|
store.set(agentChatIsStreamingState.atom, false);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
store.set(agentChatHandleEventCallbackState.atom, () => handleEvent);
|
|
|
|
const dispose = sseClient.subscribe<AgentChatEventPayload>(
|
|
{
|
|
query: print(ON_AGENT_CHAT_EVENT),
|
|
variables: { threadId },
|
|
},
|
|
{
|
|
next: (value: ExecutionResult<AgentChatEventPayload>) => {
|
|
if (isDefined(value.data?.onAgentChatEvent?.event)) {
|
|
handleEvent(
|
|
value.data.onAgentChatEvent.event as AgentChatSubscriptionEvent,
|
|
);
|
|
}
|
|
},
|
|
error: () => {
|
|
// graphql-sse handles reconnection automatically
|
|
},
|
|
complete: () => {
|
|
if (!disposed) {
|
|
cleanupStream();
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
store.set(agentChatHandleEventCallbackState.atom, null);
|
|
if (isDefined(throttleTimer)) {
|
|
clearTimeout(throttleTimer);
|
|
}
|
|
cleanupStream();
|
|
dispose();
|
|
};
|
|
}, [threadId, sseClient, store]);
|
|
};
|