-
Notifications
You must be signed in to change notification settings - Fork 118
Expand file tree
/
Copy pathchat-provider.tsx
More file actions
341 lines (298 loc) · 10 KB
/
chat-provider.tsx
File metadata and controls
341 lines (298 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
"use client";
import { useSearchParams } from "next/navigation";
import {
useState,
useEffect,
createContext,
PropsWithChildren,
useContext,
} from "react";
import {toast} from "sonner";
import {getErrorMessage} from "@/lib/error-utils";
interface Message {
id: number;
role: string;
content: string;
}
// Draft messages are used to optmistically update the UI
// before the server responds.
interface DraftMessage extends Omit<Message, "id"> {
id?: number;
}
interface MessageUpdateEvent {
id: number;
role: string;
message: string;
time: string;
}
interface StatusChangeEvent {
status: string;
agent_type: string;
}
interface APIErrorDetail {
location: string;
message: string;
value: null | string | number | boolean | object;
}
interface APIErrorModel {
$schema: string;
detail: string;
errors: APIErrorDetail[];
instance: string;
status: number;
title: string;
type: string;
}
function isDraftMessage(message: Message | DraftMessage): boolean {
return message.id === undefined;
}
type MessageType = "user" | "raw";
export type ServerStatus = "stable" | "running" | "offline" | "unknown";
export interface FileUploadResponse {
ok: boolean;
filePath?: string;
}
export type AgentType = "claude" | "goose" | "aider" | "gemini" | "amp" | "codex" | "cursor" | "cursor-agent" | "copilot" | "auggie" | "amazonq" | "opencode" | "custom" | "unknown";
export type AgentColorDisplayNamePair = {
displayName: string;
}
export const AgentType: Record<Exclude<AgentType, "unknown">, AgentColorDisplayNamePair> = {
claude: {displayName: "Claude Code"},
goose: {displayName: "Goose"},
aider: {displayName: "Aider"},
gemini: { displayName: "Gemini"},
amp: {displayName: "Amp"},
codex: {displayName: "Codex"},
cursor: { displayName: "Cursor Agent"},
"cursor-agent": { displayName: "Cursor Agent"},
copilot: {displayName: "Copilot"},
auggie: {displayName: "Auggie"},
amazonq: {displayName: "Amazon Q"},
opencode: {displayName: "Opencode"},
custom: { displayName: "Custom"}
}
interface ChatContextValue {
messages: (Message | DraftMessage)[];
loading: boolean;
serverStatus: ServerStatus;
sendMessage: (message: string, type?: MessageType) => void;
uploadFiles: (formData: FormData) => Promise<FileUploadResponse>;
agentType: AgentType;
}
const ChatContext = createContext<ChatContextValue | undefined>(undefined);
const useAgentAPIUrl = (): string => {
const searchParams = useSearchParams();
const paramsUrl = searchParams.get("url");
if (paramsUrl) {
return paramsUrl;
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
if (!basePath) {
throw new Error(
"agentAPIUrl is not set. Please set the url query parameter to the URL of the AgentAPI or the NEXT_PUBLIC_BASE_PATH environment variable."
);
}
// NOTE(cian): We use '../' here to construct the agent API URL relative
// to the chat's location. Let's say the app is hosted on a subpath
// `/@admin/workspace.agent/apps/ccw/`. When you visit this URL you get
// redirected to `/@admin/workspace.agent/apps/ccw/chat/embed`. This serves
// this React application, but it needs to know where the agent API is hosted.
// This will be at the root of where the application is mounted e.g.
// `/@admin/workspace.agent/apps/ccw/`. Previously we used
// `window.location.origin` but this assumes that the application owns the
// entire origin.
// See: https://github.com/coder/coder/issues/18779#issuecomment-3133290494 for more context.
let chatURL: string = new URL(basePath, window.location.origin).toString();
// NOTE: trailing slashes and relative URLs are tricky.
// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#current_directory_relative
if (!chatURL.endsWith("/")) {
chatURL += "/";
}
const agentAPIURL = new URL("..", chatURL).toString();
if (agentAPIURL.endsWith("/")) {
return agentAPIURL.slice(0, -1);
}
return agentAPIURL;
};
export function ChatProvider({ children }: PropsWithChildren) {
const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
const [agentType, setAgentType] = useState<AgentType>("custom");
const agentAPIUrl = useAgentAPIUrl();
// Set up SSE connection to the events endpoint. EventSource handles
// reconnection automatically, so we only create it once per URL and
// let the browser manage transient failures. Messages are NOT cleared
// on reconnect to avoid blanking the conversation on network blips.
useEffect(() => {
if (!agentAPIUrl) {
console.warn(
"agentAPIUrl is not set, SSE connection cannot be established."
);
setServerStatus("offline");
return;
}
const eventSource = new EventSource(`${agentAPIUrl}/events`);
// Handle message updates
eventSource.addEventListener("message_update", (event) => {
const data: MessageUpdateEvent = JSON.parse(event.data);
const confirmed: Message = {
role: data.role,
content: data.message,
id: data.id,
};
setMessages((prevMessages) => {
// Check if message with this ID already exists
const existingIndex = prevMessages.findIndex(
(m) => m.id === data.id
);
if (existingIndex !== -1) {
// Update existing message
const updated = [...prevMessages];
updated[existingIndex] = confirmed;
return updated;
}
// New confirmed message: replace any trailing draft that matches
// the same role (the optimistic message we inserted on send).
const last = prevMessages[prevMessages.length - 1];
if (last && isDraftMessage(last) && last.role === confirmed.role) {
return [...prevMessages.slice(0, -1), confirmed];
}
return [...prevMessages, confirmed];
});
});
// Handle status changes
eventSource.addEventListener("status_change", (event) => {
const data: StatusChangeEvent = JSON.parse(event.data);
if (data.status === "stable") {
setServerStatus("stable");
} else if (data.status === "running") {
setServerStatus("running");
} else {
setServerStatus("unknown");
}
// Set agent type
setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType);
});
eventSource.onopen = () => {
console.log("EventSource connection established");
};
// Mark offline on error. The browser will retry automatically.
eventSource.onerror = () => {
setServerStatus("offline");
};
return () => eventSource.close();
}, [agentAPIUrl]);
// Send a new message
const sendMessage = async (
content: string,
type: "user" | "raw" = "user"
) => {
// For user messages, require non-empty content
if (type === "user" && !content.trim()) return;
// For raw messages, don't set loading state as it's usually fast
if (type === "user") {
setMessages((prevMessages) => [
...prevMessages,
{ role: "user", content },
]);
setLoading(true);
}
try {
const response = await fetch(`${agentAPIUrl}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: content,
type,
}),
});
if (!response.ok) {
const errorData = await response.json() as APIErrorModel;
console.error("Failed to send message:", errorData);
const detail = errorData.detail;
const messages =
"errors" in errorData
?
errorData.errors.map((e: APIErrorDetail) => e.message).join(", ")
: "";
const fullDetail = `${detail}: ${messages}`;
toast.error(`Failed to send message`, {
description: fullDetail,
});
// Remove the optimistic draft since the server rejected it.
setMessages((prev) => prev.filter((m) => !isDraftMessage(m)));
}
} catch (error) {
console.error("Error sending message:", error);
const message = getErrorMessage(error)
toast.error(`Error sending message`, {
description: message,
});
// Remove the optimistic draft since the request failed.
setMessages((prev) => prev.filter((m) => !isDraftMessage(m)));
} finally {
if (type === "user") {
setLoading(false);
}
}
};
// Upload files to workspace
const uploadFiles = async (formData: FormData): Promise<FileUploadResponse> => {
let result: FileUploadResponse = {ok: true};
try{
const response = await fetch(`${agentAPIUrl}/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
result.ok = false;
const errorData = await response.json() as APIErrorModel;
console.error("Failed to send message:", errorData);
const detail = errorData.detail;
const messages =
"errors" in errorData
?
errorData.errors.map((e: APIErrorDetail) => e.message).join(", ")
: "";
const fullDetail = `${detail}: ${messages}`;
toast.error(`Failed to upload files`, {
description: fullDetail,
});
} else {
result = (await response.json()) as FileUploadResponse;
}
} catch (error) {
result.ok = false;
console.error("Error uploading files:", error);
const message = getErrorMessage(error)
toast.error(`Error uploading files`, {
description: message,
});
}
return result;
}
return (
<ChatContext.Provider
value={{
messages,
loading,
sendMessage,
serverStatus,
uploadFiles,
agentType,
}}
>
{children}
</ChatContext.Provider>
);
}
export function useChat() {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
}