@codrstudio/openclaude-chat 0.1.0 → 0.2.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.
Files changed (62) hide show
  1. package/dist/components/StreamingIndicator.js +5 -5
  2. package/dist/display/DisplayReactRenderer.js +12 -12
  3. package/dist/display/react-sandbox/bootstrap.js +150 -150
  4. package/dist/styles.css +1 -2
  5. package/package.json +64 -61
  6. package/src/components/Chat.tsx +107 -107
  7. package/src/components/ErrorNote.tsx +35 -35
  8. package/src/components/LazyRender.tsx +42 -42
  9. package/src/components/Markdown.tsx +114 -114
  10. package/src/components/MessageBubble.tsx +107 -107
  11. package/src/components/MessageInput.tsx +421 -421
  12. package/src/components/MessageList.tsx +153 -153
  13. package/src/components/StreamingIndicator.tsx +19 -19
  14. package/src/display/AlertRenderer.tsx +23 -23
  15. package/src/display/CarouselRenderer.tsx +141 -141
  16. package/src/display/ChartRenderer.tsx +195 -195
  17. package/src/display/ChoiceButtonsRenderer.tsx +114 -114
  18. package/src/display/CodeBlockRenderer.tsx +49 -49
  19. package/src/display/ComparisonTableRenderer.tsx +132 -132
  20. package/src/display/DataTableRenderer.tsx +144 -144
  21. package/src/display/DisplayReactRenderer.tsx +269 -269
  22. package/src/display/FileCardRenderer.tsx +55 -55
  23. package/src/display/GalleryRenderer.tsx +65 -65
  24. package/src/display/ImageViewerRenderer.tsx +114 -114
  25. package/src/display/LinkPreviewRenderer.tsx +74 -74
  26. package/src/display/MapViewRenderer.tsx +75 -75
  27. package/src/display/MetricCardRenderer.tsx +29 -29
  28. package/src/display/PriceHighlightRenderer.tsx +62 -62
  29. package/src/display/ProductCardRenderer.tsx +112 -112
  30. package/src/display/ProgressStepsRenderer.tsx +59 -59
  31. package/src/display/SourcesListRenderer.tsx +47 -47
  32. package/src/display/SpreadsheetRenderer.tsx +86 -86
  33. package/src/display/StepTimelineRenderer.tsx +75 -75
  34. package/src/display/index.ts +21 -21
  35. package/src/display/react-sandbox/bootstrap.ts +155 -155
  36. package/src/display/registry.ts +84 -84
  37. package/src/display/sdk-types.ts +217 -217
  38. package/src/hooks/ChatProvider.tsx +21 -21
  39. package/src/hooks/useIsMobile.ts +15 -15
  40. package/src/hooks/useOpenClaudeChat.ts +476 -476
  41. package/src/index.ts +76 -76
  42. package/src/lib/utils.ts +6 -6
  43. package/src/parts/PartErrorBoundary.tsx +51 -51
  44. package/src/parts/PartRenderer.tsx +145 -145
  45. package/src/parts/ReasoningBlock.tsx +41 -41
  46. package/src/parts/ToolActivity.tsx +78 -78
  47. package/src/parts/ToolResult.tsx +79 -79
  48. package/src/styles.css +2 -2
  49. package/src/types.ts +41 -41
  50. package/src/ui/alert.tsx +77 -77
  51. package/src/ui/badge.tsx +36 -36
  52. package/src/ui/button.tsx +54 -54
  53. package/src/ui/card.tsx +68 -68
  54. package/src/ui/collapsible.tsx +7 -7
  55. package/src/ui/dialog.tsx +122 -122
  56. package/src/ui/dropdown-menu.tsx +76 -76
  57. package/src/ui/input.tsx +24 -24
  58. package/src/ui/progress.tsx +36 -36
  59. package/src/ui/scroll-area.tsx +48 -48
  60. package/src/ui/separator.tsx +31 -31
  61. package/src/ui/skeleton.tsx +9 -9
  62. package/src/ui/table.tsx +114 -114
@@ -1,476 +1,476 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import type { Message, MessagePart, ToolInvocationPart } from "../types.js";
3
-
4
- // Minimo necessario dos tipos de @codrstudio/openclaude-sdk — evita dep dura.
5
- interface TextBlock {
6
- type: "text";
7
- text: string;
8
- }
9
- interface ToolUseBlock {
10
- type: "tool_use";
11
- id: string;
12
- name: string;
13
- input: Record<string, unknown>;
14
- }
15
- interface ToolResultBlock {
16
- type: "tool_result";
17
- tool_use_id: string;
18
- content: unknown;
19
- is_error?: boolean;
20
- }
21
- type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock;
22
-
23
- interface SDKAssistantMessage {
24
- type: "assistant";
25
- uuid: string;
26
- session_id: string;
27
- message: { id: string; content: ContentBlock[] };
28
- parent_tool_use_id: string | null;
29
- }
30
- interface SDKUserMessage {
31
- type: "user";
32
- session_id: string;
33
- message: { content: ContentBlock[] | unknown };
34
- parent_tool_use_id: string | null;
35
- }
36
- interface SDKResultMessage {
37
- type: "result";
38
- subtype?: string;
39
- session_id: string;
40
- total_cost_usd?: number;
41
- duration_ms?: number;
42
- is_error?: boolean;
43
- }
44
- interface SDKSystemMessage {
45
- type: "system";
46
- subtype?: string;
47
- session_id: string;
48
- }
49
- type SDKMessage =
50
- | SDKAssistantMessage
51
- | SDKUserMessage
52
- | SDKResultMessage
53
- | SDKSystemMessage
54
- | { type: string; [k: string]: unknown };
55
-
56
- // ─────────────────────────────────────────────────────────────────────────────
57
-
58
- export interface UseOpenClaudeChatOptions {
59
- /** Base URL do servico que encapsula o openclaude-sdk. Ex: http://localhost:9500/api/v1/ai */
60
- endpoint: string;
61
- /** Optional bearer token. */
62
- token?: string;
63
- /**
64
- * ID de sessao live. Caso omitido, o hook cria uma sessao nova via POST /sessions
65
- * na primeira mensagem e expoe o id via `sessionId`.
66
- */
67
- sessionId?: string;
68
- /** Mensagens iniciais (historico) para hidratar a UI. */
69
- initialMessages?: Message[];
70
- /** Options extras passadas ao servidor na criacao da sessao. */
71
- sessionOptions?: Record<string, unknown>;
72
- /** Options por turno. Passado como `turnOptions` no body do /prompt. */
73
- turnOptions?: Record<string, unknown>;
74
- /** Customiza fetch (ex: para injetar credenciais). */
75
- fetcher?: typeof fetch;
76
- }
77
-
78
- export interface UseOpenClaudeChatReturn {
79
- sessionId: string | null;
80
- messages: Message[];
81
- input: string;
82
- setInput: (value: string) => void;
83
- isLoading: boolean;
84
- error: Error | null;
85
- handleSubmit: (e?: { preventDefault?: () => void }) => void;
86
- sendMessage: (text: string) => Promise<void>;
87
- stop: () => void;
88
- reload: () => Promise<void>;
89
- clear: () => void;
90
- }
91
-
92
- // ─────────────────────────────────────────────────────────────────────────────
93
-
94
- function extractTextFromParts(parts: MessagePart[]): string {
95
- return parts
96
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
97
- .map((p) => p.text)
98
- .join("\n");
99
- }
100
-
101
- // ─────────────────────────────────────────────────────────────────────────────
102
-
103
- export function useOpenClaudeChat(options: UseOpenClaudeChatOptions): UseOpenClaudeChatReturn {
104
- const {
105
- endpoint,
106
- token,
107
- sessionId: providedSessionId,
108
- initialMessages,
109
- sessionOptions,
110
- turnOptions,
111
- fetcher,
112
- } = options;
113
-
114
- const [sessionId, setSessionId] = useState<string | null>(providedSessionId ?? null);
115
- const [messages, setMessages] = useState<Message[]>(initialMessages ?? []);
116
- const [input, setInput] = useState("");
117
- const [isLoading, setIsLoading] = useState(false);
118
- const [error, setError] = useState<Error | null>(null);
119
-
120
- const abortRef = useRef<AbortController | null>(null);
121
- const lastSentRef = useRef<string | null>(null);
122
-
123
- const doFetch = fetcher ?? fetch;
124
-
125
- // ──────────────────────────────────────────────────────────────
126
- // Garante sessao live
127
- const ensureSession = useCallback(async (): Promise<string> => {
128
- if (sessionId) return sessionId;
129
- const headers: Record<string, string> = { "Content-Type": "application/json" };
130
- if (token) headers.Authorization = `Bearer ${token}`;
131
- const res = await doFetch(`${endpoint}/sessions`, {
132
- method: "POST",
133
- headers,
134
- body: JSON.stringify({ options: sessionOptions ?? {} }),
135
- });
136
- if (!res.ok) {
137
- throw new Error(`Failed to create session: HTTP ${res.status}`);
138
- }
139
- const data = (await res.json()) as { sessionId: string };
140
- setSessionId(data.sessionId);
141
- return data.sessionId;
142
- }, [sessionId, endpoint, token, sessionOptions, doFetch]);
143
-
144
- // ──────────────────────────────────────────────────────────────
145
- // Stream parser — consome SSE do endpoint /sessions/:id/prompt
146
- // Contrato: SEMPRE promove um assistantId no setMessages e SEMPRE retorna
147
- // o numero de partes acumuladas. O chamador decide se isso configura erro
148
- // (ex: zero partes = resposta vazia, mesmo com HTTP 200).
149
- const streamPrompt = useCallback(
150
- async (sid: string, text: string): Promise<{ assistantId: string; partCount: number }> => {
151
- const headers: Record<string, string> = {
152
- "Content-Type": "application/json",
153
- Accept: "text/event-stream",
154
- };
155
- if (token) headers.Authorization = `Bearer ${token}`;
156
-
157
- const ctrl = new AbortController();
158
- abortRef.current = ctrl;
159
-
160
- // Watchdog de stall — se nao chegar NENHUM byte SSE (incluindo ping) em
161
- // STALL_MS, considera sessao morta e aborta. Subimos pra 90s pra tolerar
162
- // chat agentico (LLM pode pensar muito entre tool calls). O servidor
163
- // emite `event: ping` periodicamente como heartbeat — qualquer chunk SSE
164
- // (ping ou message) renova esse timer via armStall().
165
- const STALL_MS = 90_000;
166
- let stallTimer: ReturnType<typeof setTimeout> | null = null;
167
- const armStall = () => {
168
- if (stallTimer) clearTimeout(stallTimer);
169
- stallTimer = setTimeout(() => {
170
- try { ctrl.abort(new Error("stall-timeout")); } catch { /* noop */ }
171
- }, STALL_MS);
172
- };
173
- const disarmStall = () => {
174
- if (stallTimer) clearTimeout(stallTimer);
175
- stallTimer = null;
176
- };
177
-
178
- // Cria placeholder assistant ANTES do fetch — garante feedback visual imediato
179
- // mesmo em caso de rede lenta ou erro no fetch.
180
- const assistantId = `asst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
181
- setMessages((prev) => [
182
- ...prev,
183
- { id: assistantId, role: "assistant", content: "", parts: [] },
184
- ]);
185
-
186
- armStall();
187
- let res: Response;
188
- try {
189
- res = await doFetch(`${endpoint}/sessions/${encodeURIComponent(sid)}/prompt`, {
190
- method: "POST",
191
- headers,
192
- body: JSON.stringify({ prompt: text, turnOptions }),
193
- signal: ctrl.signal,
194
- });
195
- } catch (err) {
196
- disarmStall();
197
- throw err;
198
- }
199
-
200
- if (!res.ok || !res.body) {
201
- disarmStall();
202
- const errText = await res.text().catch(() => `HTTP ${res.status}`);
203
- throw new Error(errText || `HTTP ${res.status}`);
204
- }
205
-
206
- const reader = res.body.getReader();
207
- const decoder = new TextDecoder();
208
- let buffer = "";
209
-
210
- // Buffer acumulado de partes — fonte da verdade para esta sessao de stream.
211
- // Mantemos fora do functional updater do setMessages (o React 19 StrictMode
212
- // chama o updater mais de uma vez e mutar estado compartilhado dentro dele
213
- // leva a blocos perdidos).
214
- const accumulatedParts: MessagePart[] = [];
215
- const toolPartByCallId = new Map<string, ToolInvocationPart>();
216
-
217
- const commit = () => {
218
- const snapshot = accumulatedParts.map((p) => ({ ...p }));
219
- setMessages((prev) => {
220
- const idx = prev.findIndex((m) => m.id === assistantId);
221
- if (idx === -1) return prev;
222
- const next = prev.slice();
223
- next[idx] = {
224
- ...prev[idx],
225
- parts: snapshot,
226
- content: extractTextFromParts(snapshot),
227
- };
228
- return next;
229
- });
230
- };
231
-
232
- const handleEvent = (eventName: string, dataStr: string) => {
233
- if (eventName === "error") {
234
- try {
235
- const payload = JSON.parse(dataStr) as { message?: string; name?: string };
236
- throw new Error(payload.message ?? "Stream error");
237
- } catch (err) {
238
- throw err instanceof Error ? err : new Error(String(err));
239
- }
240
- }
241
- if (eventName === "done") return;
242
- // Heartbeat do servidor — `event: ping` apenas serve para manter o
243
- // stall watchdog renovado durante pausas longas do agente. Nao tem
244
- // payload util; o efeito ja foi aplicado no loop ao chamar armStall()
245
- // a cada chunk recebido. Apenas retornamos sem processar.
246
- if (eventName === "ping" || eventName === "keepalive" || eventName === "heartbeat") return;
247
- if (eventName !== "message") return;
248
-
249
- let msg: SDKMessage;
250
- try {
251
- msg = JSON.parse(dataStr) as SDKMessage;
252
- } catch {
253
- return;
254
- }
255
-
256
- if (msg.type === "assistant") {
257
- const assistant = msg as SDKAssistantMessage;
258
- const blocks = assistant.message?.content ?? [];
259
- for (const block of blocks) {
260
- if (block.type === "text") {
261
- accumulatedParts.push({ type: "text", text: block.text });
262
- } else if (block.type === "tool_use") {
263
- if (toolPartByCallId.has(block.id)) continue;
264
- const part: ToolInvocationPart = {
265
- type: "tool-invocation",
266
- toolInvocation: {
267
- toolName: block.name,
268
- toolCallId: block.id,
269
- state: "call",
270
- args: block.input,
271
- },
272
- };
273
- toolPartByCallId.set(block.id, part);
274
- accumulatedParts.push(part);
275
- }
276
- }
277
- commit();
278
- return;
279
- }
280
-
281
- if (msg.type === "user") {
282
- // Normalmente traz tool_result com ref a tool_use anterior.
283
- const userMsg = msg as SDKUserMessage;
284
- const content = userMsg.message?.content;
285
- if (Array.isArray(content)) {
286
- let changed = false;
287
- for (const block of content as ContentBlock[]) {
288
- if (block.type === "tool_result") {
289
- const part = toolPartByCallId.get(block.tool_use_id);
290
- if (part) {
291
- part.toolInvocation = {
292
- ...part.toolInvocation,
293
- state: "result",
294
- result: block.content,
295
- isError: block.is_error,
296
- };
297
- changed = true;
298
- }
299
- }
300
- }
301
- if (changed) commit();
302
- }
303
- return;
304
- }
305
-
306
- if (msg.type === "result") {
307
- // fim do turno — nada a fazer aqui; o loop quebra no "done"
308
- return;
309
- }
310
- };
311
-
312
- try {
313
- while (true) {
314
- const { done, value } = await reader.read();
315
- if (done) break;
316
- armStall(); // reset watchdog a cada chunk que chega
317
- buffer += decoder.decode(value, { stream: true });
318
-
319
- // SSE frames: "event: X\ndata: Y\n\n"
320
- let boundary: number;
321
- while ((boundary = buffer.indexOf("\n\n")) !== -1) {
322
- const frame = buffer.slice(0, boundary);
323
- buffer = buffer.slice(boundary + 2);
324
- let eventName = "message";
325
- const dataLines: string[] = [];
326
- for (const line of frame.split("\n")) {
327
- if (line.startsWith("event:")) eventName = line.slice(6).trim();
328
- else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
329
- }
330
- if (dataLines.length === 0) continue;
331
- handleEvent(eventName, dataLines.join("\n"));
332
- }
333
- }
334
- } finally {
335
- disarmStall();
336
- abortRef.current = null;
337
- }
338
-
339
- return { assistantId, partCount: accumulatedParts.length };
340
- },
341
- [endpoint, token, turnOptions, doFetch],
342
- );
343
-
344
- // ──────────────────────────────────────────────────────────────
345
- // Helper: garante que NUNCA fica uma bolha de assistant vazia pendurada.
346
- // Se o turno terminou em erro ou em stream vazio, removemos o placeholder
347
- // (para o ErrorNote abaixo da mensagem do usuario aparecer) ou injetamos
348
- // um bloco de texto com a mensagem de erro para o usuario.
349
- const dropEmptyAssistant = useCallback((assistantId: string | null) => {
350
- if (!assistantId) return;
351
- setMessages((prev) => {
352
- const idx = prev.findIndex((m) => m.id === assistantId);
353
- if (idx === -1) return prev;
354
- const msg = prev[idx];
355
- if (msg.role === "assistant" && msg.parts.length === 0) {
356
- const next = prev.slice();
357
- next.splice(idx, 1);
358
- return next;
359
- }
360
- return prev;
361
- });
362
- }, []);
363
-
364
- const sendMessage = useCallback(
365
- async (text: string) => {
366
- const trimmed = text.trim();
367
- if (!trimmed || isLoading) return;
368
- setError(null);
369
- lastSentRef.current = trimmed;
370
-
371
- const userId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
372
- setMessages((prev) => [
373
- ...prev,
374
- {
375
- id: userId,
376
- role: "user",
377
- content: trimmed,
378
- parts: [{ type: "text", text: trimmed }],
379
- },
380
- ]);
381
-
382
- setIsLoading(true);
383
- // NOTE: removido o `HARD_LIMIT_MS` do turno inteiro. Em chat agentico o
384
- // agente pode pensar/rodar tools por minutos sem ser uma falha. A unica
385
- // razao para abortar e:
386
- // - usuario clicou stop() (flag __userStop)
387
- // - stall watchdog (sem nenhum byte SSE em STALL_MS, incluindo pings
388
- // do servidor — heartbeat server-push e como sabemos que a sessao
389
- // ainda esta viva mesmo durante pausas longas do LLM)
390
- // - servidor emitiu `event: error` ou fechou o stream com 0 partes
391
-
392
- let assistantIdForCleanup: string | null = null;
393
- try {
394
- const sid = await ensureSession();
395
- const { assistantId, partCount } = await streamPrompt(sid, trimmed);
396
- assistantIdForCleanup = assistantId;
397
- if (partCount === 0) {
398
- // Servidor fechou o stream sem emitir nenhum bloco — erro "macio".
399
- throw new Error(
400
- "O servidor nao retornou nenhum conteudo. Verifique se o provider de LLM esta configurado.",
401
- );
402
- }
403
- } catch (err) {
404
- const e = err instanceof Error ? err : new Error(String(err));
405
- // Aborts originados do `stop()` do usuario sao intencionais — nao viram erro.
406
- const isUserStop = e.name === "AbortError" && (e as Error & { __userStop?: boolean }).__userStop;
407
- if (!isUserStop) {
408
- // Se foi stall/timeout de abort, troca a mensagem por algo legivel.
409
- const msg =
410
- e.name === "AbortError"
411
- ? "Tempo esgotado aguardando resposta do servidor."
412
- : e.message || "Falha desconhecida ao processar mensagem.";
413
- setError(new Error(msg));
414
- }
415
- dropEmptyAssistant(assistantIdForCleanup);
416
- } finally {
417
- setIsLoading(false);
418
- }
419
- },
420
- [isLoading, ensureSession, streamPrompt, dropEmptyAssistant],
421
- );
422
-
423
- const handleSubmit = useCallback(
424
- (e?: { preventDefault?: () => void }) => {
425
- e?.preventDefault?.();
426
- const text = input;
427
- setInput("");
428
- void sendMessage(text);
429
- },
430
- [input, sendMessage],
431
- );
432
-
433
- const stop = useCallback(() => {
434
- const err = new Error("User stopped stream");
435
- err.name = "AbortError";
436
- (err as Error & { __userStop?: boolean }).__userStop = true;
437
- abortRef.current?.abort(err);
438
- abortRef.current = null;
439
- setIsLoading(false);
440
- }, []);
441
-
442
- const reload = useCallback(async () => {
443
- if (!lastSentRef.current) return;
444
- // remove a ultima mensagem do assistant (se existir) antes de re-enviar
445
- setMessages((prev) => {
446
- if (prev.length === 0) return prev;
447
- const last = prev[prev.length - 1];
448
- return last.role === "assistant" ? prev.slice(0, -1) : prev;
449
- });
450
- await sendMessage(lastSentRef.current);
451
- }, [sendMessage]);
452
-
453
- const clear = useCallback(() => {
454
- setMessages([]);
455
- setError(null);
456
- lastSentRef.current = null;
457
- }, []);
458
-
459
- useEffect(() => {
460
- return () => abortRef.current?.abort();
461
- }, []);
462
-
463
- return {
464
- sessionId,
465
- messages,
466
- input,
467
- setInput,
468
- isLoading,
469
- error,
470
- handleSubmit,
471
- sendMessage,
472
- stop,
473
- reload,
474
- clear,
475
- };
476
- }
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { Message, MessagePart, ToolInvocationPart } from "../types.js";
3
+
4
+ // Minimo necessario dos tipos de @codrstudio/openclaude-sdk — evita dep dura.
5
+ interface TextBlock {
6
+ type: "text";
7
+ text: string;
8
+ }
9
+ interface ToolUseBlock {
10
+ type: "tool_use";
11
+ id: string;
12
+ name: string;
13
+ input: Record<string, unknown>;
14
+ }
15
+ interface ToolResultBlock {
16
+ type: "tool_result";
17
+ tool_use_id: string;
18
+ content: unknown;
19
+ is_error?: boolean;
20
+ }
21
+ type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock;
22
+
23
+ interface SDKAssistantMessage {
24
+ type: "assistant";
25
+ uuid: string;
26
+ session_id: string;
27
+ message: { id: string; content: ContentBlock[] };
28
+ parent_tool_use_id: string | null;
29
+ }
30
+ interface SDKUserMessage {
31
+ type: "user";
32
+ session_id: string;
33
+ message: { content: ContentBlock[] | unknown };
34
+ parent_tool_use_id: string | null;
35
+ }
36
+ interface SDKResultMessage {
37
+ type: "result";
38
+ subtype?: string;
39
+ session_id: string;
40
+ total_cost_usd?: number;
41
+ duration_ms?: number;
42
+ is_error?: boolean;
43
+ }
44
+ interface SDKSystemMessage {
45
+ type: "system";
46
+ subtype?: string;
47
+ session_id: string;
48
+ }
49
+ type SDKMessage =
50
+ | SDKAssistantMessage
51
+ | SDKUserMessage
52
+ | SDKResultMessage
53
+ | SDKSystemMessage
54
+ | { type: string; [k: string]: unknown };
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ export interface UseOpenClaudeChatOptions {
59
+ /** Base URL do servico que encapsula o openclaude-sdk. Ex: http://localhost:9500/api/v1/ai */
60
+ endpoint: string;
61
+ /** Optional bearer token. */
62
+ token?: string;
63
+ /**
64
+ * ID de sessao live. Caso omitido, o hook cria uma sessao nova via POST /sessions
65
+ * na primeira mensagem e expoe o id via `sessionId`.
66
+ */
67
+ sessionId?: string;
68
+ /** Mensagens iniciais (historico) para hidratar a UI. */
69
+ initialMessages?: Message[];
70
+ /** Options extras passadas ao servidor na criacao da sessao. */
71
+ sessionOptions?: Record<string, unknown>;
72
+ /** Options por turno. Passado como `turnOptions` no body do /prompt. */
73
+ turnOptions?: Record<string, unknown>;
74
+ /** Customiza fetch (ex: para injetar credenciais). */
75
+ fetcher?: typeof fetch;
76
+ }
77
+
78
+ export interface UseOpenClaudeChatReturn {
79
+ sessionId: string | null;
80
+ messages: Message[];
81
+ input: string;
82
+ setInput: (value: string) => void;
83
+ isLoading: boolean;
84
+ error: Error | null;
85
+ handleSubmit: (e?: { preventDefault?: () => void }) => void;
86
+ sendMessage: (text: string) => Promise<void>;
87
+ stop: () => void;
88
+ reload: () => Promise<void>;
89
+ clear: () => void;
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+
94
+ function extractTextFromParts(parts: MessagePart[]): string {
95
+ return parts
96
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
97
+ .map((p) => p.text)
98
+ .join("\n");
99
+ }
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ export function useOpenClaudeChat(options: UseOpenClaudeChatOptions): UseOpenClaudeChatReturn {
104
+ const {
105
+ endpoint,
106
+ token,
107
+ sessionId: providedSessionId,
108
+ initialMessages,
109
+ sessionOptions,
110
+ turnOptions,
111
+ fetcher,
112
+ } = options;
113
+
114
+ const [sessionId, setSessionId] = useState<string | null>(providedSessionId ?? null);
115
+ const [messages, setMessages] = useState<Message[]>(initialMessages ?? []);
116
+ const [input, setInput] = useState("");
117
+ const [isLoading, setIsLoading] = useState(false);
118
+ const [error, setError] = useState<Error | null>(null);
119
+
120
+ const abortRef = useRef<AbortController | null>(null);
121
+ const lastSentRef = useRef<string | null>(null);
122
+
123
+ const doFetch = fetcher ?? fetch;
124
+
125
+ // ──────────────────────────────────────────────────────────────
126
+ // Garante sessao live
127
+ const ensureSession = useCallback(async (): Promise<string> => {
128
+ if (sessionId) return sessionId;
129
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
130
+ if (token) headers.Authorization = `Bearer ${token}`;
131
+ const res = await doFetch(`${endpoint}/sessions`, {
132
+ method: "POST",
133
+ headers,
134
+ body: JSON.stringify({ options: sessionOptions ?? {} }),
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`Failed to create session: HTTP ${res.status}`);
138
+ }
139
+ const data = (await res.json()) as { sessionId: string };
140
+ setSessionId(data.sessionId);
141
+ return data.sessionId;
142
+ }, [sessionId, endpoint, token, sessionOptions, doFetch]);
143
+
144
+ // ──────────────────────────────────────────────────────────────
145
+ // Stream parser — consome SSE do endpoint /sessions/:id/prompt
146
+ // Contrato: SEMPRE promove um assistantId no setMessages e SEMPRE retorna
147
+ // o numero de partes acumuladas. O chamador decide se isso configura erro
148
+ // (ex: zero partes = resposta vazia, mesmo com HTTP 200).
149
+ const streamPrompt = useCallback(
150
+ async (sid: string, text: string): Promise<{ assistantId: string; partCount: number }> => {
151
+ const headers: Record<string, string> = {
152
+ "Content-Type": "application/json",
153
+ Accept: "text/event-stream",
154
+ };
155
+ if (token) headers.Authorization = `Bearer ${token}`;
156
+
157
+ const ctrl = new AbortController();
158
+ abortRef.current = ctrl;
159
+
160
+ // Watchdog de stall — se nao chegar NENHUM byte SSE (incluindo ping) em
161
+ // STALL_MS, considera sessao morta e aborta. Subimos pra 90s pra tolerar
162
+ // chat agentico (LLM pode pensar muito entre tool calls). O servidor
163
+ // emite `event: ping` periodicamente como heartbeat — qualquer chunk SSE
164
+ // (ping ou message) renova esse timer via armStall().
165
+ const STALL_MS = 90_000;
166
+ let stallTimer: ReturnType<typeof setTimeout> | null = null;
167
+ const armStall = () => {
168
+ if (stallTimer) clearTimeout(stallTimer);
169
+ stallTimer = setTimeout(() => {
170
+ try { ctrl.abort(new Error("stall-timeout")); } catch { /* noop */ }
171
+ }, STALL_MS);
172
+ };
173
+ const disarmStall = () => {
174
+ if (stallTimer) clearTimeout(stallTimer);
175
+ stallTimer = null;
176
+ };
177
+
178
+ // Cria placeholder assistant ANTES do fetch — garante feedback visual imediato
179
+ // mesmo em caso de rede lenta ou erro no fetch.
180
+ const assistantId = `asst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
181
+ setMessages((prev) => [
182
+ ...prev,
183
+ { id: assistantId, role: "assistant", content: "", parts: [] },
184
+ ]);
185
+
186
+ armStall();
187
+ let res: Response;
188
+ try {
189
+ res = await doFetch(`${endpoint}/sessions/${encodeURIComponent(sid)}/prompt`, {
190
+ method: "POST",
191
+ headers,
192
+ body: JSON.stringify({ prompt: text, turnOptions }),
193
+ signal: ctrl.signal,
194
+ });
195
+ } catch (err) {
196
+ disarmStall();
197
+ throw err;
198
+ }
199
+
200
+ if (!res.ok || !res.body) {
201
+ disarmStall();
202
+ const errText = await res.text().catch(() => `HTTP ${res.status}`);
203
+ throw new Error(errText || `HTTP ${res.status}`);
204
+ }
205
+
206
+ const reader = res.body.getReader();
207
+ const decoder = new TextDecoder();
208
+ let buffer = "";
209
+
210
+ // Buffer acumulado de partes — fonte da verdade para esta sessao de stream.
211
+ // Mantemos fora do functional updater do setMessages (o React 19 StrictMode
212
+ // chama o updater mais de uma vez e mutar estado compartilhado dentro dele
213
+ // leva a blocos perdidos).
214
+ const accumulatedParts: MessagePart[] = [];
215
+ const toolPartByCallId = new Map<string, ToolInvocationPart>();
216
+
217
+ const commit = () => {
218
+ const snapshot = accumulatedParts.map((p) => ({ ...p }));
219
+ setMessages((prev) => {
220
+ const idx = prev.findIndex((m) => m.id === assistantId);
221
+ if (idx === -1) return prev;
222
+ const next = prev.slice();
223
+ next[idx] = {
224
+ ...prev[idx],
225
+ parts: snapshot,
226
+ content: extractTextFromParts(snapshot),
227
+ };
228
+ return next;
229
+ });
230
+ };
231
+
232
+ const handleEvent = (eventName: string, dataStr: string) => {
233
+ if (eventName === "error") {
234
+ try {
235
+ const payload = JSON.parse(dataStr) as { message?: string; name?: string };
236
+ throw new Error(payload.message ?? "Stream error");
237
+ } catch (err) {
238
+ throw err instanceof Error ? err : new Error(String(err));
239
+ }
240
+ }
241
+ if (eventName === "done") return;
242
+ // Heartbeat do servidor — `event: ping` apenas serve para manter o
243
+ // stall watchdog renovado durante pausas longas do agente. Nao tem
244
+ // payload util; o efeito ja foi aplicado no loop ao chamar armStall()
245
+ // a cada chunk recebido. Apenas retornamos sem processar.
246
+ if (eventName === "ping" || eventName === "keepalive" || eventName === "heartbeat") return;
247
+ if (eventName !== "message") return;
248
+
249
+ let msg: SDKMessage;
250
+ try {
251
+ msg = JSON.parse(dataStr) as SDKMessage;
252
+ } catch {
253
+ return;
254
+ }
255
+
256
+ if (msg.type === "assistant") {
257
+ const assistant = msg as SDKAssistantMessage;
258
+ const blocks = assistant.message?.content ?? [];
259
+ for (const block of blocks) {
260
+ if (block.type === "text") {
261
+ accumulatedParts.push({ type: "text", text: block.text });
262
+ } else if (block.type === "tool_use") {
263
+ if (toolPartByCallId.has(block.id)) continue;
264
+ const part: ToolInvocationPart = {
265
+ type: "tool-invocation",
266
+ toolInvocation: {
267
+ toolName: block.name,
268
+ toolCallId: block.id,
269
+ state: "call",
270
+ args: block.input,
271
+ },
272
+ };
273
+ toolPartByCallId.set(block.id, part);
274
+ accumulatedParts.push(part);
275
+ }
276
+ }
277
+ commit();
278
+ return;
279
+ }
280
+
281
+ if (msg.type === "user") {
282
+ // Normalmente traz tool_result com ref a tool_use anterior.
283
+ const userMsg = msg as SDKUserMessage;
284
+ const content = userMsg.message?.content;
285
+ if (Array.isArray(content)) {
286
+ let changed = false;
287
+ for (const block of content as ContentBlock[]) {
288
+ if (block.type === "tool_result") {
289
+ const part = toolPartByCallId.get(block.tool_use_id);
290
+ if (part) {
291
+ part.toolInvocation = {
292
+ ...part.toolInvocation,
293
+ state: "result",
294
+ result: block.content,
295
+ isError: block.is_error,
296
+ };
297
+ changed = true;
298
+ }
299
+ }
300
+ }
301
+ if (changed) commit();
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (msg.type === "result") {
307
+ // fim do turno — nada a fazer aqui; o loop quebra no "done"
308
+ return;
309
+ }
310
+ };
311
+
312
+ try {
313
+ while (true) {
314
+ const { done, value } = await reader.read();
315
+ if (done) break;
316
+ armStall(); // reset watchdog a cada chunk que chega
317
+ buffer += decoder.decode(value, { stream: true });
318
+
319
+ // SSE frames: "event: X\ndata: Y\n\n"
320
+ let boundary: number;
321
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
322
+ const frame = buffer.slice(0, boundary);
323
+ buffer = buffer.slice(boundary + 2);
324
+ let eventName = "message";
325
+ const dataLines: string[] = [];
326
+ for (const line of frame.split("\n")) {
327
+ if (line.startsWith("event:")) eventName = line.slice(6).trim();
328
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
329
+ }
330
+ if (dataLines.length === 0) continue;
331
+ handleEvent(eventName, dataLines.join("\n"));
332
+ }
333
+ }
334
+ } finally {
335
+ disarmStall();
336
+ abortRef.current = null;
337
+ }
338
+
339
+ return { assistantId, partCount: accumulatedParts.length };
340
+ },
341
+ [endpoint, token, turnOptions, doFetch],
342
+ );
343
+
344
+ // ──────────────────────────────────────────────────────────────
345
+ // Helper: garante que NUNCA fica uma bolha de assistant vazia pendurada.
346
+ // Se o turno terminou em erro ou em stream vazio, removemos o placeholder
347
+ // (para o ErrorNote abaixo da mensagem do usuario aparecer) ou injetamos
348
+ // um bloco de texto com a mensagem de erro para o usuario.
349
+ const dropEmptyAssistant = useCallback((assistantId: string | null) => {
350
+ if (!assistantId) return;
351
+ setMessages((prev) => {
352
+ const idx = prev.findIndex((m) => m.id === assistantId);
353
+ if (idx === -1) return prev;
354
+ const msg = prev[idx];
355
+ if (msg.role === "assistant" && msg.parts.length === 0) {
356
+ const next = prev.slice();
357
+ next.splice(idx, 1);
358
+ return next;
359
+ }
360
+ return prev;
361
+ });
362
+ }, []);
363
+
364
+ const sendMessage = useCallback(
365
+ async (text: string) => {
366
+ const trimmed = text.trim();
367
+ if (!trimmed || isLoading) return;
368
+ setError(null);
369
+ lastSentRef.current = trimmed;
370
+
371
+ const userId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
372
+ setMessages((prev) => [
373
+ ...prev,
374
+ {
375
+ id: userId,
376
+ role: "user",
377
+ content: trimmed,
378
+ parts: [{ type: "text", text: trimmed }],
379
+ },
380
+ ]);
381
+
382
+ setIsLoading(true);
383
+ // NOTE: removido o `HARD_LIMIT_MS` do turno inteiro. Em chat agentico o
384
+ // agente pode pensar/rodar tools por minutos sem ser uma falha. A unica
385
+ // razao para abortar e:
386
+ // - usuario clicou stop() (flag __userStop)
387
+ // - stall watchdog (sem nenhum byte SSE em STALL_MS, incluindo pings
388
+ // do servidor — heartbeat server-push e como sabemos que a sessao
389
+ // ainda esta viva mesmo durante pausas longas do LLM)
390
+ // - servidor emitiu `event: error` ou fechou o stream com 0 partes
391
+
392
+ let assistantIdForCleanup: string | null = null;
393
+ try {
394
+ const sid = await ensureSession();
395
+ const { assistantId, partCount } = await streamPrompt(sid, trimmed);
396
+ assistantIdForCleanup = assistantId;
397
+ if (partCount === 0) {
398
+ // Servidor fechou o stream sem emitir nenhum bloco — erro "macio".
399
+ throw new Error(
400
+ "O servidor nao retornou nenhum conteudo. Verifique se o provider de LLM esta configurado.",
401
+ );
402
+ }
403
+ } catch (err) {
404
+ const e = err instanceof Error ? err : new Error(String(err));
405
+ // Aborts originados do `stop()` do usuario sao intencionais — nao viram erro.
406
+ const isUserStop = e.name === "AbortError" && (e as Error & { __userStop?: boolean }).__userStop;
407
+ if (!isUserStop) {
408
+ // Se foi stall/timeout de abort, troca a mensagem por algo legivel.
409
+ const msg =
410
+ e.name === "AbortError"
411
+ ? "Tempo esgotado aguardando resposta do servidor."
412
+ : e.message || "Falha desconhecida ao processar mensagem.";
413
+ setError(new Error(msg));
414
+ }
415
+ dropEmptyAssistant(assistantIdForCleanup);
416
+ } finally {
417
+ setIsLoading(false);
418
+ }
419
+ },
420
+ [isLoading, ensureSession, streamPrompt, dropEmptyAssistant],
421
+ );
422
+
423
+ const handleSubmit = useCallback(
424
+ (e?: { preventDefault?: () => void }) => {
425
+ e?.preventDefault?.();
426
+ const text = input;
427
+ setInput("");
428
+ void sendMessage(text);
429
+ },
430
+ [input, sendMessage],
431
+ );
432
+
433
+ const stop = useCallback(() => {
434
+ const err = new Error("User stopped stream");
435
+ err.name = "AbortError";
436
+ (err as Error & { __userStop?: boolean }).__userStop = true;
437
+ abortRef.current?.abort(err);
438
+ abortRef.current = null;
439
+ setIsLoading(false);
440
+ }, []);
441
+
442
+ const reload = useCallback(async () => {
443
+ if (!lastSentRef.current) return;
444
+ // remove a ultima mensagem do assistant (se existir) antes de re-enviar
445
+ setMessages((prev) => {
446
+ if (prev.length === 0) return prev;
447
+ const last = prev[prev.length - 1];
448
+ return last.role === "assistant" ? prev.slice(0, -1) : prev;
449
+ });
450
+ await sendMessage(lastSentRef.current);
451
+ }, [sendMessage]);
452
+
453
+ const clear = useCallback(() => {
454
+ setMessages([]);
455
+ setError(null);
456
+ lastSentRef.current = null;
457
+ }, []);
458
+
459
+ useEffect(() => {
460
+ return () => abortRef.current?.abort();
461
+ }, []);
462
+
463
+ return {
464
+ sessionId,
465
+ messages,
466
+ input,
467
+ setInput,
468
+ isLoading,
469
+ error,
470
+ handleSubmit,
471
+ sendMessage,
472
+ stop,
473
+ reload,
474
+ clear,
475
+ };
476
+ }