@arcote.tech/arc-chat 0.5.1 → 0.5.2

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.
@@ -1,260 +1 @@
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
- }
1
+ // Removed use chat().toReactComponent() instead.
@@ -1,25 +1,19 @@
1
1
  import { route } from "@arcote.tech/arc";
2
2
  import type { Token } from "@arcote.tech/arc-auth";
3
- import { getStreamSession } from "../streaming/stream-registry";
3
+ import { subscribe } from "../streaming/stream-registry";
4
4
 
5
5
  export function createChatStreamRoute(config: {
6
6
  name: string;
7
7
  userToken: Token;
8
8
  }) {
9
9
  return route(`${config.name}ChatStream`)
10
- .path(`/chat/${config.name}/stream/:sessionId`)
10
+ .path(`/chat/${config.name}/stream/:streamId`)
11
11
  .protectBy(config.userToken, () => true)
12
12
  .handle({
13
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
- }
14
+ const stream = subscribe(params.streamId);
21
15
 
22
- return new Response(session.createReadableStream(), {
16
+ return new Response(stream, {
23
17
  headers: {
24
18
  "Content-Type": "text/event-stream",
25
19
  "Cache-Control": "no-cache",
@@ -1,146 +1,114 @@
1
- import type { ChatStreamEvent, ToolResult } from "@arcote.tech/arc-ai";
1
+ import type { ChatStreamEvent } from "@arcote.tech/arc-ai";
2
2
 
3
- // ─── Stream Session ─────────────────────────────────────────────
3
+ // ─── ChatStreamManager per message ID streaming ──────────────
4
4
 
5
- export interface StreamSession {
6
- readonly sessionId: string;
7
- push(event: ChatStreamEvent): void;
8
- createReadableStream(): ReadableStream<Uint8Array>;
9
- waitForClientToolResults(timeoutMs?: number): Promise<ToolResult[]>;
10
- resolveClientToolResults(results: ToolResult[]): void;
11
- close(): void;
12
- isClosed(): boolean;
13
- }
14
-
15
- // ─── Registry ───────────────────────────────────────────────────
16
-
17
- const sessions = new Map<string, StreamSession>();
18
-
19
- export function createStreamSession(sessionId: string): StreamSession {
20
- const existing = sessions.get(sessionId);
21
- if (existing) return existing;
22
-
23
- let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
24
- let closed = false;
25
- const encoder = new TextEncoder();
26
- const buffer: ChatStreamEvent[] = [];
27
-
28
- // Client tool results coordination
29
- let toolResultsResolve: ((results: ToolResult[]) => void) | null = null;
30
-
31
- // Keep-alive interval
32
- let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
5
+ const streams = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();
6
+ const keepAliveIntervals = new Map<string, ReturnType<typeof setInterval>>();
7
+ const encoder = new TextEncoder();
33
8
 
34
- function encode(event: ChatStreamEvent): Uint8Array {
35
- return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
36
- }
9
+ function encode(event: ChatStreamEvent): Uint8Array {
10
+ return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
11
+ }
37
12
 
38
- function startKeepAlive() {
39
- keepAliveInterval = setInterval(() => {
40
- if (controller && !closed) {
41
- try {
42
- controller.enqueue(encoder.encode(`: ping\n\n`));
43
- } catch {
44
- // Controller closed, clean up
45
- cleanup();
46
- }
47
- }
48
- }, 5000);
49
- }
13
+ function encodePing(): Uint8Array {
14
+ return encoder.encode(`: ping\n\n`);
15
+ }
50
16
 
51
- function cleanup() {
52
- if (keepAliveInterval) {
53
- clearInterval(keepAliveInterval);
54
- keepAliveInterval = null;
17
+ export function broadcast(messageId: string, event: ChatStreamEvent): void {
18
+ const controllers = streams.get(messageId);
19
+ if (!controllers) return;
20
+ const data = encode(event);
21
+ for (const controller of controllers) {
22
+ try {
23
+ controller.enqueue(data);
24
+ } catch {
25
+ controllers.delete(controller);
55
26
  }
56
27
  }
28
+ }
57
29
 
58
- const session: StreamSession = {
59
- sessionId,
60
-
61
- push(event: ChatStreamEvent) {
62
- if (closed) return;
63
- buffer.push(event);
64
- if (controller) {
65
- try {
66
- controller.enqueue(encode(event));
67
- } catch {
68
- // Client disconnected, ignore
69
- }
30
+ export function subscribe(messageId: string): ReadableStream<Uint8Array> {
31
+ return new ReadableStream<Uint8Array>({
32
+ start(controller) {
33
+ let set = streams.get(messageId);
34
+ if (!set) {
35
+ set = new Set();
36
+ streams.set(messageId, set);
70
37
  }
71
- },
72
-
73
- createReadableStream(): ReadableStream<Uint8Array> {
74
- return new ReadableStream<Uint8Array>({
75
- start(ctrl) {
76
- controller = ctrl;
77
-
78
- // Replay buffered events for late-connecting clients
79
- for (const event of buffer) {
80
- ctrl.enqueue(encode(event));
38
+ set.add(controller);
39
+
40
+ // Start keep-alive if not running
41
+ if (!keepAliveIntervals.has(messageId)) {
42
+ const interval = setInterval(() => {
43
+ const s = streams.get(messageId);
44
+ if (s && s.size > 0) {
45
+ const ping = encodePing();
46
+ for (const c of s) {
47
+ try { c.enqueue(ping); } catch { s.delete(c); }
48
+ }
49
+ } else {
50
+ cleanup(messageId);
81
51
  }
82
-
83
- startKeepAlive();
84
- },
85
- cancel() {
86
- controller = null;
87
- cleanup();
88
- },
89
- });
52
+ }, 5000);
53
+ keepAliveIntervals.set(messageId, interval);
54
+ }
90
55
  },
56
+ cancel() {
57
+ // One client disconnected — don't cleanup everything
58
+ },
59
+ });
60
+ }
91
61
 
92
- waitForClientToolResults(timeoutMs = 60_000): Promise<ToolResult[]> {
93
- return new Promise<ToolResult[]>((resolve, reject) => {
94
- const timer = setTimeout(() => {
95
- toolResultsResolve = null;
96
- reject(new Error("Client tool results timeout"));
97
- }, timeoutMs);
62
+ export function endStream(messageId: string): void {
63
+ const controllers = streams.get(messageId);
64
+ if (controllers) {
65
+ const done = encode({ type: "done", sessionId: messageId } as any);
66
+ for (const controller of controllers) {
67
+ try {
68
+ controller.enqueue(done);
69
+ controller.close();
70
+ } catch {}
71
+ }
72
+ }
73
+ cleanup(messageId);
74
+ }
98
75
 
99
- toolResultsResolve = (results) => {
100
- clearTimeout(timer);
101
- toolResultsResolve = null;
102
- resolve(results);
103
- };
104
- });
105
- },
76
+ export function hasActiveStream(messageId: string): boolean {
77
+ const s = streams.get(messageId);
78
+ return !!s && s.size > 0;
79
+ }
106
80
 
107
- resolveClientToolResults(results: ToolResult[]) {
108
- if (toolResultsResolve) {
109
- toolResultsResolve(results);
110
- }
111
- },
81
+ function cleanup(messageId: string): void {
82
+ const interval = keepAliveIntervals.get(messageId);
83
+ if (interval) {
84
+ clearInterval(interval);
85
+ keepAliveIntervals.delete(messageId);
86
+ }
87
+ streams.delete(messageId);
88
+ }
112
89
 
113
- close() {
114
- if (closed) return;
115
- closed = true;
116
- cleanup();
117
- if (controller) {
118
- try {
119
- controller.close();
120
- } catch {
121
- // Already closed
122
- }
123
- controller = null;
124
- }
125
- },
90
+ // ─── Legacy exports (for respondToTool compatibility) ───────────
91
+ // TODO: remove after full migration
126
92
 
127
- isClosed() {
128
- return closed;
129
- },
130
- };
93
+ export interface StreamSession {
94
+ readonly sessionId: string;
95
+ push(event: ChatStreamEvent): void;
96
+ close(): void;
97
+ isClosed(): boolean;
98
+ }
131
99
 
132
- sessions.set(sessionId, session);
133
- return session;
100
+ export function createStreamSession(sessionId: string): StreamSession {
101
+ let closed = false;
102
+ return {
103
+ sessionId,
104
+ push(event) { broadcast(sessionId, event); },
105
+ close() { closed = true; },
106
+ isClosed() { return closed; },
107
+ };
134
108
  }
135
109
 
136
110
  export function getStreamSession(sessionId: string): StreamSession | undefined {
137
- return sessions.get(sessionId);
111
+ return undefined;
138
112
  }
139
113
 
140
- export function deleteStreamSession(sessionId: string): void {
141
- const session = sessions.get(sessionId);
142
- if (session) {
143
- session.close();
144
- sessions.delete(sessionId);
145
- }
146
- }
114
+ export function deleteStreamSession(sessionId: string): void {}
@@ -0,0 +1,107 @@
1
+ import { useEffect } from "react";
2
+ import { tool } from "@arcote.tech/arc-ai";
3
+ import { string, array, object } from "@arcote.tech/arc";
4
+ import { ChatToolQuestion, QuestionTabs, useChatInput } from "@arcote.tech/arc-ds";
5
+ import type { Question, QuestionAnswers } from "@arcote.tech/arc-ds";
6
+
7
+ type AskQuestionsParams = {
8
+ readonly comment: string;
9
+ readonly questions: readonly {
10
+ readonly id: string;
11
+ readonly label: string;
12
+ readonly description: string;
13
+ readonly options: readonly string[];
14
+ }[];
15
+ };
16
+
17
+ function AskQuestionsView({
18
+ params,
19
+ respond,
20
+ calling,
21
+ result,
22
+ }: {
23
+ params: AskQuestionsParams;
24
+ respond: (result: QuestionAnswers) => void;
25
+ calling: boolean;
26
+ result?: QuestionAnswers;
27
+ }) {
28
+ const { registerInputOverride, clearInputOverride } = useChatInput();
29
+
30
+ useEffect(() => {
31
+ if (calling) {
32
+ const questions: Question[] = params.questions.map((q) => ({
33
+ id: q.id,
34
+ label: q.label,
35
+ description: q.description,
36
+ options: [...q.options],
37
+ }));
38
+
39
+ registerInputOverride(
40
+ <QuestionTabs
41
+ questions={questions}
42
+ onSubmit={(answers) => {
43
+ respond(answers);
44
+ clearInputOverride();
45
+ }}
46
+ />,
47
+ );
48
+ }
49
+
50
+ return () => clearInputOverride();
51
+ }, [calling]);
52
+
53
+ // Answered — show summary
54
+ if (!calling && result) {
55
+ const answers = result as Record<string, { selected?: string[]; text?: string }>;
56
+ return (
57
+ <ChatToolQuestion calling={false}>
58
+ {params.comment && (
59
+ <p className="text-sm mb-2">{params.comment}</p>
60
+ )}
61
+ <div className="space-y-1.5">
62
+ {Object.entries(answers).map(([questionId, answer]) => {
63
+ const question = params.questions.find((q) => q.id === questionId);
64
+ const selected = answer?.selected?.length ? answer.selected.join(", ") : "";
65
+ const text = answer?.text || "";
66
+ const value = [selected, text].filter(Boolean).join(" — ");
67
+ return value ? (
68
+ <div key={questionId}>
69
+ <span className="text-xs font-medium">{question?.label ?? questionId}: </span>
70
+ <span className="text-xs text-muted-foreground">{value}</span>
71
+ </div>
72
+ ) : null;
73
+ })}
74
+ </div>
75
+ </ChatToolQuestion>
76
+ );
77
+ }
78
+
79
+ // Waiting for response
80
+ return (
81
+ <ChatToolQuestion calling={true}>
82
+ {params.comment && (
83
+ <p className="text-sm mb-1">{params.comment}</p>
84
+ )}
85
+ <p className="text-xs text-muted-foreground">
86
+ Odpowiedz na pytania poniżej
87
+ </p>
88
+ </ChatToolQuestion>
89
+ );
90
+ }
91
+
92
+ export const askQuestions = tool("askQuestions")
93
+ .description(
94
+ "Zadaj użytkownikowi pytania z predefiniowanymi odpowiedziami. ZAWSZE podaj comment — krótki, entuzjastyczny komentarz do tego co użytkownik napisał.",
95
+ )
96
+ .withParams({
97
+ comment: string(),
98
+ questions: array(
99
+ object({
100
+ id: string(),
101
+ label: string(),
102
+ description: string(),
103
+ options: array(string()),
104
+ }),
105
+ ),
106
+ })
107
+ .view(AskQuestionsView as any);
@@ -1,49 +0,0 @@
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
- }