@arcote.tech/arc-chat 0.5.1 → 0.5.5

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,126 @@
1
+ import { useEffect } from "react";
2
+ import { tool } from "@arcote.tech/arc-ai";
3
+ import { string, array, object } from "@arcote.tech/arc";
4
+ import {
5
+ ChatToolQuestion,
6
+ QuestionTabs,
7
+ useChatInput,
8
+ useChatLabels,
9
+ } from "@arcote.tech/arc-ds";
10
+ import type { Question, QuestionAnswers } from "@arcote.tech/arc-ds";
11
+
12
+ type AskQuestionsParams = {
13
+ readonly questions: readonly {
14
+ readonly id: string;
15
+ readonly label: string;
16
+ readonly description: string;
17
+ readonly options: readonly string[];
18
+ }[];
19
+ };
20
+
21
+ /** Result shape returned by respond() — includes a discuss flag. */
22
+ export interface AskQuestionsResult {
23
+ answers: QuestionAnswers;
24
+ /** User clicked "Continue discussion" instead of submitting answers. */
25
+ wantsToDiscuss: boolean;
26
+ }
27
+
28
+ function AskQuestionsView({
29
+ params,
30
+ respond,
31
+ calling,
32
+ result,
33
+ }: {
34
+ params: AskQuestionsParams;
35
+ respond: (result: AskQuestionsResult) => void;
36
+ calling: boolean;
37
+ result?: AskQuestionsResult;
38
+ }) {
39
+ const { registerInputOverride, clearInputOverride } = useChatInput();
40
+ const { answerBelowLabel } = useChatLabels();
41
+
42
+ useEffect(() => {
43
+ if (calling) {
44
+ const questions: Question[] = params.questions.map((q) => ({
45
+ id: q.id,
46
+ label: q.label,
47
+ description: q.description,
48
+ options: [...q.options],
49
+ }));
50
+
51
+ registerInputOverride(
52
+ <QuestionTabs
53
+ questions={questions}
54
+ onSubmit={(answers, { wantsToDiscuss }) => {
55
+ respond({ answers, wantsToDiscuss });
56
+ clearInputOverride();
57
+ }}
58
+ />,
59
+ );
60
+ }
61
+
62
+ return () => clearInputOverride();
63
+ }, [calling]);
64
+
65
+ // Answered — show summary
66
+ if (!calling && result) {
67
+ const { answers } = result;
68
+ const entries = Object.entries(answers) as Array<
69
+ [string, { selected?: string[]; text?: string }]
70
+ >;
71
+ return (
72
+ <ChatToolQuestion calling={false}>
73
+ <div className="space-y-1.5">
74
+ {entries.map(([questionId, answer]) => {
75
+ const question = params.questions.find((q) => q.id === questionId);
76
+ const selected = answer?.selected?.length ? answer.selected.join(", ") : "";
77
+ const text = answer?.text || "";
78
+ const value = [selected, text].filter(Boolean).join(" — ");
79
+ return value ? (
80
+ <div key={questionId}>
81
+ <span className="text-xs font-medium">{question?.label ?? questionId}: </span>
82
+ <span className="text-xs text-muted-foreground">{value}</span>
83
+ </div>
84
+ ) : null;
85
+ })}
86
+ </div>
87
+ </ChatToolQuestion>
88
+ );
89
+ }
90
+
91
+ // Waiting for response
92
+ return (
93
+ <ChatToolQuestion calling={true}>
94
+ <p className="text-xs text-muted-foreground">{answerBelowLabel}</p>
95
+ </ChatToolQuestion>
96
+ );
97
+ }
98
+
99
+ export const askQuestions = tool("askQuestions")
100
+ .description(
101
+ "Zadaj użytkownikowi **zamknięte** pytanie z predefiniowanymi opcjami — " +
102
+ "gdy wiesz jakich konkretnie odpowiedzi się spodziewasz i możesz je " +
103
+ "wymienić jako 3–6 opcji (np. preferowany ton, zakres tematyczny, " +
104
+ "formalność). Każde pytanie ma id, label, description i tablicę opcji. " +
105
+ "Komentarz/insight napisz jako zwykły tekst PRZED wywołaniem toola — " +
106
+ "nie wkładaj komentarzy do parametrów.\n\n" +
107
+ "NIE używaj do otwartych pytań narracyjnych (np. 'opowiedz o swoim " +
108
+ "zespole', 'opisz przełomowy moment') — na te pytaj zwykłym tekstem " +
109
+ "w jednym zdaniu, użytkownik odpowie własnymi słowami.\n\n" +
110
+ "Tool result: `{ answers, wantsToDiscuss }`. Gdy `wantsToDiscuss === true` " +
111
+ "— user kliknął 'Kontynuuj rozmowę' zamiast odpowiadać. W TEJ turze NIE " +
112
+ "wolno wołać żadnego toola (ani askQuestions ani innego). Zwróć zwykły " +
113
+ "tekst: krótkie podsumowanie (2-3 zdania) + jedno open-ended pytanie " +
114
+ "w prozie.",
115
+ )
116
+ .withParams({
117
+ questions: array(
118
+ object({
119
+ id: string(),
120
+ label: string(),
121
+ description: string(),
122
+ options: array(string()),
123
+ }),
124
+ ),
125
+ })
126
+ .view(AskQuestionsView as any);