@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.
@@ -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
+ }
@@ -9,3 +9,7 @@ export type {
9
9
  Question,
10
10
  QuestionAnswers,
11
11
  } from "@arcote.tech/arc-ds";
12
+
13
+ // Chat hook
14
+ export { useChat } from "./use-chat";
15
+ export type { UseChatConfig, UseChatReturn } from "./use-chat";
@@ -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
+ }