mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Use streaming in Copilot
This commit is contained in:
parent
ba14bc9bd8
commit
49ca5a7154
19 changed files with 1303 additions and 839 deletions
|
|
@ -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>[],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
33
apps/rowboat/app/projects/[projectId]/copilot/example.md
Normal file
33
apps/rowboat/app/projects/[projectId]/copilot/example.md
Normal 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?
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue