Add conversations and turns foundation + DDD (#188)

- Store conversations and turns for:
   - playground chat
   - api
 - New DDD code organisation with container dependency injection
 - sdk update
 - streaming api support
This commit is contained in:
Ramnique Singh 2025-08-05 14:40:48 +05:30 committed by GitHub
parent 659b23ae2b
commit 51a33ab2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1474 additions and 525 deletions

View file

@ -16,6 +16,7 @@ export function App({
messageSubscriber,
onPanelClick,
triggerCopilotChat,
isLiveWorkflow,
}: {
hidden?: boolean;
projectId: string;
@ -23,6 +24,7 @@ export function App({
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
onPanelClick?: () => void;
triggerCopilotChat?: (message: string) => void;
isLiveWorkflow: boolean;
}) {
const [counter, setCounter] = useState<number>(0);
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
@ -118,6 +120,7 @@ export function App({
onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
showDebugMessages={showDebugMessages}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLiveWorkflow}
/>
</div>
</Panel>

View file

@ -1,8 +1,8 @@
'use client';
import { useEffect, useRef, useState, useCallback } from "react";
import { getAssistantResponseStreamId } from "@/app/actions/actions";
import { createCachedTurn, createConversation } from "@/app/actions/playground-chat.actions";
import { Messages } from "./messages";
import z from "zod";
import { z } from "zod";
import { Message, ToolMessage } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
@ -11,6 +11,7 @@ import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { FeedbackModal } from "./feedback-modal";
import { FIX_WORKFLOW_PROMPT, FIX_WORKFLOW_PROMPT_WITH_FEEDBACK, EXPLAIN_WORKFLOW_PROMPT_ASSISTANT, EXPLAIN_WORKFLOW_PROMPT_TOOL, EXPLAIN_WORKFLOW_PROMPT_TRANSITION } from "../copilot-prompts";
import { TurnEvent } from "@/src/entities/models/turn";
export function Chat({
projectId,
@ -20,6 +21,7 @@ export function Chat({
showDebugMessages = true,
showJsonMode = false,
triggerCopilotChat,
isLiveWorkflow,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
@ -28,10 +30,12 @@ export function Chat({
showDebugMessages?: boolean;
showJsonMode?: boolean;
triggerCopilotChat?: (message: string) => void;
isLiveWorkflow: boolean;
}) {
const conversationId = useRef<string | null>(null);
const [messages, setMessages] = useState<z.infer<typeof Message>[]>([]);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
@ -142,7 +146,7 @@ export function Chat({
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
setLoadingAssistantResponse(false);
setLoading(false);
}
}, []);
@ -152,7 +156,7 @@ export function Chat({
content: prompt,
}];
setMessages(updatedMessages);
setFetchResponseError(null);
setError(null);
setIsLastInteracted(true);
}
@ -165,7 +169,7 @@ export function Chat({
} else {
setShowUnreadBubble(true);
}
}, [optimisticMessages, loadingAssistantResponse, autoScroll]);
}, [optimisticMessages, loading, autoScroll]);
// Expose copy function to parent
useEffect(() => {
@ -190,148 +194,175 @@ export function Chat({
}
}, [messages, messageSubscriber]);
// get assistant response
// get agent response
useEffect(() => {
let ignore = false;
let eventSource: EventSource | null = null;
let msgs: z.infer<typeof Message>[] = [];
async function process() {
setLoadingAssistantResponse(true);
setFetchResponseError(null);
// Reset request/response state before making new request
setLastAgenticRequest(null);
setLastAgenticResponse(null);
let streamId: string | null = null;
try {
const response = await getAssistantResponseStreamId(
projectId,
workflow,
messages,
);
// first, if there is no conversation id, create it
if (!conversationId.current) {
const response = await createConversation({
projectId,
workflow,
isLiveWorkflow,
});
conversationId.current = response.id;
}
// set up a cached turn
const response = await createCachedTurn({
conversationId: conversationId.current,
messages: messages.slice(-1), // only send the last message
});
if (ignore) {
return;
}
if ('billingError' in response) {
setBillingError(response.billingError);
setFetchResponseError(response.billingError);
setLoadingAssistantResponse(false);
console.log('returning from getAssistantResponseStreamId due to billing error');
return;
}
streamId = response.streamId;
} catch (err) {
if (!ignore) {
setFetchResponseError(`Failed to get assistant response: ${err instanceof Error ? err.message : 'Unknown error'}`);
setLoadingAssistantResponse(false);
}
}
// if ('billingError' in response) {
// setBillingError(response.billingError);
// setError(response.billingError);
// setLoading(false);
// console.log('returning from createRun due to billing error');
// return;
// }
if (ignore || !streamId) {
return;
}
// stream events
eventSource = new EventSource(`/api/stream-response/${response.key}`);
eventSourceRef.current = eventSource;
console.log(`chat.tsx: got streamid: ${streamId}`);
eventSource = new EventSource(`/api/stream-response/${streamId}`);
eventSourceRef.current = eventSource;
// handle events
eventSource.addEventListener("message", (event) => {
console.log(`chat.tsx: got message: ${JSON.stringify(event.data)}`);
if (ignore) {
return;
}
eventSource.addEventListener("message", (event) => {
console.log(`chat.tsx: got message: ${event.data}`);
if (ignore) {
return;
}
try {
const data = JSON.parse(event.data);
const turnEvent = TurnEvent.parse(data);
console.log(`chat.tsx: got event: ${turnEvent}`);
try {
const data = JSON.parse(event.data);
const parsedMsg = Message.parse(data);
msgs.push(parsedMsg);
// Update optimistic messages immediately for real-time streaming UX
setOptimisticMessages(prev => [...prev, parsedMsg]);
} catch (err) {
console.error('Failed to parse SSE message:', err);
setFetchResponseError(`Failed to parse SSE message: ${err instanceof Error ? err.message : 'Unknown error'}`);
// Rollback to last known good state on parsing errors
setOptimisticMessages(messages);
}
});
switch (turnEvent.type) {
case "message": {
// Handle regular message events
const generatedMessage = turnEvent.data;
// Update optimistic messages immediately for real-time streaming UX
setOptimisticMessages(prev => [...prev, generatedMessage]);
break;
}
case "done": {
// Handle completion event
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
eventSource.addEventListener('done', (event) => {
console.log(`chat.tsx: got done event: ${event.data}`);
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
// Combine state and collected messages in the response
setLastAgenticResponse({
turn: turnEvent.turn,
messages: turnEvent.turn.output,
});
const parsed = JSON.parse(event.data);
// Commit all streamed messages atomically to the source of truth
setMessages([...messages, ...turnEvent.turn.output]);
setLoading(false);
break;
}
case "error": {
// Handle error event
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
// Combine state and collected messages in the response
setLastAgenticResponse({
...parsed,
messages: msgs
console.error('Turn Error:', turnEvent.error);
if (!ignore) {
setLoading(false);
setError('Error: ' + turnEvent.error);
// Rollback to last known good state on stream errors
setOptimisticMessages(messages);
// check if billing error
if (turnEvent.isBillingError) {
setBillingError(turnEvent.error);
}
}
break;
}
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
setError(`Failed to parse SSE message: ${err instanceof Error ? err.message : 'Unknown error'}`);
// Rollback to last known good state on parsing errors
setOptimisticMessages(messages);
}
});
// Commit all streamed messages atomically to the source of truth
setMessages([...messages, ...msgs]);
setLoadingAssistantResponse(false);
});
eventSource.addEventListener('stream_error', (event) => {
console.log(`chat.tsx: got stream_error event: ${event.data}`);
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
console.error('SSE Error:', event);
if (!ignore) {
setLoading(false);
setError('Error: ' + JSON.parse(event.data).error);
// Rollback to last known good state on stream errors
setOptimisticMessages(messages);
}
});
eventSource.addEventListener('stream_error', (event) => {
console.log(`chat.tsx: got stream_error event: ${event.data}`);
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
console.error('SSE Error:', event);
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
if (!ignore) {
setLoading(false);
setError('Stream connection failed');
// Rollback to last known good state on connection errors
setOptimisticMessages(messages);
}
};
} catch (err) {
if (!ignore) {
setLoadingAssistantResponse(false);
setFetchResponseError('Error: ' + JSON.parse(event.data).error);
// Rollback to last known good state on stream errors
setOptimisticMessages(messages);
setError(`Failed to create run: ${err instanceof Error ? err.message : 'Unknown error'}`);
setLoading(false);
}
});
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
if (!ignore) {
setLoadingAssistantResponse(false);
setFetchResponseError('Stream connection failed');
// Rollback to last known good state on connection errors
setOptimisticMessages(messages);
}
};
}
// if last message is not a user message, return
if (messages.length > 0) {
const last = messages[messages.length - 1];
if (last.role !== 'user') {
return;
}
}
// if there is an error, return
if (fetchResponseError) {
// if there are no messages yet, return
if (messages.length === 0) {
return;
}
console.log(`executing response process: fetchresponseerr: ${fetchResponseError}`);
// if last message is not a user message, return
const last = messages[messages.length - 1];
if (last.role !== 'user') {
return;
}
// if there is an error, return
if (error) {
return;
}
console.log(`chat.tsx: fetching agent response`);
setLoading(true);
setError(null);
process();
return () => {
ignore = true;
if (eventSource) {
eventSource.close();
eventSourceRef.current = null;
}
};
}, [
conversationId,
messages,
projectId,
workflow,
fetchResponseError,
isLiveWorkflow,
error,
]);
return (
@ -349,9 +380,17 @@ export function Chat({
>
<Messages
projectId={projectId}
messages={optimisticMessages}
messages={[
{
role: 'assistant',
content: 'Hi, how can I help you today?',
agentName: 'assistant',
responseType: 'external',
},
...optimisticMessages,
]}
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
loadingAssistantResponse={loading}
workflow={workflow}
showDebugMessages={showDebugMessages}
showJsonMode={showJsonMode}
@ -403,15 +442,15 @@ export function Chat({
</Button>
</div>
)}
{fetchResponseError && (
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
rounded-lg flex gap-2 justify-between items-center">
<p className="text-red-600 dark:text-red-400 text-sm">{fetchResponseError}</p>
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
<Button
size="sm"
color="danger"
onPress={() => {
setFetchResponseError(null);
setError(null);
setBillingError(null);
}}
>
@ -423,7 +462,7 @@ export function Chat({
<ComposeBoxPlayground
handleUserMessage={handleUserMessage}
messages={messages.filter(msg => msg.content !== undefined) as any}
loading={loadingAssistantResponse}
loading={loading}
shouldAutoFocus={isLastInteracted}
onFocus={() => setIsLastInteracted(true)}
onCancel={handleStop}

View file

@ -1160,6 +1160,7 @@ export function WorkflowEditor({
messageSubscriber={updateChatMessages}
onPanelClick={handlePlaygroundClick}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLive}
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}`}