@agentick/react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.ts ADDED
@@ -0,0 +1,612 @@
1
+ /**
2
+ * React hooks for Agentick.
3
+ *
4
+ * @module @agentick/react/hooks
5
+ */
6
+
7
+ import {
8
+ useState,
9
+ useEffect,
10
+ useCallback,
11
+ useRef,
12
+ useSyncExternalStore,
13
+ useMemo,
14
+ useContext,
15
+ } from "react";
16
+ import type {
17
+ AgentickClient,
18
+ ConnectionState,
19
+ StreamEvent,
20
+ SessionStreamEvent,
21
+ StreamingTextState,
22
+ SessionAccessor,
23
+ } from "@agentick/client";
24
+ import { AgentickContext } from "./context";
25
+ import type {
26
+ UseSessionOptions,
27
+ UseSessionResult,
28
+ UseEventsOptions,
29
+ UseEventsResult,
30
+ UseStreamingTextOptions,
31
+ UseStreamingTextResult,
32
+ UseConnectionOptions,
33
+ UseConnectionResult,
34
+ } from "./types";
35
+
36
+ // ============================================================================
37
+ // useClient
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Access the Agentick client from context.
42
+ *
43
+ * @throws If used outside of AgentickProvider
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * import { useClient } from '@agentick/react';
48
+ *
49
+ * function MyComponent() {
50
+ * const client = useClient();
51
+ *
52
+ * // Direct client access for advanced use cases
53
+ * const handleCustomChannel = () => {
54
+ * const session = client.session('conv-123');
55
+ * const channel = session.channel('custom');
56
+ * channel.publish('event', { data: 'value' });
57
+ * };
58
+ *
59
+ * return <button onClick={handleCustomChannel}>Send</button>;
60
+ * }
61
+ * ```
62
+ */
63
+ export function useClient(): AgentickClient {
64
+ const context = useContext(AgentickContext);
65
+
66
+ if (!context) {
67
+ throw new Error("useClient must be used within a AgentickProvider");
68
+ }
69
+
70
+ return context.client;
71
+ }
72
+
73
+ // ============================================================================
74
+ // useConnectionState (alias for useConnection)
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Subscribe to connection state changes.
79
+ *
80
+ * @example
81
+ * ```tsx
82
+ * import { useConnectionState } from '@agentick/react';
83
+ *
84
+ * function ConnectionIndicator() {
85
+ * const state = useConnectionState();
86
+ *
87
+ * return (
88
+ * <div className={`indicator ${state}`}>
89
+ * {state === 'connected' ? 'Online' : 'Offline'}
90
+ * </div>
91
+ * );
92
+ * }
93
+ * ```
94
+ */
95
+ export function useConnectionState(): ConnectionState {
96
+ const client = useClient();
97
+ const [state, setState] = useState<ConnectionState>(client.state);
98
+
99
+ useEffect(() => {
100
+ // Sync initial state
101
+ setState(client.state);
102
+
103
+ // Subscribe to changes
104
+ const unsubscribe = client.onConnectionChange(setState);
105
+ return unsubscribe;
106
+ }, [client]);
107
+
108
+ return state;
109
+ }
110
+
111
+ // ============================================================================
112
+ // useConnection
113
+ // ============================================================================
114
+
115
+ /**
116
+ * Read the SSE connection state.
117
+ */
118
+ export function useConnection(_options: UseConnectionOptions = {}): UseConnectionResult {
119
+ const client = useClient();
120
+ const [state, setState] = useState<ConnectionState>(client.state);
121
+
122
+ useEffect(() => {
123
+ setState(client.state);
124
+ return client.onConnectionChange(setState);
125
+ }, [client]);
126
+
127
+ return {
128
+ state,
129
+ isConnected: state === "connected",
130
+ isConnecting: state === "connecting",
131
+ };
132
+ }
133
+
134
+ // ============================================================================
135
+ // useSession
136
+ // ============================================================================
137
+
138
+ /**
139
+ * Work with a specific session.
140
+ *
141
+ * @example Basic usage with session ID
142
+ * ```tsx
143
+ * import { useSession } from '@agentick/react';
144
+ *
145
+ * function Chat({ sessionId }: { sessionId: string }) {
146
+ * const { send, isSubscribed, subscribe } = useSession({ sessionId });
147
+ * const [input, setInput] = useState('');
148
+ *
149
+ * // Subscribe on mount
150
+ * useEffect(() => {
151
+ * subscribe();
152
+ * }, [subscribe]);
153
+ *
154
+ * const handleSend = async () => {
155
+ * await send(input);
156
+ * setInput('');
157
+ * };
158
+ *
159
+ * return (
160
+ * <div>
161
+ * <input value={input} onChange={(e) => setInput(e.target.value)} />
162
+ * <button onClick={handleSend}>Send</button>
163
+ * </div>
164
+ * );
165
+ * }
166
+ * ```
167
+ *
168
+ * @example Ephemeral session (no sessionId)
169
+ * ```tsx
170
+ * function QuickChat() {
171
+ * const { send } = useSession();
172
+ *
173
+ * // Each send creates/uses an ephemeral session
174
+ * const handleSend = () => send('Hello!');
175
+ *
176
+ * return <button onClick={handleSend}>Ask</button>;
177
+ * }
178
+ * ```
179
+ *
180
+ * @example Auto-subscribe
181
+ * ```tsx
182
+ * function Chat({ sessionId }: { sessionId: string }) {
183
+ * const { send, isSubscribed } = useSession({
184
+ * sessionId,
185
+ * autoSubscribe: true,
186
+ * });
187
+ *
188
+ * if (!isSubscribed) return <div>Subscribing...</div>;
189
+ *
190
+ * return <ChatInterface />;
191
+ * }
192
+ * ```
193
+ */
194
+ export function useSession(options: UseSessionOptions = {}): UseSessionResult {
195
+ const { sessionId, autoSubscribe = false } = options;
196
+
197
+ const client = useClient();
198
+ const mountedRef = useRef(true);
199
+
200
+ // Get or create session accessor
201
+ const accessor = useMemo<SessionAccessor | undefined>(() => {
202
+ if (!sessionId) return undefined;
203
+ return client.session(sessionId);
204
+ }, [client, sessionId]);
205
+
206
+ const [isSubscribed, setIsSubscribed] = useState(false);
207
+
208
+ // Cleanup on unmount
209
+ useEffect(() => {
210
+ mountedRef.current = true;
211
+ return () => {
212
+ mountedRef.current = false;
213
+ };
214
+ }, []);
215
+
216
+ // Subscribe function
217
+ const subscribe = useCallback(() => {
218
+ if (!accessor) return;
219
+ accessor.subscribe();
220
+ if (mountedRef.current) {
221
+ setIsSubscribed(true);
222
+ }
223
+ }, [accessor]);
224
+
225
+ // Unsubscribe function
226
+ const unsubscribe = useCallback(() => {
227
+ if (!accessor) return;
228
+ accessor.unsubscribe();
229
+ if (mountedRef.current) {
230
+ setIsSubscribed(false);
231
+ }
232
+ }, [accessor]);
233
+
234
+ // Auto-subscribe
235
+ useEffect(() => {
236
+ if (autoSubscribe && accessor && !isSubscribed) {
237
+ subscribe();
238
+ }
239
+ }, [autoSubscribe, accessor, isSubscribed, subscribe]);
240
+
241
+ // Send function
242
+ const send = useCallback(
243
+ (input: Parameters<UseSessionResult["send"]>[0]) => {
244
+ if (accessor) {
245
+ const normalizedInput =
246
+ typeof input === "string"
247
+ ? {
248
+ message: {
249
+ role: "user" as const,
250
+ content: [{ type: "text" as const, text: input }],
251
+ },
252
+ }
253
+ : input;
254
+ return accessor.send(normalizedInput as any);
255
+ }
256
+ return client.send(input as any);
257
+ },
258
+ [client, accessor],
259
+ );
260
+
261
+ // Abort function
262
+ const abort = useCallback(
263
+ async (reason?: string) => {
264
+ if (accessor) {
265
+ await accessor.abort(reason);
266
+ } else if (sessionId) {
267
+ await client.abort(sessionId, reason);
268
+ }
269
+ },
270
+ [client, accessor, sessionId],
271
+ );
272
+
273
+ // Close function
274
+ const close = useCallback(async () => {
275
+ if (accessor) {
276
+ await accessor.close();
277
+ } else if (sessionId) {
278
+ await client.closeSession(sessionId);
279
+ }
280
+ }, [client, accessor, sessionId]);
281
+
282
+ return {
283
+ sessionId,
284
+ isSubscribed,
285
+ subscribe,
286
+ unsubscribe,
287
+ send,
288
+ abort,
289
+ close,
290
+ accessor,
291
+ };
292
+ }
293
+
294
+ // ============================================================================
295
+ // useEvents
296
+ // ============================================================================
297
+
298
+ /**
299
+ * Subscribe to stream events.
300
+ *
301
+ * Returns the latest event (not accumulated). Use useStreamingText
302
+ * for accumulated text from content_delta events.
303
+ *
304
+ * @example
305
+ * ```tsx
306
+ * import { useEvents } from '@agentick/react';
307
+ *
308
+ * function EventLog() {
309
+ * const { event } = useEvents();
310
+ *
311
+ * useEffect(() => {
312
+ * if (event) {
313
+ * console.log('Event:', event.type, event);
314
+ * }
315
+ * }, [event]);
316
+ *
317
+ * return <div>Latest: {event?.type}</div>;
318
+ * }
319
+ * ```
320
+ *
321
+ * @example With filter
322
+ * ```tsx
323
+ * function ToolCalls() {
324
+ * const { event } = useEvents({ filter: ['tool_call', 'tool_result'] });
325
+ *
326
+ * if (!event) return null;
327
+ *
328
+ * return <div>Tool: {event.type === 'tool_call' ? event.name : 'result'}</div>;
329
+ * }
330
+ * ```
331
+ *
332
+ * @example Session-specific events
333
+ * ```tsx
334
+ * function SessionEvents({ sessionId }: { sessionId: string }) {
335
+ * const { event } = useEvents({ sessionId });
336
+ * // Only receives events for this session
337
+ * return <div>{event?.type}</div>;
338
+ * }
339
+ * ```
340
+ */
341
+ export function useEvents(options: UseEventsOptions = {}): UseEventsResult {
342
+ const { filter, sessionId, enabled = true } = options;
343
+
344
+ const client = useClient();
345
+ const [event, setEvent] = useState<StreamEvent | SessionStreamEvent | undefined>();
346
+
347
+ useEffect(() => {
348
+ if (!enabled) return;
349
+
350
+ // Use session-specific subscription if sessionId provided
351
+ if (sessionId) {
352
+ const accessor = client.session(sessionId);
353
+ const unsubscribe = accessor.onEvent((incoming) => {
354
+ if (filter && !filter.includes(incoming.type)) {
355
+ return;
356
+ }
357
+ setEvent(incoming);
358
+ });
359
+ return unsubscribe;
360
+ }
361
+
362
+ // Global subscription
363
+ const unsubscribe = client.onEvent((incoming) => {
364
+ if (filter && !filter.includes(incoming.type)) {
365
+ return;
366
+ }
367
+ setEvent(incoming);
368
+ });
369
+
370
+ return unsubscribe;
371
+ }, [client, sessionId, enabled, filter]);
372
+
373
+ const clear = useCallback(() => {
374
+ setEvent(undefined);
375
+ }, []);
376
+
377
+ return { event, clear };
378
+ }
379
+
380
+ // ============================================================================
381
+ // useStreamingText
382
+ // ============================================================================
383
+
384
+ /**
385
+ * Subscribe to streaming text from the client.
386
+ *
387
+ * Uses the client's built-in streaming text accumulation which handles
388
+ * tick_start, content_delta, tick_end, and execution_end events.
389
+ *
390
+ * @example
391
+ * ```tsx
392
+ * import { useStreamingText } from '@agentick/react';
393
+ *
394
+ * function StreamingResponse() {
395
+ * const { text, isStreaming } = useStreamingText();
396
+ *
397
+ * return (
398
+ * <div>
399
+ * <p>{text}</p>
400
+ * {isStreaming && <span className="cursor">|</span>}
401
+ * </div>
402
+ * );
403
+ * }
404
+ * ```
405
+ */
406
+ export function useStreamingText(options: UseStreamingTextOptions = {}): UseStreamingTextResult {
407
+ const { enabled = true } = options;
408
+ const client = useClient();
409
+
410
+ // Use useSyncExternalStore for concurrent-safe subscription
411
+ const state = useSyncExternalStore<StreamingTextState>(
412
+ useCallback(
413
+ (onStoreChange) => {
414
+ if (!enabled) return () => {};
415
+ return client.onStreamingText(onStoreChange);
416
+ },
417
+ [client, enabled],
418
+ ),
419
+ () => (enabled ? client.streamingText : { text: "", isStreaming: false }),
420
+ () => ({ text: "", isStreaming: false }),
421
+ );
422
+
423
+ const clear = useCallback(() => {
424
+ client.clearStreamingText();
425
+ }, [client]);
426
+
427
+ return { text: state.text, isStreaming: state.isStreaming, clear };
428
+ }
429
+
430
+ // ============================================================================
431
+ // useContextInfo
432
+ // ============================================================================
433
+
434
+ /**
435
+ * Context utilization info from the server.
436
+ * Updated after each tick with token usage and model capabilities.
437
+ */
438
+ export interface ContextInfo {
439
+ /** Model ID (e.g., "gpt-4o", "claude-3-5-sonnet-20241022") */
440
+ modelId: string;
441
+ /** Human-readable model name */
442
+ modelName?: string;
443
+ /** Provider name (e.g., "openai", "anthropic") */
444
+ provider?: string;
445
+ /** Context window size in tokens */
446
+ contextWindow?: number;
447
+ /** Input tokens used this tick */
448
+ inputTokens: number;
449
+ /** Output tokens generated this tick */
450
+ outputTokens: number;
451
+ /** Total tokens this tick */
452
+ totalTokens: number;
453
+ /** Context utilization percentage (0-100) */
454
+ utilization?: number;
455
+ /** Max output tokens for this model */
456
+ maxOutputTokens?: number;
457
+ /** Model capabilities */
458
+ supportsVision?: boolean;
459
+ supportsToolUse?: boolean;
460
+ isReasoningModel?: boolean;
461
+ /** Current tick number */
462
+ tick: number;
463
+ /** Cumulative usage across all ticks in this execution */
464
+ cumulativeUsage?: {
465
+ inputTokens: number;
466
+ outputTokens: number;
467
+ totalTokens: number;
468
+ ticks: number;
469
+ };
470
+ }
471
+
472
+ /**
473
+ * Options for useContextInfo hook.
474
+ */
475
+ export interface UseContextInfoOptions {
476
+ /**
477
+ * Optional session ID to filter events for.
478
+ * If not provided, receives context info from any session.
479
+ */
480
+ sessionId?: string;
481
+
482
+ /**
483
+ * Whether the hook is enabled.
484
+ * If false, no context info subscription is created.
485
+ * @default true
486
+ */
487
+ enabled?: boolean;
488
+ }
489
+
490
+ /**
491
+ * Return value from useContextInfo hook.
492
+ */
493
+ export interface UseContextInfoResult {
494
+ /**
495
+ * Latest context info (null before first tick completes).
496
+ */
497
+ contextInfo: ContextInfo | null;
498
+
499
+ /**
500
+ * Clear the current context info.
501
+ */
502
+ clear: () => void;
503
+ }
504
+
505
+ /**
506
+ * Subscribe to context utilization info from the server.
507
+ *
508
+ * Receives context_update events after each tick with:
509
+ * - Token usage (input, output, total)
510
+ * - Context utilization percentage
511
+ * - Model capabilities (vision, tools, reasoning)
512
+ * - Cumulative usage across ticks
513
+ *
514
+ * @example Basic usage
515
+ * ```tsx
516
+ * import { useContextInfo } from '@agentick/react';
517
+ *
518
+ * function ContextBar() {
519
+ * const { contextInfo } = useContextInfo();
520
+ *
521
+ * if (!contextInfo) return null;
522
+ *
523
+ * return (
524
+ * <div className="context-bar">
525
+ * <span>{contextInfo.modelId}</span>
526
+ * <span>{contextInfo.utilization?.toFixed(1)}% used</span>
527
+ * <progress value={contextInfo.utilization} max={100} />
528
+ * </div>
529
+ * );
530
+ * }
531
+ * ```
532
+ *
533
+ * @example Session-specific context info
534
+ * ```tsx
535
+ * function SessionContext({ sessionId }: { sessionId: string }) {
536
+ * const { contextInfo } = useContextInfo({ sessionId });
537
+ *
538
+ * if (!contextInfo) return <span>No context yet</span>;
539
+ *
540
+ * return (
541
+ * <span>
542
+ * {contextInfo.inputTokens.toLocaleString()} /
543
+ * {contextInfo.contextWindow?.toLocaleString() ?? '?'} tokens
544
+ * </span>
545
+ * );
546
+ * }
547
+ * ```
548
+ */
549
+ export function useContextInfo(options: UseContextInfoOptions = {}): UseContextInfoResult {
550
+ const { sessionId, enabled = true } = options;
551
+ const client = useClient();
552
+ const [contextInfo, setContextInfo] = useState<ContextInfo | null>(null);
553
+
554
+ useEffect(() => {
555
+ if (!enabled) return;
556
+
557
+ // Subscribe to context_update events
558
+ const handleEvent = (event: StreamEvent | SessionStreamEvent) => {
559
+ if (event.type !== "context_update") return;
560
+
561
+ // Type assertion since we filtered by type
562
+ const ctxEvent = event as StreamEvent & {
563
+ modelId: string;
564
+ modelName?: string;
565
+ provider?: string;
566
+ contextWindow?: number;
567
+ inputTokens: number;
568
+ outputTokens: number;
569
+ totalTokens: number;
570
+ utilization?: number;
571
+ maxOutputTokens?: number;
572
+ supportsVision?: boolean;
573
+ supportsToolUse?: boolean;
574
+ isReasoningModel?: boolean;
575
+ tick: number;
576
+ cumulativeUsage?: ContextInfo["cumulativeUsage"];
577
+ };
578
+
579
+ setContextInfo({
580
+ modelId: ctxEvent.modelId,
581
+ modelName: ctxEvent.modelName,
582
+ provider: ctxEvent.provider,
583
+ contextWindow: ctxEvent.contextWindow,
584
+ inputTokens: ctxEvent.inputTokens,
585
+ outputTokens: ctxEvent.outputTokens,
586
+ totalTokens: ctxEvent.totalTokens,
587
+ utilization: ctxEvent.utilization,
588
+ maxOutputTokens: ctxEvent.maxOutputTokens,
589
+ supportsVision: ctxEvent.supportsVision,
590
+ supportsToolUse: ctxEvent.supportsToolUse,
591
+ isReasoningModel: ctxEvent.isReasoningModel,
592
+ tick: ctxEvent.tick,
593
+ cumulativeUsage: ctxEvent.cumulativeUsage,
594
+ });
595
+ };
596
+
597
+ // Use session-specific subscription if sessionId provided
598
+ if (sessionId) {
599
+ const accessor = client.session(sessionId);
600
+ return accessor.onEvent(handleEvent);
601
+ }
602
+
603
+ // Global subscription
604
+ return client.onEvent(handleEvent);
605
+ }, [client, sessionId, enabled]);
606
+
607
+ const clear = useCallback(() => {
608
+ setContextInfo(null);
609
+ }, []);
610
+
611
+ return { contextInfo, clear };
612
+ }