Use streaming in Copilot

This commit is contained in:
Ramnique Singh 2025-04-15 00:56:15 +05:30
parent ba14bc9bd8
commit 49ca5a7154
19 changed files with 1303 additions and 839 deletions

View file

@ -5,14 +5,14 @@ import {
CopilotChatContext, CopilotMessage, CopilotAssistantMessage, CopilotWorkflow
} from "../lib/types/copilot_types";
import {
Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent
} from "../lib/types/workflow_types";
Workflow} from "../lib/types/workflow_types";
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assert } from "node:console";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { QueryLimitError, validateConfigChanges } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis";
export async function getCopilotResponse(
projectId: string,
@ -67,90 +67,19 @@ export async function getCopilotResponse(
// validate response schema
assert(msg.role === 'assistant');
if (msg.role === 'assistant') {
for (const part of msg.content.response) {
const content = JSON.parse(msg.content);
for (const part of content.response) {
if (part.type === 'action') {
switch (part.content.config_type) {
case 'tool': {
const test = {
name: 'test',
description: 'test',
type: 'custom' as const,
implementation: 'mock' as const,
parameters: {
type: 'object',
properties: {},
required: [],
},
} as z.infer<typeof WorkflowTool>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowTool.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
case 'agent': {
const test = {
name: 'test',
description: 'test',
type: 'conversation',
instructions: 'test',
prompts: [],
tools: [],
model: 'gpt-4o',
ragReturnType: 'chunks',
ragK: 10,
connectedAgents: [],
controlType: 'retain',
} as z.infer<typeof WorkflowAgent>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowAgent.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
case 'prompt': {
const test = {
name: 'test',
type: 'base_prompt',
prompt: "test",
} as z.infer<typeof WorkflowPrompt>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowPrompt.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
default: {
part.content.error = `Unknown config type: ${part.content.config_type}`;
break;
}
const result = validateConfigChanges(
part.content.config_type,
part.content.config_changes,
part.content.name
);
if ('error' in result) {
part.content.error = result.error;
} else {
part.content.config_changes = result.changes;
}
}
}
@ -163,6 +92,43 @@ export async function getCopilotResponse(
};
}
export async function getCopilotResponseStream(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null
): Promise<{
streamId: string;
}> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
context: context ? convertToCopilotApiChatContext(context) : null,
};
// serialize the request
const payload = JSON.stringify(request);
// create a uuid for the stream
const streamId = crypto.randomUUID();
// store payload in redis
await redisClient.set(`copilot-stream-${streamId}`, payload, {
EX: 60 * 10, // expire in 10 minutes
});
return {
streamId,
};
}
export async function getCopilotAgentInstructions(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],

View file

@ -0,0 +1,52 @@
import { redisClient } from "@/app/lib/redis";
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// get the payload from redis
const payload = await redisClient.get(`copilot-stream-${params.streamId}`);
if (!payload) {
return new Response("Stream not found", { status: 404 });
}
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, {
method: 'POST',
body: payload,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
cache: 'no-store',
});
// If the upstream request fails, return a 502 Bad Gateway.
if (!upstreamResponse.ok || !upstreamResponse.body) {
return new Response("Error connecting to upstream SSE stream", { status: 502 });
}
const reader = upstreamResponse.body.getReader();
const stream = new ReadableStream({
async start(controller) {
try {
// Read from the upstream stream continuously.
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Immediately enqueue each received chunk.
controller.enqueue(value);
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}

View file

@ -1,6 +1,73 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt } from "./types/workflow_types";
import { z } from "zod";
export class QueryLimitError extends Error {
constructor(message: string = 'Query limit exceeded') {
super(message);
this.name = 'QueryLimitError';
}
}
}
export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) {
let testObject: any;
let schema: z.ZodType<any>;
switch (configType) {
case 'tool': {
testObject = {
name: 'test',
description: 'test',
parameters: {
type: 'object',
properties: {},
required: [],
},
} as z.infer<typeof WorkflowTool>;
schema = WorkflowTool;
break;
}
case 'agent': {
testObject = {
name: 'test',
description: 'test',
type: 'conversation',
instructions: 'test',
prompts: [],
tools: [],
model: 'gpt-4o',
ragReturnType: 'chunks',
ragK: 10,
connectedAgents: [],
controlType: 'retain',
} as z.infer<typeof WorkflowAgent>;
schema = WorkflowAgent;
break;
}
case 'prompt': {
testObject = {
name: 'test',
type: 'base_prompt',
prompt: "test",
} as z.infer<typeof WorkflowPrompt>;
schema = WorkflowPrompt;
break;
}
default:
return { error: `Unknown config type: ${configType}` };
}
// Validate each field and remove invalid ones
const validatedChanges = { ...configChanges };
for (const [key, value] of Object.entries(configChanges)) {
const result = schema.safeParse({
...testObject,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${configType}: ${name}`, result.error.message);
delete validatedChanges[key];
}
}
return { changes: validatedChanges };
}

View file

@ -28,10 +28,7 @@ export const CopilotAssistantMessageActionPart = z.object({
});
export const CopilotAssistantMessage = z.object({
role: z.literal('assistant'),
content: z.object({
thoughts: z.string().optional(),
response: z.array(z.union([CopilotAssistantMessageTextPart, CopilotAssistantMessageActionPart])),
}),
content: z.string(),
});
export const CopilotMessage = z.union([CopilotUserMessage, CopilotAssistantMessage]);

View file

@ -4,21 +4,19 @@ import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger,
import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react";
import { CopilotChatContext } from "../../../lib/types/copilot_types";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { CopilotAssistantMessageActionPart } from "../../../lib/types/copilot_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { z } from "zod";
import { getCopilotResponse } from "@/app/actions/copilot_actions";
import { Action as WorkflowDispatch } from "../workflow/workflow_editor";
import { Panel } from "@/components/common/panel-common";
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
import { useCopilot } from "./use-copilot";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
}>({ workflow: null, handleApplyChange: () => { }, appliedChanges: {} });
dispatch: (action: any) => void;
}>({ workflow: null, dispatch: () => { } });
export function getAppliedChangeKey(messageIndex: number, actionIndex: number, field: string) {
return `${messageIndex}-${actionIndex}-${field}`;
@ -29,7 +27,7 @@ interface AppProps {
workflow: z.infer<typeof Workflow>;
dispatch: (action: any) => void;
chatContext?: any;
onCopyJson?: (data: { messages: any[], lastRequest: any, lastResponse: any }) => void;
onCopyJson?: (data: { messages: any[] }) => void;
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean;
}
@ -43,17 +41,34 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
onMessagesChange,
isInitialState = false,
}, ref) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
const [responseError, setResponseError] = useState<string | null>(null);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const [discardContext, setDiscardContext] = useState(false);
const [lastRequest, setLastRequest] = useState<unknown | null>(null);
const [lastResponse, setLastResponse] = useState<unknown | null>(null);
const [currentStatus, setCurrentStatus] = useState<'thinking' | 'planning' | 'generating'>('thinking');
const statusIntervalRef = useRef<NodeJS.Timeout>();
const [isLastInteracted, setIsLastInteracted] = useState(isInitialState);
const workflowRef = useRef(workflow);
const startRef = useRef<any>(null);
const cancelRef = useRef<any>(null);
// Keep workflow ref up to date
workflowRef.current = workflow;
// Get the effective context based on user preference
const effectiveContext = discardContext ? null : chatContext;
const {
streamingResponse,
loading: loadingResponse,
error: responseError,
start,
cancel
} = useCopilot({
projectId,
workflow: workflowRef.current,
context: effectiveContext
});
// Store latest start/cancel functions in refs
startRef.current = start;
cancelRef.current = cancel;
// Notify parent of message changes
useEffect(() => {
@ -77,207 +92,56 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
setDiscardContext(false);
}, [chatContext]);
// Get the effective context based on user preference
const effectiveContext = discardContext ? null : chatContext;
function handleUserMessage(prompt: string) {
setMessages(currentMessages => [...currentMessages, {
role: 'user',
content: prompt
}]);
setResponseError(null);
setIsLastInteracted(true);
}
const handleApplyChange = useCallback((
messageIndex: number,
actionIndex: number,
field?: string
) => {
// validate
console.log('apply change', messageIndex, actionIndex, field);
const msg = messages[messageIndex];
if (!msg) {
console.log('no message');
return;
}
if (msg.role !== 'assistant') {
console.log('not assistant');
return;
}
const action = msg.content.response[actionIndex].content as z.infer<typeof CopilotAssistantMessageActionPart>['content'];
if (!action) {
console.log('no action');
return;
}
console.log('reached here');
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'add_agent',
agent: {
name: action.name,
...action.config_changes
}
});
break;
case 'tool':
dispatch({
type: 'add_tool',
tool: {
name: action.name,
...action.config_changes
}
});
break;
case 'prompt':
dispatch({
type: 'add_prompt',
prompt: {
name: action.name,
...action.config_changes
}
});
break;
}
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
} else if (action.action === 'edit') {
const changes = field
? { [field]: action.config_changes[field] }
: action.config_changes;
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: changes
});
break;
}
const appliedKeys = Object.keys(changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
}
}, [dispatch, appliedChanges, messages]);
// Effect for handling copilot responses
// Effect for getting copilot response
useEffect(() => {
let ignore = false;
if (!messages.length || messages.at(-1)?.role !== 'user') return;
async function process() {
if (!messages.length) return;
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'user') return;
setLoadingResponse(true);
setCurrentStatus('thinking');
const currentStart = startRef.current;
const currentCancel = cancelRef.current;
// Start cycling through statuses
statusIntervalRef.current = setInterval(() => {
setCurrentStatus(prev => {
if (prev === 'thinking') return 'planning';
if (prev === 'planning') return 'generating';
return 'generating'; // Stay on generating once reached
});
}, 3000);
try {
const response = await getCopilotResponse(
projectId,
messages,
workflow,
effectiveContext || null,
);
if (ignore) return;
setLastRequest(response.rawRequest);
setLastResponse(response.rawResponse);
setMessages(currentMessages => [...currentMessages, response.message]);
} catch (err) {
if (!ignore) {
setResponseError(`Failed to get copilot response: ${err instanceof Error ? err.message : 'Unknown error'}`);
currentStart(messages, (finalResponse: string) => {
setMessages(prev => [
...prev,
{
role: 'assistant',
content: finalResponse
}
} finally {
if (!ignore) {
setLoadingResponse(false);
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
}
}
}
}
]);
});
process();
return () => {
ignore = true;
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
}
};
}, [messages, projectId, workflow, effectiveContext]);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loadingResponse]);
return () => currentCancel();
}, [messages]); // Only depend on messages
const handleCopyChat = useCallback(() => {
if (onCopyJson) {
onCopyJson({
messages,
lastRequest,
lastResponse,
});
}
}, [messages, lastRequest, lastResponse, onCopyJson]);
}, [messages, onCopyJson]);
useImperativeHandle(ref, () => ({
handleCopyChat
}), [handleCopyChat]);
return (
<CopilotContext.Provider value={{ workflow, handleApplyChange, appliedChanges }}>
<CopilotContext.Provider value={{ workflow: workflowRef.current, dispatch }}>
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<Messages
messages={messages}
streamingResponse={streamingResponse}
loadingResponse={loadingResponse}
currentStatus={currentStatus}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
workflow={workflowRef.current}
dispatch={dispatch}
/>
</div>
<div className="shrink-0 px-1 pb-6">
@ -287,7 +151,9 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
<Button
size="sm"
color="danger"
onClick={() => setResponseError(null)}
onClick={() => {
setMessages(prev => [...prev.slice(0, -1)]); // remove last assistant if needed
}}
>
Retry
</Button>
@ -313,10 +179,10 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
handleUserMessage={handleUserMessage}
messages={messages}
loading={loadingResponse}
disabled={loadingResponse}
initialFocus={isInitialState}
shouldAutoFocus={isLastInteracted}
onFocus={() => setIsLastInteracted(true)}
onCancel={cancel}
/>
</div>
</div>
@ -347,7 +213,7 @@ export function Copilot({
setMessages([]);
}
function handleCopyJson(data: { messages: any[], lastRequest: any, lastResponse: any }) {
function handleCopyJson(data: { messages: any[] }) {
const jsonString = JSON.stringify(data, null, 2);
navigator.clipboard.writeText(jsonString);
setShowCopySuccess(true);

View file

@ -1,5 +1,5 @@
'use client';
import { createContext, useContext, useState } from "react";
import { createContext, useContext, useRef, useState } from "react";
import clsx from "clsx";
import { z } from "zod";
import { CopilotAssistantMessageActionPart } from "../../../../lib/types/copilot_types";
@ -7,35 +7,34 @@ import { Workflow } from "../../../../lib/types/workflow_types";
import { PreviewModalProvider, usePreviewModal } from '../../workflow/preview-modal';
import { getAppliedChangeKey } from "../app";
import { AlertTriangleIcon, CheckCheckIcon, CheckIcon, ChevronsDownIcon, ChevronsUpIcon, EyeIcon, PencilIcon, PlusIcon } from "lucide-react";
import { Spinner } from "@heroui/react";
const ActionContext = createContext<{
msgIndex: number;
actionIndex: number;
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'] | null;
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedFields: string[];
stale: boolean;
}>({ msgIndex: 0, actionIndex: 0, action: null, workflow: null, handleApplyChange: () => { }, appliedFields: [], stale: false });
}>({ msgIndex: 0, actionIndex: 0, action: null, workflow: null, appliedFields: [], stale: false });
export function Action({
msgIndex,
actionIndex,
action,
workflow,
handleApplyChange,
appliedChanges,
dispatch,
stale,
}: {
msgIndex: number;
actionIndex: number;
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
workflow: z.infer<typeof Workflow>;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
dispatch: (action: any) => void;
stale: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
if (!action || typeof action !== 'object') {
console.warn('Invalid action object:', action);
@ -49,17 +48,115 @@ export function Action({
appliedFields.includes(key)
);
// generate apply change function
const applyChangeHandler = () => {
handleApplyChange(msgIndex, actionIndex);
}
// Handle applying a single field change
const handleFieldChange = (field: string) => {
const changes = { [field]: action.config_changes[field] };
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: changes
});
break;
}
setAppliedChanges(prev => ({
...prev,
[getAppliedChangeKey(msgIndex, actionIndex, field)]: true
}));
};
// Handle applying all changes
const handleApplyAll = () => {
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'add_agent',
agent: {
name: action.name,
...action.config_changes
}
});
break;
case 'tool':
dispatch({
type: 'add_tool',
tool: {
name: action.name,
...action.config_changes
}
});
break;
case 'prompt':
dispatch({
type: 'add_prompt',
prompt: {
name: action.name,
...action.config_changes
}
});
break;
}
} else if (action.action === 'edit') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: action.config_changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: action.config_changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: action.config_changes
});
break;
}
}
// Mark all fields as applied
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(msgIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges(prev => ({
...prev,
...appliedKeys
}));
};
return <div className={clsx('flex flex-col rounded-sm border border-t-4', {
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': !stale && !allApplied && action.action == 'create_new',
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': !stale && !allApplied && action.action == 'edit',
'bg-gray-100 dark:bg-gray-800/30 border-gray-400 dark:border-gray-600 border-t-gray-400': stale || allApplied || action.error,
})}>
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale }}>
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, appliedFields, stale }}>
<ActionHeader />
<ActionSummary />
{expanded && <PreviewModalProvider>
@ -69,7 +166,7 @@ export function Action({
</div>}
<div className="flex flex-col gap-2 px-1">
{Object.entries(action.config_changes).map(([key, value]) => {
return <ActionField key={key} field={key} />
return <ActionField key={key} field={key} onApply={handleFieldChange} />
})}
</div>
</PreviewModalProvider>}
@ -82,7 +179,7 @@ export function Action({
</div>}
{!action.error && <button
className="grow rounded-l-sm bg-blue-100 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/30 disabled:bg-gray-100 dark:disabled:bg-gray-800/30 disabled:text-gray-300 dark:disabled:text-gray-600 flex flex-col items-center justify-center h-8"
onClick={applyChangeHandler}
onClick={handleApplyAll}
disabled={stale || allApplied}
>
<div className="flex items-center gap-2 justify-center">
@ -108,7 +205,7 @@ export function Action({
}
export function ActionSummary() {
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
const { msgIndex, actionIndex, action, workflow, appliedFields, stale } = useContext(ActionContext);
if (!action || !workflow) return null;
return <div className="px-1 my-1">
@ -119,7 +216,7 @@ export function ActionSummary() {
}
export function ActionHeader() {
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
const { msgIndex, actionIndex, action, workflow, appliedFields, stale } = useContext(ActionContext);
if (!action || !workflow) return null;
const targetType = action.config_type === 'tool' ? 'tool' : action.config_type === 'agent' ? 'agent' : 'prompt';
@ -134,10 +231,12 @@ export function ActionHeader() {
export function ActionField({
field,
onApply,
}: {
field: string;
onApply: (field: string) => void;
}) {
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
const { msgIndex, actionIndex, action, workflow, appliedFields, stale } = useContext(ActionContext);
const { showPreview } = usePreviewModal();
if (!action || !workflow) return null;
@ -178,11 +277,6 @@ export function ActionField({
(action.config_type === 'agent' && field === 'examples') ||
(action.config_type === 'prompt' && field === 'prompt') ||
(action.config_type === 'tool' && field === 'description');
// generate apply change function
const applyChangeHandler = () => {
handleApplyChange(msgIndex, actionIndex, field);
}
// generate preview modal function
const previewModalHandler = () => {
@ -193,7 +287,7 @@ export function ActionField({
markdownPreviewCondition,
`${action.name} - ${field}`,
"Review changes",
applyChangeHandler
() => onApply(field)
);
}
}
@ -213,7 +307,7 @@ export function ActionField({
'text-green-600 dark:text-green-400': applied,
'text-gray-600 dark:text-gray-400': stale,
})}
onClick={applyChangeHandler}
onClick={() => onApply(field)}
disabled={stale || applied}
>
<CheckIcon size={16} />
@ -226,4 +320,36 @@ export function ActionField({
</div>
</div>
</div>;
}
export function StreamingAction({
action,
loading,
}: {
action: {
action?: 'create_new' | 'edit';
config_type?: 'tool' | 'agent' | 'prompt';
name?: string;
};
loading: boolean;
}) {
return <div className={clsx('flex flex-col rounded-sm border border-t-4', {
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': action.action == 'create_new',
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': action.action == 'edit',
})}>
<div className="flex gap-2 items-center py-1 px-1">
{action.action == 'create_new' && <PlusIcon size={16} />}
{action.action == 'edit' && <PencilIcon size={16} />}
<div className="text-sm truncate">
{action.config_type && `${action.action === 'create_new' ? 'Create' : 'Edit'} ${action.config_type}`}
{action.name && <span className="font-medium ml-1">{action.name}</span>}
</div>
</div>
<div className="px-1 my-1">
<div className="bg-white dark:bg-gray-800 rounded-sm p-2 text-sm flex items-center gap-2">
{loading && <Spinner size="sm" />}
{!loading && <div className="text-gray-400">Canceled</div>}
</div>
</div>
</div>;
}

View file

@ -2,11 +2,108 @@
import { Spinner } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Workflow } from "@/app/lib/types/workflow_types";
import { Workflow, WorkflowTool, WorkflowAgent, WorkflowPrompt } from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage } from "@/app/lib/types/copilot_types";
import { Action } from './actions';
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/app/lib/types/copilot_types";
import { Action, StreamingAction } from './actions';
import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils";
const CopilotResponsePart = z.union([
z.object({
type: z.literal('text'),
content: z.string(),
}),
z.object({
type: z.literal('streaming_action'),
action: CopilotAssistantMessageActionPart.shape.content.partial(),
}),
z.object({
type: z.literal('action'),
action: CopilotAssistantMessageActionPart.shape.content,
}),
]);
function enrich(response: string): z.infer<typeof CopilotResponsePart> {
// If it's not a code block, return as text
if (!response.trim().startsWith('//')) {
return {
type: 'text',
content: response
};
}
// Parse the metadata from comments
const lines = response.trim().split('\n');
const metadata: Record<string, string> = {};
let jsonStartIndex = 0;
// Parse metadata from comment lines
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.startsWith('//')) {
jsonStartIndex = i;
break;
}
const [key, value] = line.substring(2).trim().split(':').map(s => s.trim());
if (key && value) {
metadata[key] = value;
}
}
// Try to parse the JSON part
try {
const jsonContent = lines.slice(jsonStartIndex).join('\n');
const jsonData = JSON.parse(jsonContent);
// If we have all required metadata, validate the config changes
if (metadata.action && metadata.config_type && metadata.name) {
const result = validateConfigChanges(
metadata.config_type,
jsonData.config_changes || {},
metadata.name
);
if ('error' in result) {
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: {},
error: result.error
}
};
}
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
}
};
}
} catch (e) {
// JSON parsing failed - this is likely a streaming block
}
// Return as streaming action with whatever metadata we have
return {
type: 'streaming_action',
action: {
action: (metadata.action as 'create_new' | 'edit') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt') || undefined,
name: metadata.name
}
};
}
function UserMessage({ content }: { content: string }) {
return (
@ -15,7 +112,7 @@ function UserMessage({ content }: { content: string }) {
rounded-lg text-sm leading-relaxed
text-gray-700 dark:text-gray-200
border border-blue-100 dark:border-[#2a2d31]
shadow-sm animate-slideUpAndFade">
shadow-sm animate-[slideUpAndFade_150ms_ease-out]">
<div className="text-left">
<MarkdownContent content={content} />
</div>
@ -30,7 +127,7 @@ function InternalAssistantMessage({ content }: { content: string }) {
return (
<div className="w-full">
{!expanded ? (
<button className="flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 gap-1 group"
<button className="flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 gap-1 group"
onClick={() => setExpanded(true)}>
<MessageSquareIcon size={16} />
<EllipsisIcon size={16} />
@ -42,7 +139,7 @@ function InternalAssistantMessage({ content }: { content: string }) {
px-4 py-2.5 rounded-lg text-sm
text-gray-700 dark:text-gray-200 shadow-sm">
<div className="flex justify-end mb-2">
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onClick={() => setExpanded(false)}>
<XIcon size={16} />
</button>
@ -55,40 +152,66 @@ function InternalAssistantMessage({ content }: { content: string }) {
);
}
function AssistantMessage({
content,
workflow,
handleApplyChange,
appliedChanges,
messageIndex
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
function AssistantMessage({
content,
workflow,
dispatch,
messageIndex,
loading
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>,
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void,
appliedChanges: Record<string, boolean>,
messageIndex: number
dispatch: (action: any) => void,
messageIndex: number,
loading: boolean
}) {
const blocks = useParsedBlocks(content);
// parse actions from parts
let parsed: z.infer<typeof CopilotResponsePart>[] = [];
for (const block of blocks) {
if (block.type === 'text') {
parsed.push({
type: 'text',
content: block.content,
});
} else {
parsed.push(enrich(block.content));
}
}
// split the content into parts
return (
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-4">
<div className="text-left flex flex-col gap-4">
{content.response.map((part, actionIndex) => {
if (part.type === "text") {
return <MarkdownContent key={actionIndex} content={part.content} />;
} else if (part.type === "action") {
{parsed.map((part, actionIndex) => {
if (part.type === 'text') {
return <MarkdownContent
key={actionIndex}
content={part.content}
/>;
}
if (part.type === 'streaming_action') {
return <StreamingAction
key={actionIndex}
action={part.action}
loading={loading}
/>;
}
if (part.type === 'action') {
return <Action
key={actionIndex}
msgIndex={messageIndex}
actionIndex={actionIndex}
action={part.content}
action={part.action}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
dispatch={dispatch}
stale={false}
/>;
}
return null;
})}
</div>
</div>
@ -119,23 +242,40 @@ function AssistantMessageLoading({ currentStatus }: { currentStatus: 'thinking'
export function Messages({
messages,
streamingResponse,
loadingResponse,
currentStatus,
workflow,
handleApplyChange,
appliedChanges
dispatch
}: {
messages: z.infer<typeof CopilotMessage>[];
streamingResponse: string;
loadingResponse: boolean;
currentStatus: 'thinking' | 'planning' | 'generating';
workflow: z.infer<typeof Workflow>;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
dispatch: (action: any) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayMessages, setDisplayMessages] = useState(messages);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
if (loadingResponse) {
setDisplayMessages([...messages, {
role: 'assistant',
content: streamingResponse
}]);
}
}, [messages, loadingResponse, streamingResponse]);
useEffect(() => {
// Small delay to ensure content is rendered
const timeoutId = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest"
});
}, 100);
return () => clearTimeout(timeoutId);
}, [messages, loadingResponse]);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
@ -145,31 +285,31 @@ export function Messages({
key={messageIndex}
content={message.content}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
dispatch={dispatch}
messageIndex={messageIndex}
loading={loadingResponse}
/>
);
}
if (message.role === 'user' && typeof message.content === 'string') {
return <UserMessage key={messageIndex} content={message.content} />;
}
return null;
};
return (
<div className="h-full">
<div className="flex flex-col [&>*]:mb-4">
{messages.map((message, index) => (
<div className="flex flex-col mb-4">
{displayMessages.map((message, index) => (
<div key={index} className="mb-4">
{renderMessage(message, index)}
</div>
))}
{loadingResponse && (
<div className="animate-pulse">
<AssistantMessageLoading currentStatus={currentStatus} />
<div className="text-xs text-gray-500">
<Spinner size="sm" className="ml-2" />
</div>
)}
</div>

View file

@ -0,0 +1,33 @@
This is a response in markdown from the copilot.
This is some text.
I'm adding a tool `get_status()` below:
```copilot_change
// action: create_new
// config_type: tool
// name: get_status
{
"change_description": "added a new tool...",
"config_changes": {
// same as before
}
}
```
I'm also updating the example agent:
```copilot_change
// action: edit
// config_type: agent
// name: Example agent
{
"change_description": "updated the instructions...",
"config_changes": {
// same as before
}
}
```
This concludes my changes. Would you like some more help?

View file

@ -0,0 +1,88 @@
import { useCallback, useRef, useState } from "react";
import { getCopilotResponseStream } from "@/app/actions/copilot_actions";
import { CopilotMessage } from "@/app/lib/types/copilot_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { z } from "zod";
interface UseCopilotParams {
projectId: string;
workflow: z.infer<typeof Workflow>;
context: any;
}
interface UseCopilotResult {
streamingResponse: string;
loading: boolean;
error: string | null;
start: (
messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void
) => void;
cancel: () => void;
}
export function useCopilot({ projectId, workflow, context }: UseCopilotParams): UseCopilotResult {
const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancelRef = useRef<() => void>(() => { });
const responseRef = useRef('');
const start = useCallback(async (
messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void
) => {
if (!messages.length || messages.at(-1)?.role !== 'user') return;
setStreamingResponse('');
responseRef.current = '';
setError(null);
setLoading(true);
try {
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null);
const eventSource = new EventSource(`/api/v1/copilot-stream-response/${res.streamId}`);
eventSource.onmessage = (event) => {
try {
const { content } = JSON.parse(event.data);
responseRef.current += content;
setStreamingResponse(prev => prev + content);
} catch (e) {
setError('Failed to parse stream message');
}
};
eventSource.addEventListener('done', () => {
eventSource.close();
setLoading(false);
onDone(responseRef.current);
});
eventSource.onerror = () => {
eventSource.close();
setError('Streaming failed');
setLoading(false);
};
cancelRef.current = () => eventSource.close();
} catch (err) {
setError('Failed to initiate stream');
setLoading(false);
}
}, [projectId, workflow, context]);
const cancel = useCallback(() => {
cancelRef.current?.();
setLoading(false);
}, []);
return {
streamingResponse,
loading,
error,
start,
cancel,
};
}

View file

@ -0,0 +1,30 @@
import { useMemo } from "react";
type Block =
| { type: "text"; content: string }
| { type: "code"; content: string };
const copilotCodeMarker = "copilot_change\n";
function parseMarkdown(markdown: string): Block[] {
// Split on triple backticks but keep the delimiters
// This gives us the raw content between and including delimiters
const parts = markdown.split("```");
const blocks: Block[] = [];
for (const part of parts) {
if (part.trim().startsWith(copilotCodeMarker)) {
blocks.push({ type: 'code', content: part.slice(copilotCodeMarker.length) });
} else {
blocks.push({ type: 'text', content: part });
}
}
return blocks;
}
export function useParsedBlocks(text: string): Block[] {
return useMemo(() => {
return parseMarkdown(text);
}, [text]);
}

View file

@ -18,20 +18,20 @@ interface ComposeBoxCopilotProps {
handleUserMessage: (message: string) => void;
messages: any[];
loading: boolean;
disabled?: boolean;
initialFocus?: boolean;
shouldAutoFocus?: boolean;
onFocus?: () => void;
onCancel?: () => void;
}
export function ComposeBoxCopilot({
handleUserMessage,
messages,
loading,
disabled = false,
initialFocus = false,
shouldAutoFocus = false,
onFocus,
onCancel,
}: ComposeBoxCopilotProps) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
@ -94,7 +94,7 @@ export function ComposeBoxCopilot({
onKeyDown={handleInputKeyDown}
onFocus={handleFocus}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
disabled={loading}
placeholder="Type a message..."
autoResize={true}
maxHeight={120}
@ -118,13 +118,15 @@ export function ComposeBoxCopilot({
<Button
size="sm"
isIconOnly
disabled={disabled || loading || !input.trim()}
onPress={handleInput}
disabled={!loading && !input.trim()}
onPress={loading ? onCancel : handleInput}
className={`
transition-all duration-200
${input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
${loading
? 'bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-900/50 dark:hover:bg-red-800/60 dark:text-red-300'
: input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}
scale-100 hover:scale-105 active:scale-95
disabled:opacity-50 disabled:scale-95
@ -133,7 +135,7 @@ export function ComposeBoxCopilot({
`}
>
{loading ? (
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
<StopIcon size={16} />
) : (
<SendIcon
size={16}
@ -165,3 +167,19 @@ function SendIcon({ size, className }: { size: number, className?: string }) {
</svg>
);
}
// Custom StopIcon component for better visual alignment
function StopIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
className={className}
>
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
);
}