@arcote.tech/arc-chat 0.4.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/aggregates/message.ts +68 -78
- package/src/chat-builder.ts +75 -51
- package/src/index.ts +22 -4
- package/src/listeners/ai-generation-listener.ts +293 -0
- package/src/react/index.ts +4 -0
- package/src/react/use-chat.ts +260 -0
- package/src/routes/chat-stream-route.ts +31 -0
- package/src/routes/tool-results-route.ts +49 -0
- package/src/streaming/stream-registry.ts +146 -0
- package/src/aggregates/conversation.ts +0 -151
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/// <reference path="../arc.d.ts" />
|
|
2
|
+
import { listener, type ArcContextElement } from "@arcote.tech/arc";
|
|
3
|
+
import type { ArcToolAny, LLMProvider, Message, ToolContext } from "@arcote.tech/arc-ai";
|
|
4
|
+
import type { PrepareContext, PrepareParams, PrepareResult } from "../chat-builder";
|
|
5
|
+
import {
|
|
6
|
+
createStreamSession,
|
|
7
|
+
getStreamSession,
|
|
8
|
+
deleteStreamSession,
|
|
9
|
+
} from "../streaming/stream-registry";
|
|
10
|
+
|
|
11
|
+
// ─── Config ─────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface AiGenerationListenerConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
messageElement: any;
|
|
16
|
+
resolveProvider: (model: string) => LLMProvider | undefined;
|
|
17
|
+
prepare?: (ctx: PrepareContext, params: PrepareParams) => Promise<PrepareResult>;
|
|
18
|
+
tools: ArcToolAny[];
|
|
19
|
+
clientTools: ArcToolAny[];
|
|
20
|
+
toolMutationElements: ArcContextElement<any>[];
|
|
21
|
+
maxExecutionCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Factory ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
27
|
+
const {
|
|
28
|
+
name,
|
|
29
|
+
messageElement,
|
|
30
|
+
resolveProvider,
|
|
31
|
+
prepare,
|
|
32
|
+
tools: defaultTools,
|
|
33
|
+
clientTools: defaultClientTools,
|
|
34
|
+
toolMutationElements,
|
|
35
|
+
maxExecutionCount: defaultMaxExecution,
|
|
36
|
+
} = config;
|
|
37
|
+
|
|
38
|
+
const messageSentEvent = messageElement.getEvent("messageSent");
|
|
39
|
+
|
|
40
|
+
return listener(`${name}AiGeneration`)
|
|
41
|
+
.listenTo([messageSentEvent])
|
|
42
|
+
.async()
|
|
43
|
+
.query([messageElement])
|
|
44
|
+
.mutate([messageElement, ...toolMutationElements])
|
|
45
|
+
.handle(async (ctx, event) => {
|
|
46
|
+
const payload = event.payload;
|
|
47
|
+
const {
|
|
48
|
+
sessionId,
|
|
49
|
+
scopeId,
|
|
50
|
+
content: userContent,
|
|
51
|
+
model: modelName,
|
|
52
|
+
} = payload;
|
|
53
|
+
|
|
54
|
+
// 1. Get or create stream session
|
|
55
|
+
let session = getStreamSession(sessionId);
|
|
56
|
+
if (!session) {
|
|
57
|
+
session = createStreamSession(sessionId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Resolve provider
|
|
61
|
+
const model = modelName ?? "gpt-4o";
|
|
62
|
+
const provider = resolveProvider(model);
|
|
63
|
+
if (!provider) {
|
|
64
|
+
session.push({
|
|
65
|
+
type: "error",
|
|
66
|
+
sessionId,
|
|
67
|
+
error: `Provider not found for model: ${model}`,
|
|
68
|
+
});
|
|
69
|
+
session.close();
|
|
70
|
+
deleteStreamSession(sessionId);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Call prepare callback to get instructions, tools, clientTools
|
|
75
|
+
let instructions = "";
|
|
76
|
+
let serverTools = defaultTools;
|
|
77
|
+
let clientTools = defaultClientTools;
|
|
78
|
+
|
|
79
|
+
if (prepare) {
|
|
80
|
+
const prepareCtx: PrepareContext = {
|
|
81
|
+
query: (element) => ctx.query(element),
|
|
82
|
+
mutate: (element) => ctx.mutate(element),
|
|
83
|
+
};
|
|
84
|
+
const prepareResult = await prepare(prepareCtx, {
|
|
85
|
+
content: userContent,
|
|
86
|
+
identifyBy: scopeId,
|
|
87
|
+
model,
|
|
88
|
+
});
|
|
89
|
+
instructions = prepareResult.instructions;
|
|
90
|
+
if (prepareResult.tools) serverTools = prepareResult.tools;
|
|
91
|
+
if (prepareResult.clientTools) clientTools = prepareResult.clientTools;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build server tools map
|
|
95
|
+
const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
|
|
96
|
+
const serverToolNames = [...serverToolsMap.keys()];
|
|
97
|
+
|
|
98
|
+
// Build tool defs for LLM (server + client)
|
|
99
|
+
const allToolsForLLM = [...serverTools, ...clientTools];
|
|
100
|
+
const toolDefs = allToolsForLLM.length > 0
|
|
101
|
+
? allToolsForLLM.map((t) => t.toJsonSchema())
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
// 4. Load conversation history
|
|
105
|
+
const history = await ctx.query(messageElement).getByScope({ scopeId });
|
|
106
|
+
|
|
107
|
+
// 5. Build messages array
|
|
108
|
+
const messages: Message[] = [];
|
|
109
|
+
|
|
110
|
+
if (instructions) {
|
|
111
|
+
messages.push({ role: "system", content: instructions });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const msg of history) {
|
|
115
|
+
if (msg.role === "user" && msg.content === userContent && msg._id === payload.messageId) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
messages.push({
|
|
119
|
+
role: msg.role as Message["role"],
|
|
120
|
+
content: msg.content,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
messages.push({ role: "user", content: userContent });
|
|
125
|
+
|
|
126
|
+
// 6. Build tool context for server tool execution
|
|
127
|
+
const toolCtx: ToolContext = {
|
|
128
|
+
mutate: (element) => ctx.mutate(element),
|
|
129
|
+
query: (element) => ctx.query(element),
|
|
130
|
+
identifyBy: scopeId,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// 7. AI generation loop
|
|
134
|
+
let executionCount = 0;
|
|
135
|
+
let fullContent = "";
|
|
136
|
+
let previousResponseId: string | undefined;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
while (executionCount <= defaultMaxExecution) {
|
|
140
|
+
const result = await provider.streamComplete(
|
|
141
|
+
{ model, messages, tools: toolDefs, previousResponseId },
|
|
142
|
+
(chunk) => {
|
|
143
|
+
switch (chunk.type) {
|
|
144
|
+
case "content_delta":
|
|
145
|
+
if (chunk.content) {
|
|
146
|
+
fullContent += chunk.content;
|
|
147
|
+
session!.push({
|
|
148
|
+
type: "content_delta",
|
|
149
|
+
sessionId,
|
|
150
|
+
content: chunk.content,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case "usage_update":
|
|
155
|
+
session!.push({
|
|
156
|
+
type: "usage_update",
|
|
157
|
+
sessionId,
|
|
158
|
+
usage: chunk.usage,
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (result.content) {
|
|
166
|
+
fullContent = result.content;
|
|
167
|
+
}
|
|
168
|
+
previousResponseId = result.responseId;
|
|
169
|
+
|
|
170
|
+
// No tool calls — generation complete
|
|
171
|
+
if (
|
|
172
|
+
result.finishReason !== "tool_call" ||
|
|
173
|
+
result.toolCalls.length === 0
|
|
174
|
+
) {
|
|
175
|
+
await ctx.mutate(messageElement).completeAssistantMessage({
|
|
176
|
+
scopeId,
|
|
177
|
+
sessionId,
|
|
178
|
+
content: fullContent,
|
|
179
|
+
model,
|
|
180
|
+
usage: JSON.stringify(result.usage),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
session.push({
|
|
184
|
+
type: "done",
|
|
185
|
+
sessionId,
|
|
186
|
+
usage: result.usage,
|
|
187
|
+
finishReason: result.finishReason,
|
|
188
|
+
executionCount,
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Separate server vs client tool calls
|
|
194
|
+
const serverCalls = result.toolCalls.filter((tc) =>
|
|
195
|
+
serverToolNames.includes(tc.name),
|
|
196
|
+
);
|
|
197
|
+
const clientCalls = result.toolCalls.filter(
|
|
198
|
+
(tc) => !serverToolNames.includes(tc.name),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Execute server tools with aggregate context
|
|
202
|
+
for (const tc of serverCalls) {
|
|
203
|
+
session.push({
|
|
204
|
+
type: "server_tool_start",
|
|
205
|
+
sessionId,
|
|
206
|
+
toolCall: tc,
|
|
207
|
+
executionCount,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const tool = serverToolsMap.get(tc.name);
|
|
211
|
+
let resultContent: string;
|
|
212
|
+
let isError = false;
|
|
213
|
+
|
|
214
|
+
if (tool) {
|
|
215
|
+
try {
|
|
216
|
+
resultContent = await tool.executeWithContext(tc.arguments, toolCtx);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
resultContent = `Tool execution error: ${err instanceof Error ? err.message : String(err)}`;
|
|
219
|
+
isError = true;
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
resultContent = `Tool "${tc.name}" not found on server`;
|
|
223
|
+
isError = true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
session.push({
|
|
227
|
+
type: "server_tool_result",
|
|
228
|
+
sessionId,
|
|
229
|
+
toolResult: {
|
|
230
|
+
toolCallId: tc.id,
|
|
231
|
+
name: tc.name,
|
|
232
|
+
content: resultContent,
|
|
233
|
+
isError,
|
|
234
|
+
},
|
|
235
|
+
executionCount,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
messages.push({
|
|
239
|
+
role: "tool",
|
|
240
|
+
content: resultContent,
|
|
241
|
+
toolCallId: tc.id,
|
|
242
|
+
name: tc.name,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Request client tool execution
|
|
247
|
+
if (clientCalls.length > 0) {
|
|
248
|
+
session.push({
|
|
249
|
+
type: "client_tool_request",
|
|
250
|
+
sessionId,
|
|
251
|
+
toolCalls: clientCalls,
|
|
252
|
+
executionCount,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const clientResults =
|
|
257
|
+
await session.waitForClientToolResults();
|
|
258
|
+
|
|
259
|
+
for (const tr of clientResults) {
|
|
260
|
+
messages.push({
|
|
261
|
+
role: "tool",
|
|
262
|
+
content: tr.content,
|
|
263
|
+
toolCallId: tr.toolCallId,
|
|
264
|
+
name: tr.name,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
session.push({
|
|
269
|
+
type: "error",
|
|
270
|
+
sessionId,
|
|
271
|
+
error: `Client tool execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
272
|
+
executionCount,
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fullContent = "";
|
|
279
|
+
executionCount++;
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
session.push({
|
|
283
|
+
type: "error",
|
|
284
|
+
sessionId,
|
|
285
|
+
error: `AI generation error: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
+
executionCount,
|
|
287
|
+
});
|
|
288
|
+
} finally {
|
|
289
|
+
session.close();
|
|
290
|
+
deleteStreamSession(sessionId);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ChatStreamEvent,
|
|
4
|
+
ToolCall,
|
|
5
|
+
ToolResult,
|
|
6
|
+
} from "@arcote.tech/arc-ai";
|
|
7
|
+
import type { ChatMessageData, ToolUse } from "@arcote.tech/arc-ds";
|
|
8
|
+
|
|
9
|
+
// ─── Config ─────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface UseChatConfig {
|
|
12
|
+
chatName: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
identifyBy?: string;
|
|
15
|
+
onClientToolCall?: (toolCalls: ToolCall[]) => Promise<ToolResult[]>;
|
|
16
|
+
onToolUse?: (toolCall: ToolCall, result: string) => ToolUse | undefined;
|
|
17
|
+
queries?: {
|
|
18
|
+
getByScope: (params: { scopeId: string }) => readonly [any[] | undefined, boolean];
|
|
19
|
+
};
|
|
20
|
+
mutations?: {
|
|
21
|
+
sendMessage: (params: Record<string, any>) => Promise<any>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Return Type ────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface UseChatReturn {
|
|
28
|
+
messages: ChatMessageData[];
|
|
29
|
+
isStreaming: boolean;
|
|
30
|
+
sendMessage: (content: string, options: { model: string }) => Promise<void>;
|
|
31
|
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessageData[]>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Hook ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function useChat(config: UseChatConfig): UseChatReturn {
|
|
37
|
+
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
|
38
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
39
|
+
|
|
40
|
+
const configRef = useRef(config);
|
|
41
|
+
configRef.current = config;
|
|
42
|
+
|
|
43
|
+
// Sync identifyBy and load history
|
|
44
|
+
const scopeId = config.identifyBy;
|
|
45
|
+
const historyResult = config.queries?.getByScope(
|
|
46
|
+
scopeId ? { scopeId } : { scopeId: "" },
|
|
47
|
+
);
|
|
48
|
+
const historyData = scopeId ? historyResult?.[0] : undefined;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!historyData || historyData.length === 0 || isStreaming) return;
|
|
52
|
+
|
|
53
|
+
const mapped: ChatMessageData[] = historyData.map((msg) => ({
|
|
54
|
+
id: msg._id,
|
|
55
|
+
role: msg.role as "user" | "assistant",
|
|
56
|
+
content: msg.content,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
setMessages(mapped);
|
|
60
|
+
}, [historyData?.length, scopeId]);
|
|
61
|
+
|
|
62
|
+
// Cleanup
|
|
63
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
return () => {
|
|
66
|
+
eventSourceRef.current?.close();
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const sendMessage = useCallback(
|
|
71
|
+
async (content: string, options: { model: string }) => {
|
|
72
|
+
if (isStreaming) return;
|
|
73
|
+
|
|
74
|
+
const { mutations, chatName, baseUrl, onClientToolCall, onToolUse } =
|
|
75
|
+
configRef.current;
|
|
76
|
+
const currentScopeId = configRef.current.identifyBy;
|
|
77
|
+
|
|
78
|
+
if (!mutations || !currentScopeId) {
|
|
79
|
+
console.error("useChat: mutations or identifyBy not provided");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setIsStreaming(true);
|
|
84
|
+
|
|
85
|
+
const userMsgId = `user_${Date.now()}`;
|
|
86
|
+
setMessages((prev) => [
|
|
87
|
+
...prev,
|
|
88
|
+
{ id: userMsgId, role: "user", content },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const sendResult = await mutations.sendMessage({
|
|
93
|
+
scopeId: currentScopeId,
|
|
94
|
+
content,
|
|
95
|
+
model: options.model,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const { sessionId } = sendResult as { sessionId: string; messageId: string };
|
|
99
|
+
if (!sessionId) {
|
|
100
|
+
throw new Error("No sessionId returned from sendMessage");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const routeBase = baseUrl ?? "";
|
|
104
|
+
const streamUrl = `${routeBase}/route/chat/${chatName}/stream/${sessionId}`;
|
|
105
|
+
|
|
106
|
+
const response = await fetch(streamUrl, {
|
|
107
|
+
credentials: "include",
|
|
108
|
+
headers: { Accept: "text/event-stream" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(`Stream connection failed: ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const assistantMsgId = `assistant_${Date.now()}`;
|
|
116
|
+
setMessages((prev) => [
|
|
117
|
+
...prev,
|
|
118
|
+
{ id: assistantMsgId, role: "assistant", content: "", isStreaming: true },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const reader = response.body!.getReader();
|
|
122
|
+
const decoder = new TextDecoder();
|
|
123
|
+
let partialLine = "";
|
|
124
|
+
const toolUses: ToolUse[] = [];
|
|
125
|
+
|
|
126
|
+
const processEvent = async (event: ChatStreamEvent) => {
|
|
127
|
+
switch (event.type) {
|
|
128
|
+
case "content_delta":
|
|
129
|
+
if (event.content) {
|
|
130
|
+
setMessages((prev) =>
|
|
131
|
+
prev.map((msg) =>
|
|
132
|
+
msg.id === assistantMsgId
|
|
133
|
+
? { ...msg, content: msg.content + event.content }
|
|
134
|
+
: msg,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case "server_tool_result":
|
|
141
|
+
if (event.toolResult && event.toolCall) {
|
|
142
|
+
const toolUse = onToolUse?.(event.toolCall, event.toolResult.content);
|
|
143
|
+
if (toolUse) {
|
|
144
|
+
toolUses.push(toolUse);
|
|
145
|
+
setMessages((prev) =>
|
|
146
|
+
prev.map((msg) =>
|
|
147
|
+
msg.id === assistantMsgId
|
|
148
|
+
? { ...msg, toolUses: [...toolUses] }
|
|
149
|
+
: msg,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case "client_tool_request":
|
|
157
|
+
if (event.toolCalls && onClientToolCall) {
|
|
158
|
+
try {
|
|
159
|
+
const results = await onClientToolCall(event.toolCalls);
|
|
160
|
+
|
|
161
|
+
const toolsUrl = `${routeBase}/route/chat/${chatName}/tools/${sessionId}`;
|
|
162
|
+
await fetch(toolsUrl, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
credentials: "include",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ toolResults: results }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
for (const tc of event.toolCalls) {
|
|
170
|
+
const result = results.find((r) => r.toolCallId === tc.id);
|
|
171
|
+
if (result) {
|
|
172
|
+
const toolUse = onToolUse?.(tc, result.content);
|
|
173
|
+
if (toolUse) toolUses.push(toolUse);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (toolUses.length > 0) {
|
|
178
|
+
setMessages((prev) =>
|
|
179
|
+
prev.map((msg) =>
|
|
180
|
+
msg.id === assistantMsgId
|
|
181
|
+
? { ...msg, toolUses: [...toolUses] }
|
|
182
|
+
: msg,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error("Client tool execution failed:", err);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
|
|
192
|
+
case "done":
|
|
193
|
+
setMessages((prev) =>
|
|
194
|
+
prev.map((msg) =>
|
|
195
|
+
msg.id === assistantMsgId
|
|
196
|
+
? { ...msg, isStreaming: false }
|
|
197
|
+
: msg,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
setIsStreaming(false);
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case "error":
|
|
204
|
+
setMessages((prev) =>
|
|
205
|
+
prev.map((msg) =>
|
|
206
|
+
msg.id === assistantMsgId
|
|
207
|
+
? { ...msg, content: msg.content || event.error || "An error occurred", isStreaming: false }
|
|
208
|
+
: msg,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
setIsStreaming(false);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
while (true) {
|
|
217
|
+
const { value, done } = await reader.read();
|
|
218
|
+
if (done) break;
|
|
219
|
+
|
|
220
|
+
const text = partialLine + decoder.decode(value, { stream: true });
|
|
221
|
+
const lines = text.split("\n");
|
|
222
|
+
partialLine = lines.pop() ?? "";
|
|
223
|
+
|
|
224
|
+
for (const line of lines) {
|
|
225
|
+
if (line.startsWith("data: ")) {
|
|
226
|
+
try {
|
|
227
|
+
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
228
|
+
await processEvent(event);
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (partialLine.startsWith("data: ")) {
|
|
235
|
+
try {
|
|
236
|
+
const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
|
|
237
|
+
await processEvent(event);
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setIsStreaming(false);
|
|
242
|
+
setMessages((prev) =>
|
|
243
|
+
prev.map((msg) =>
|
|
244
|
+
msg.id === assistantMsgId ? { ...msg, isStreaming: false } : msg,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
249
|
+
setMessages((prev) => [
|
|
250
|
+
...prev,
|
|
251
|
+
{ id: `error_${Date.now()}`, role: "assistant" as const, content: `Error: ${errorMsg}` },
|
|
252
|
+
]);
|
|
253
|
+
setIsStreaming(false);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
[isStreaming],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return { messages, isStreaming, sendMessage, setMessages };
|
|
260
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { route } from "@arcote.tech/arc";
|
|
2
|
+
import type { Token } from "@arcote.tech/arc-auth";
|
|
3
|
+
import { getStreamSession } from "../streaming/stream-registry";
|
|
4
|
+
|
|
5
|
+
export function createChatStreamRoute(config: {
|
|
6
|
+
name: string;
|
|
7
|
+
userToken: Token;
|
|
8
|
+
}) {
|
|
9
|
+
return route(`${config.name}ChatStream`)
|
|
10
|
+
.path(`/chat/${config.name}/stream/:sessionId`)
|
|
11
|
+
.protectBy(config.userToken, () => true)
|
|
12
|
+
.handle({
|
|
13
|
+
GET: async (_ctx, _req: Request, params: Record<string, string>) => {
|
|
14
|
+
const session = getStreamSession(params.sessionId);
|
|
15
|
+
if (!session) {
|
|
16
|
+
return new Response(
|
|
17
|
+
JSON.stringify({ error: "Session not found" }),
|
|
18
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return new Response(session.createReadableStream(), {
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "text/event-stream",
|
|
25
|
+
"Cache-Control": "no-cache",
|
|
26
|
+
Connection: "keep-alive",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { route } from "@arcote.tech/arc";
|
|
2
|
+
import type { Token } from "@arcote.tech/arc-auth";
|
|
3
|
+
import { getStreamSession } from "../streaming/stream-registry";
|
|
4
|
+
|
|
5
|
+
export function createToolResultsRoute(config: {
|
|
6
|
+
name: string;
|
|
7
|
+
userToken: Token;
|
|
8
|
+
}) {
|
|
9
|
+
return route(`${config.name}ChatToolResults`)
|
|
10
|
+
.path(`/chat/${config.name}/tools/:sessionId`)
|
|
11
|
+
.protectBy(config.userToken, () => true)
|
|
12
|
+
.handle({
|
|
13
|
+
POST: async (
|
|
14
|
+
_ctx,
|
|
15
|
+
req: Request,
|
|
16
|
+
params: Record<string, string>,
|
|
17
|
+
) => {
|
|
18
|
+
const session = getStreamSession(params.sessionId);
|
|
19
|
+
if (!session) {
|
|
20
|
+
return new Response(
|
|
21
|
+
JSON.stringify({ error: "Session not found" }),
|
|
22
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (session.isClosed()) {
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({ error: "Session already closed" }),
|
|
29
|
+
{ status: 410, headers: { "Content-Type": "application/json" } },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const body = (await req.json()) as {
|
|
34
|
+
toolResults: Array<{
|
|
35
|
+
toolCallId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
content: string;
|
|
38
|
+
isError: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
session.resolveClientToolResults(body.toolResults);
|
|
43
|
+
|
|
44
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|