@copilotkit/react-core 1.55.0-next.9 → 1.55.1-next.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.
- package/CHANGELOG.md +46 -6
- package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
- package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
- package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
- package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
- package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
- package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
- package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +1400 -238
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +13 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +3 -3
- package/dist/v2/index.d.mts +3 -3
- package/dist/v2/index.mjs +3 -2
- package/dist/v2/index.umd.js +2442 -552
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +62 -54
- package/scripts/scope-preflight.mjs +1 -2
- package/src/components/CopilotListeners.tsx +41 -8
- package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
- package/src/components/toast/toast-provider.tsx +269 -194
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
- package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
- package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
- package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
- package/src/v2/components/CopilotKitInspector.tsx +2 -0
- package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
- package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
- package/src/v2/components/chat/CopilotChat.tsx +193 -50
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
- package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
- package/src/v2/components/chat/CopilotChatView.tsx +179 -66
- package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
- package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
- package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
- package/src/v2/components/chat/index.ts +9 -0
- package/src/v2/components/chat/scroll-element-context.ts +13 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
- package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
- package/src/v2/hooks/index.ts +5 -0
- package/src/v2/hooks/use-agent.tsx +95 -10
- package/src/v2/hooks/use-attachments.tsx +269 -0
- package/src/v2/hooks/use-frontend-tool.tsx +5 -2
- package/src/v2/hooks/use-render-activity-message.tsx +9 -2
- package/src/v2/hooks/use-threads.tsx +35 -15
- package/src/v2/index.ts +5 -1
- package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
- package/src/v2/lib/__tests__/slots.test.ts +56 -0
- package/src/v2/lib/processPartialHtml.ts +45 -0
- package/src/v2/lib/slots.tsx +42 -1
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
- package/src/v2/providers/CopilotKitProvider.tsx +268 -32
- package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
- package/src/v2/providers/index.ts +7 -0
- package/src/v2/styles/globals.css +2 -1
- package/src/v2/types/index.ts +1 -0
- package/src/v2/types/sandbox-function.ts +11 -0
- package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
- package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
- package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
- package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { useAttachments } from "../use-attachments";
|
|
5
|
+
|
|
6
|
+
describe("useAttachments", () => {
|
|
7
|
+
// -----------------------------------------------------------------------
|
|
8
|
+
// Referential stability — callbacks must not change between renders
|
|
9
|
+
// -----------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
describe("referential stability", () => {
|
|
12
|
+
it("all callbacks are stable across re-renders with same config", () => {
|
|
13
|
+
const config = { enabled: true, accept: "image/*" };
|
|
14
|
+
const { result, rerender } = renderHook(() => useAttachments({ config }));
|
|
15
|
+
|
|
16
|
+
const first = result.current;
|
|
17
|
+
rerender();
|
|
18
|
+
const second = result.current;
|
|
19
|
+
|
|
20
|
+
expect(second.processFiles).toBe(first.processFiles);
|
|
21
|
+
expect(second.handleFileUpload).toBe(first.handleFileUpload);
|
|
22
|
+
expect(second.handleDragOver).toBe(first.handleDragOver);
|
|
23
|
+
expect(second.handleDragLeave).toBe(first.handleDragLeave);
|
|
24
|
+
expect(second.handleDrop).toBe(first.handleDrop);
|
|
25
|
+
expect(second.removeAttachment).toBe(first.removeAttachment);
|
|
26
|
+
expect(second.consumeAttachments).toBe(first.consumeAttachments);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("callbacks remain stable when config object reference changes", () => {
|
|
30
|
+
let config = { enabled: true, accept: "image/*" };
|
|
31
|
+
const { result, rerender } = renderHook(() => useAttachments({ config }));
|
|
32
|
+
|
|
33
|
+
const first = result.current;
|
|
34
|
+
|
|
35
|
+
// Create a new config with same values — different reference
|
|
36
|
+
config = { enabled: true, accept: "image/*" };
|
|
37
|
+
rerender();
|
|
38
|
+
const second = result.current;
|
|
39
|
+
|
|
40
|
+
expect(second.processFiles).toBe(first.processFiles);
|
|
41
|
+
expect(second.handleFileUpload).toBe(first.handleFileUpload);
|
|
42
|
+
expect(second.handleDragOver).toBe(first.handleDragOver);
|
|
43
|
+
expect(second.handleDragLeave).toBe(first.handleDragLeave);
|
|
44
|
+
expect(second.handleDrop).toBe(first.handleDrop);
|
|
45
|
+
expect(second.removeAttachment).toBe(first.removeAttachment);
|
|
46
|
+
expect(second.consumeAttachments).toBe(first.consumeAttachments);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("refs are stable across re-renders", () => {
|
|
50
|
+
const { result, rerender } = renderHook(() =>
|
|
51
|
+
useAttachments({ config: undefined }),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const first = result.current;
|
|
55
|
+
rerender();
|
|
56
|
+
const second = result.current;
|
|
57
|
+
|
|
58
|
+
expect(second.fileInputRef).toBe(first.fileInputRef);
|
|
59
|
+
expect(second.containerRef).toBe(first.containerRef);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
// Re-render counting — hook should not cause unnecessary renders
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("re-render counting", () => {
|
|
68
|
+
it("does not re-render when consumeAttachments is called on empty queue", () => {
|
|
69
|
+
let renderCount = 0;
|
|
70
|
+
|
|
71
|
+
const { result } = renderHook(() => {
|
|
72
|
+
renderCount++;
|
|
73
|
+
return useAttachments({ config: undefined });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const initialRenderCount = renderCount;
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
result.current.consumeAttachments();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// consumeAttachments on empty queue should not trigger a state update
|
|
83
|
+
expect(renderCount).toBe(initialRenderCount);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not re-render on repeated consumeAttachments with empty queue", () => {
|
|
87
|
+
let renderCount = 0;
|
|
88
|
+
|
|
89
|
+
const { result } = renderHook(() => {
|
|
90
|
+
renderCount++;
|
|
91
|
+
return useAttachments({ config: undefined });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const initialRenderCount = renderCount;
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current.consumeAttachments();
|
|
98
|
+
result.current.consumeAttachments();
|
|
99
|
+
result.current.consumeAttachments();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(renderCount).toBe(initialRenderCount);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// State defaults
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("initial state", () => {
|
|
111
|
+
it("returns empty attachments and disabled by default", () => {
|
|
112
|
+
const { result } = renderHook(() =>
|
|
113
|
+
useAttachments({ config: undefined }),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(result.current.attachments).toEqual([]);
|
|
117
|
+
expect(result.current.enabled).toBe(false);
|
|
118
|
+
expect(result.current.dragOver).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns enabled when config.enabled is true", () => {
|
|
122
|
+
const { result } = renderHook(() =>
|
|
123
|
+
useAttachments({ config: { enabled: true } }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.current.enabled).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
// consumeAttachments behavior
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe("consumeAttachments", () => {
|
|
135
|
+
it("returns empty array when no attachments", () => {
|
|
136
|
+
const { result } = renderHook(() =>
|
|
137
|
+
useAttachments({ config: undefined }),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
let consumed: any[];
|
|
141
|
+
act(() => {
|
|
142
|
+
consumed = result.current.consumeAttachments();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(consumed!).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
// removeAttachment
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe("removeAttachment", () => {
|
|
154
|
+
it("is a no-op when id does not exist", () => {
|
|
155
|
+
const { result } = renderHook(() =>
|
|
156
|
+
useAttachments({ config: undefined }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const before = result.current.attachments;
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
result.current.removeAttachment("nonexistent");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Should still be empty, no crash
|
|
166
|
+
expect(result.current.attachments).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -383,6 +383,60 @@ describe("useThreads", () => {
|
|
|
383
383
|
});
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
it("exposes thread-scoped pagination properties", async () => {
|
|
387
|
+
fetchMock
|
|
388
|
+
.mockReturnValueOnce(
|
|
389
|
+
jsonResponse({
|
|
390
|
+
threads: sampleThreads,
|
|
391
|
+
joinCode: "jc-1",
|
|
392
|
+
nextCursor: "cursor-abc",
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
|
|
396
|
+
|
|
397
|
+
const { result } = renderHook(() => useThreads(defaultInput));
|
|
398
|
+
|
|
399
|
+
await waitFor(() => {
|
|
400
|
+
expect(result.current.isLoading).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(result.current).toHaveProperty("hasMoreThreads");
|
|
404
|
+
expect(result.current).toHaveProperty("isFetchingMoreThreads");
|
|
405
|
+
expect(result.current).toHaveProperty("fetchMoreThreads");
|
|
406
|
+
expect(result.current).not.toHaveProperty("hasNextPage");
|
|
407
|
+
expect(result.current).not.toHaveProperty("isFetchingNextPage");
|
|
408
|
+
expect(result.current).not.toHaveProperty("fetchNextPage");
|
|
409
|
+
|
|
410
|
+
expect(result.current.hasMoreThreads).toBe(true);
|
|
411
|
+
expect(result.current.isFetchingMoreThreads).toBe(false);
|
|
412
|
+
expect(typeof result.current.fetchMoreThreads).toBe("function");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("does not expose organizationId or createdById on threads", async () => {
|
|
416
|
+
fetchMock
|
|
417
|
+
.mockReturnValueOnce(
|
|
418
|
+
jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
|
|
419
|
+
)
|
|
420
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
|
|
421
|
+
|
|
422
|
+
const { result } = renderHook(() => useThreads(defaultInput));
|
|
423
|
+
|
|
424
|
+
await waitFor(() => {
|
|
425
|
+
expect(result.current.isLoading).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
for (const thread of result.current.threads) {
|
|
429
|
+
expect(thread).not.toHaveProperty("organizationId");
|
|
430
|
+
expect(thread).not.toHaveProperty("createdById");
|
|
431
|
+
expect(thread).toHaveProperty("id");
|
|
432
|
+
expect(thread).toHaveProperty("agentId");
|
|
433
|
+
expect(thread).toHaveProperty("name");
|
|
434
|
+
expect(thread).toHaveProperty("archived");
|
|
435
|
+
expect(thread).toHaveProperty("createdAt");
|
|
436
|
+
expect(thread).toHaveProperty("updatedAt");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
386
440
|
it("tears down sockets after repeated connection failures", async () => {
|
|
387
441
|
fetchMock
|
|
388
442
|
.mockReturnValueOnce(
|
package/src/v2/hooks/index.ts
CHANGED
|
@@ -16,3 +16,8 @@ export { useInterrupt } from "./use-interrupt";
|
|
|
16
16
|
export type { UseInterruptConfig } from "./use-interrupt";
|
|
17
17
|
export { useThreads } from "./use-threads";
|
|
18
18
|
export type { Thread, UseThreadsInput, UseThreadsResult } from "./use-threads";
|
|
19
|
+
export { useAttachments } from "./use-attachments";
|
|
20
|
+
export type {
|
|
21
|
+
UseAttachmentsProps,
|
|
22
|
+
UseAttachmentsReturn,
|
|
23
|
+
} from "./use-attachments";
|
|
@@ -24,6 +24,26 @@ export interface UseAgentProps {
|
|
|
24
24
|
agentId?: string;
|
|
25
25
|
threadId?: string;
|
|
26
26
|
updates?: UseAgentUpdate[];
|
|
27
|
+
/**
|
|
28
|
+
* Throttle interval (in milliseconds) for React re-renders triggered by
|
|
29
|
+
* `OnMessagesChanged` notifications. Useful to reduce re-render frequency
|
|
30
|
+
* during high-frequency message updates such as streaming.
|
|
31
|
+
*
|
|
32
|
+
* Uses leading+trailing: first update fires immediately, subsequent updates
|
|
33
|
+
* within the window are coalesced, and a trailing timer ensures the most
|
|
34
|
+
* recent update fires after the window expires. The trailing edge restarts
|
|
35
|
+
* the throttle window, so no two renders occur within `throttleMs` of each
|
|
36
|
+
* other. Cleanup on unmount cancels any pending trailing timer.
|
|
37
|
+
*
|
|
38
|
+
* Must be a non-negative finite number. Negative or non-finite values fall
|
|
39
|
+
* back to unthrottled behavior with a `console.error`. Only affects
|
|
40
|
+
* `OnMessagesChanged` updates — `OnStateChanged` and `OnRunStatusChanged`
|
|
41
|
+
* always fire immediately. If `updates` does not include
|
|
42
|
+
* `OnMessagesChanged`, this property has no effect.
|
|
43
|
+
*
|
|
44
|
+
* Default: `0` (no throttle).
|
|
45
|
+
*/
|
|
46
|
+
throttleMs?: number;
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
/**
|
|
@@ -94,16 +114,40 @@ function getOrCreateThreadClone(
|
|
|
94
114
|
return clone;
|
|
95
115
|
}
|
|
96
116
|
|
|
97
|
-
export function useAgent({
|
|
117
|
+
export function useAgent({
|
|
118
|
+
agentId,
|
|
119
|
+
threadId,
|
|
120
|
+
updates,
|
|
121
|
+
throttleMs,
|
|
122
|
+
}: UseAgentProps = {}) {
|
|
98
123
|
agentId ??= DEFAULT_AGENT_ID;
|
|
99
124
|
|
|
100
125
|
const { copilotkit } = useCopilotKit();
|
|
126
|
+
const providerThrottleMs = copilotkit.defaultThrottleMs;
|
|
101
127
|
// Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
|
|
102
128
|
// that useAgent() called without explicit threadId (e.g. inside a custom
|
|
103
129
|
// message renderer) automatically uses the same per-thread clone as the
|
|
104
130
|
// CopilotChat component it lives within.
|
|
105
131
|
const chatConfig = useCopilotChatConfiguration();
|
|
106
132
|
threadId ??= chatConfig?.threadId;
|
|
133
|
+
|
|
134
|
+
const effectiveThrottleMs = useMemo(() => {
|
|
135
|
+
const resolved = throttleMs ?? providerThrottleMs ?? 0;
|
|
136
|
+
if (!Number.isFinite(resolved) || resolved < 0) {
|
|
137
|
+
// When both throttleMs and providerThrottleMs are undefined, resolved
|
|
138
|
+
// is 0 which passes validation — so one of them must be defined here.
|
|
139
|
+
const source =
|
|
140
|
+
throttleMs !== undefined
|
|
141
|
+
? "hook-level throttleMs"
|
|
142
|
+
: "provider-level defaultThrottleMs";
|
|
143
|
+
console.error(
|
|
144
|
+
`useAgent: ${source} must be a non-negative finite number, got ${resolved}. Falling back to unthrottled.`,
|
|
145
|
+
);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
return resolved;
|
|
149
|
+
}, [throttleMs, providerThrottleMs]);
|
|
150
|
+
|
|
107
151
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
108
152
|
|
|
109
153
|
const updateFlags = useMemo(
|
|
@@ -231,17 +275,52 @@ export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
|
|
|
231
275
|
]);
|
|
232
276
|
|
|
233
277
|
useEffect(() => {
|
|
234
|
-
if (updateFlags.length === 0)
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
278
|
+
if (updateFlags.length === 0) return;
|
|
237
279
|
|
|
238
280
|
const handlers: Parameters<AbstractAgent["subscribe"]>[0] = {};
|
|
281
|
+
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
282
|
+
let active = true;
|
|
239
283
|
|
|
240
284
|
if (updateFlags.includes(UseAgentUpdate.OnMessagesChanged)) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
285
|
+
const ms = effectiveThrottleMs;
|
|
286
|
+
if (ms > 0) {
|
|
287
|
+
// Throttled onMessagesChanged: leading+trailing pattern.
|
|
288
|
+
// First notification fires immediately, subsequent ones within the
|
|
289
|
+
// window are coalesced. Trailing timer fires after the window to
|
|
290
|
+
// ensure the final state is rendered.
|
|
291
|
+
let throttleActive = false;
|
|
292
|
+
// Tracks whether a notification arrived during the throttle window,
|
|
293
|
+
// so the trailing timer knows whether a re-render is needed.
|
|
294
|
+
let pending = false;
|
|
295
|
+
|
|
296
|
+
const throttledNotify = () => {
|
|
297
|
+
if (!active) return;
|
|
298
|
+
if (!throttleActive) {
|
|
299
|
+
// Leading edge — fire immediately and start the throttle window
|
|
300
|
+
throttleActive = true;
|
|
301
|
+
pending = false;
|
|
302
|
+
forceUpdate();
|
|
303
|
+
timerId = setTimeout(function trailingEdge() {
|
|
304
|
+
timerId = null;
|
|
305
|
+
if (active && pending) {
|
|
306
|
+
// Trailing edge — fire and restart the window
|
|
307
|
+
pending = false;
|
|
308
|
+
forceUpdate();
|
|
309
|
+
timerId = setTimeout(trailingEdge, ms);
|
|
310
|
+
} else {
|
|
311
|
+
// No pending notifications — end the window
|
|
312
|
+
throttleActive = false;
|
|
313
|
+
}
|
|
314
|
+
}, ms);
|
|
315
|
+
} else {
|
|
316
|
+
pending = true;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
handlers.onMessagesChanged = throttledNotify;
|
|
321
|
+
} else {
|
|
322
|
+
handlers.onMessagesChanged = forceUpdate;
|
|
323
|
+
}
|
|
245
324
|
}
|
|
246
325
|
|
|
247
326
|
if (updateFlags.includes(UseAgentUpdate.OnStateChanged)) {
|
|
@@ -255,9 +334,15 @@ export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
|
|
|
255
334
|
}
|
|
256
335
|
|
|
257
336
|
const subscription = agent.subscribe(handlers);
|
|
258
|
-
return () =>
|
|
337
|
+
return () => {
|
|
338
|
+
active = false;
|
|
339
|
+
if (timerId !== null) {
|
|
340
|
+
clearTimeout(timerId);
|
|
341
|
+
}
|
|
342
|
+
subscription.unsubscribe();
|
|
343
|
+
};
|
|
259
344
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
260
|
-
}, [agent, forceUpdate,
|
|
345
|
+
}, [agent, forceUpdate, effectiveThrottleMs, updateFlags]);
|
|
261
346
|
|
|
262
347
|
// Keep HttpAgent headers fresh without mutating inside useMemo, which is
|
|
263
348
|
// unsafe in concurrent mode (React may invoke useMemo multiple times and
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
randomUUID,
|
|
4
|
+
getModalityFromMimeType,
|
|
5
|
+
exceedsMaxSize,
|
|
6
|
+
readFileAsBase64,
|
|
7
|
+
generateVideoThumbnail,
|
|
8
|
+
matchesAcceptFilter,
|
|
9
|
+
formatFileSize,
|
|
10
|
+
} from "@copilotkit/shared";
|
|
11
|
+
import type { Attachment, AttachmentsConfig } from "@copilotkit/shared";
|
|
12
|
+
|
|
13
|
+
export interface UseAttachmentsProps {
|
|
14
|
+
config?: AttachmentsConfig;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseAttachmentsReturn {
|
|
18
|
+
/** Currently selected attachments (uploading + ready). */
|
|
19
|
+
attachments: Attachment[];
|
|
20
|
+
/** Whether attachments are enabled. */
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
/** Whether the user is dragging a file over the drop zone. */
|
|
23
|
+
dragOver: boolean;
|
|
24
|
+
/** Ref for the hidden file input element. */
|
|
25
|
+
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
|
26
|
+
/** Ref for the container element (used for scoped paste handling). */
|
|
27
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
28
|
+
/** Process an array of files (validate, upload, add to state). */
|
|
29
|
+
processFiles: (files: File[]) => Promise<void>;
|
|
30
|
+
/** Handler for `<input type="file" onChange>`. */
|
|
31
|
+
handleFileUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
|
32
|
+
/** Handler for `onDragOver` on the drop zone. */
|
|
33
|
+
handleDragOver: (e: React.DragEvent) => void;
|
|
34
|
+
/** Handler for `onDragLeave` on the drop zone. */
|
|
35
|
+
handleDragLeave: (e: React.DragEvent) => void;
|
|
36
|
+
/** Handler for `onDrop` on the drop zone. */
|
|
37
|
+
handleDrop: (e: React.DragEvent) => Promise<void>;
|
|
38
|
+
/** Remove an attachment by ID. */
|
|
39
|
+
removeAttachment: (id: string) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Consume ready attachments and clear the queue.
|
|
42
|
+
* Returns the attachments that were ready; resets the file input.
|
|
43
|
+
* No-ops if the queue is already empty (no state update triggered).
|
|
44
|
+
*/
|
|
45
|
+
consumeAttachments: () => Attachment[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook that manages file attachment state — uploads, drag-and-drop, paste,
|
|
50
|
+
* and lifecycle. All returned callbacks are referentially stable across
|
|
51
|
+
* renders (via useCallback) to avoid destabilizing downstream memoization.
|
|
52
|
+
*/
|
|
53
|
+
export function useAttachments({
|
|
54
|
+
config,
|
|
55
|
+
}: UseAttachmentsProps): UseAttachmentsReturn {
|
|
56
|
+
const enabled = config?.enabled ?? false;
|
|
57
|
+
|
|
58
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
59
|
+
const [dragOver, setDragOver] = useState(false);
|
|
60
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
61
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
|
|
63
|
+
// Keep refs to the latest values so stable callbacks can read current
|
|
64
|
+
// state without appearing in dependency arrays.
|
|
65
|
+
const configRef = useRef(config);
|
|
66
|
+
configRef.current = config;
|
|
67
|
+
const attachmentsRef = useRef<Attachment[]>([]);
|
|
68
|
+
attachmentsRef.current = attachments;
|
|
69
|
+
|
|
70
|
+
// Stable processFiles — reads config from ref, never changes identity
|
|
71
|
+
const processFiles = useCallback(async (files: File[]) => {
|
|
72
|
+
const cfg = configRef.current;
|
|
73
|
+
const accept = cfg?.accept ?? "*/*";
|
|
74
|
+
const maxSize = cfg?.maxSize ?? 20 * 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
const rejectedFiles = files.filter(
|
|
77
|
+
(file) => !matchesAcceptFilter(file, accept),
|
|
78
|
+
);
|
|
79
|
+
for (const file of rejectedFiles) {
|
|
80
|
+
cfg?.onUploadFailed?.({
|
|
81
|
+
reason: "invalid-type",
|
|
82
|
+
file,
|
|
83
|
+
message: `File "${file.name}" is not accepted. Supported types: ${accept}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const validFiles = files.filter((file) =>
|
|
88
|
+
matchesAcceptFilter(file, accept),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
for (const file of validFiles) {
|
|
92
|
+
if (exceedsMaxSize(file, maxSize)) {
|
|
93
|
+
cfg?.onUploadFailed?.({
|
|
94
|
+
reason: "file-too-large",
|
|
95
|
+
file,
|
|
96
|
+
message: `File "${file.name}" exceeds the maximum size of ${formatFileSize(maxSize)}`,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const modality = getModalityFromMimeType(file.type);
|
|
102
|
+
const placeholderId = randomUUID();
|
|
103
|
+
const placeholder: Attachment = {
|
|
104
|
+
id: placeholderId,
|
|
105
|
+
type: modality,
|
|
106
|
+
source: { type: "data", value: "", mimeType: file.type },
|
|
107
|
+
filename: file.name,
|
|
108
|
+
size: file.size,
|
|
109
|
+
status: "uploading",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
setAttachments((prev) => [...prev, placeholder]);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
let source: Attachment["source"];
|
|
116
|
+
let uploadMetadata: Record<string, unknown> | undefined;
|
|
117
|
+
|
|
118
|
+
if (cfg?.onUpload) {
|
|
119
|
+
const { metadata: meta, ...uploadSource } = await cfg.onUpload(file);
|
|
120
|
+
source = uploadSource;
|
|
121
|
+
uploadMetadata = meta;
|
|
122
|
+
} else {
|
|
123
|
+
const base64 = await readFileAsBase64(file);
|
|
124
|
+
source = { type: "data", value: base64, mimeType: file.type };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let thumbnail: string | undefined;
|
|
128
|
+
if (modality === "video") {
|
|
129
|
+
thumbnail = await generateVideoThumbnail(file);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setAttachments((prev) =>
|
|
133
|
+
prev.map((att) =>
|
|
134
|
+
att.id === placeholderId
|
|
135
|
+
? {
|
|
136
|
+
...att,
|
|
137
|
+
source,
|
|
138
|
+
status: "ready" as const,
|
|
139
|
+
thumbnail,
|
|
140
|
+
metadata: uploadMetadata,
|
|
141
|
+
}
|
|
142
|
+
: att,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
setAttachments((prev) =>
|
|
147
|
+
prev.filter((att) => att.id !== placeholderId),
|
|
148
|
+
);
|
|
149
|
+
console.error(`[CopilotKit] Failed to upload "${file.name}":`, error);
|
|
150
|
+
cfg?.onUploadFailed?.({
|
|
151
|
+
reason: "upload-failed",
|
|
152
|
+
file,
|
|
153
|
+
message:
|
|
154
|
+
error instanceof Error
|
|
155
|
+
? error.message
|
|
156
|
+
: `Failed to upload "${file.name}"`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const handleFileUpload = useCallback(
|
|
163
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
164
|
+
if (!e.target.files?.length) return;
|
|
165
|
+
try {
|
|
166
|
+
await processFiles(Array.from(e.target.files));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error("[CopilotKit] Upload error:", error);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
[processFiles],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
175
|
+
if (!configRef.current?.enabled) return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
setDragOver(true);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
e.stopPropagation();
|
|
184
|
+
setDragOver(false);
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
const handleDrop = useCallback(
|
|
188
|
+
async (e: React.DragEvent) => {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
setDragOver(false);
|
|
192
|
+
if (!configRef.current?.enabled) return;
|
|
193
|
+
|
|
194
|
+
const files = Array.from(e.dataTransfer.files);
|
|
195
|
+
if (files.length > 0) {
|
|
196
|
+
try {
|
|
197
|
+
await processFiles(files);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error("[CopilotKit] Drop error:", error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[processFiles],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Clipboard paste handler — scoped to the container
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!enabled) return;
|
|
209
|
+
|
|
210
|
+
const handlePaste = async (e: ClipboardEvent) => {
|
|
211
|
+
const target = e.target as HTMLElement | null;
|
|
212
|
+
if (!target || !containerRef.current?.contains(target)) return;
|
|
213
|
+
|
|
214
|
+
const accept = configRef.current?.accept ?? "*/*";
|
|
215
|
+
const items = Array.from(e.clipboardData?.items || []);
|
|
216
|
+
const fileItems = items.filter(
|
|
217
|
+
(item) =>
|
|
218
|
+
item.kind === "file" &&
|
|
219
|
+
item.getAsFile() !== null &&
|
|
220
|
+
matchesAcceptFilter(item.getAsFile()!, accept),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (fileItems.length === 0) return;
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
|
|
226
|
+
const files = fileItems
|
|
227
|
+
.map((item) => item.getAsFile())
|
|
228
|
+
.filter((f): f is File => f !== null);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await processFiles(files);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error("[CopilotKit] Paste error:", error);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
document.addEventListener("paste", handlePaste);
|
|
238
|
+
return () => document.removeEventListener("paste", handlePaste);
|
|
239
|
+
}, [enabled, processFiles]);
|
|
240
|
+
|
|
241
|
+
const removeAttachment = useCallback((id: string) => {
|
|
242
|
+
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const consumeAttachments = useCallback(() => {
|
|
246
|
+
const ready = attachmentsRef.current.filter((a) => a.status === "ready");
|
|
247
|
+
if (ready.length === 0) return ready;
|
|
248
|
+
setAttachments((prev) => prev.filter((a) => a.status !== "ready"));
|
|
249
|
+
if (fileInputRef.current) {
|
|
250
|
+
fileInputRef.current.value = "";
|
|
251
|
+
}
|
|
252
|
+
return ready;
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
attachments,
|
|
257
|
+
enabled,
|
|
258
|
+
dragOver,
|
|
259
|
+
fileInputRef,
|
|
260
|
+
containerRef,
|
|
261
|
+
processFiles,
|
|
262
|
+
handleFileUpload,
|
|
263
|
+
handleDragOver,
|
|
264
|
+
handleDragLeave,
|
|
265
|
+
handleDrop,
|
|
266
|
+
removeAttachment,
|
|
267
|
+
consumeAttachments,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -22,8 +22,11 @@ export function useFrontendTool<
|
|
|
22
22
|
}
|
|
23
23
|
copilotkit.addTool(tool);
|
|
24
24
|
|
|
25
|
-
// Register/override renderer by name and agentId through core
|
|
26
|
-
|
|
25
|
+
// Register/override renderer by name and agentId through core.
|
|
26
|
+
// The render function is registered even when tool.parameters is
|
|
27
|
+
// undefined — tools like HITL confirm dialogs have no parameters
|
|
28
|
+
// but still need their UI rendered in the chat.
|
|
29
|
+
if (tool.render) {
|
|
27
30
|
copilotkit.addHookRenderToolCall({
|
|
28
31
|
name,
|
|
29
32
|
args: tool.parameters,
|