@copilotkit/react-ui 1.55.3 → 1.56.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/dist/index.cjs +32 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +27 -20
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +36 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +36 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +33 -29
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +33 -28
- package/dist/index.umd.js.map +1 -1
- package/package.json +4 -4
- package/src/components/chat/Markdown.test.ts +101 -0
- package/src/components/chat/Markdown.tsx +11 -5
- package/src/components/chat/Messages.tsx +3 -3
- package/src/components/chat/index.tsx +2 -0
- package/src/components/chat/messages/AssistantMessage.tsx +7 -8
- package/src/components/chat/messages/ErrorMessage.tsx +7 -8
- package/src/components/dev-console/console.tsx +9 -5
- package/src/css/colors.css +1 -1
- package/src/css/console.css +16 -10
- package/src/css/input.css +5 -5
- package/src/css/markdown.css +3 -3
- package/src/css/messages.css +2 -1
- package/src/hooks/__tests__/use-push-to-talk.test.ts +49 -0
- package/src/hooks/use-push-to-talk.tsx +19 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"rehype-raw": "^7.0.0",
|
|
49
49
|
"remark-gfm": "^4.0.1",
|
|
50
50
|
"remark-math": "^6.0.0",
|
|
51
|
-
"@copilotkit/
|
|
52
|
-
"@copilotkit/
|
|
53
|
-
"@copilotkit/shared": "1.
|
|
51
|
+
"@copilotkit/react-core": "1.56.1",
|
|
52
|
+
"@copilotkit/runtime-client-gql": "1.56.1",
|
|
53
|
+
"@copilotkit/shared": "1.56.1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "^19.1.0",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* These tests verify that CSS selectors in markdown.css correctly target
|
|
6
|
+
* the class names used by the Markdown component. When the p tag was changed
|
|
7
|
+
* to a div (to fix hydration errors), the CSS selectors must use class-only
|
|
8
|
+
* selectors instead of element-qualified selectors for paragraphs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const cssPath = resolve(__dirname, "../../css/markdown.css");
|
|
12
|
+
const tsxPath = resolve(__dirname, "Markdown.tsx");
|
|
13
|
+
|
|
14
|
+
const cssContent = readFileSync(cssPath, "utf-8");
|
|
15
|
+
const tsxContent = readFileSync(tsxPath, "utf-8");
|
|
16
|
+
|
|
17
|
+
describe("Markdown CSS/component selector consistency", () => {
|
|
18
|
+
it("should not use p.copilotKitMarkdownElement selector in CSS", () => {
|
|
19
|
+
// After the p->div change, CSS must not use element-qualified p selectors
|
|
20
|
+
expect(cssContent).not.toMatch(/\bp\.copilotKitMarkdownElement\b/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should have .copilotKitParagraph selector in CSS for paragraph styling", () => {
|
|
24
|
+
expect(cssContent).toMatch(/\.copilotKitParagraph\s*\{/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should have .copilotKitParagraph:not(:last-child) selector for paragraph spacing", () => {
|
|
28
|
+
expect(cssContent).toMatch(/\.copilotKitParagraph:not\(:last-child\)/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should use copilotKitParagraph class on the paragraph component", () => {
|
|
32
|
+
// The p component override in Markdown.tsx should include copilotKitParagraph
|
|
33
|
+
expect(tsxContent).toMatch(/copilotKitParagraph/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should render a div instead of p for the paragraph component", () => {
|
|
37
|
+
// The paragraph component should use <div> to avoid hydration errors
|
|
38
|
+
// when block-level elements are nested inside markdown paragraphs
|
|
39
|
+
const pComponentMatch = tsxContent.match(
|
|
40
|
+
/p:\s*\(\{[^}]*\}\)\s*=>\s*\(\s*<(\w+)/,
|
|
41
|
+
);
|
|
42
|
+
expect(pComponentMatch).not.toBeNull();
|
|
43
|
+
expect(pComponentMatch![1]).toBe("div");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should still have copilotKitMarkdownElement class on the paragraph div", () => {
|
|
47
|
+
// The div should retain the base class for any shared styling
|
|
48
|
+
const pSection = tsxContent.match(/p:\s*\([^)]*\)\s*=>\s*\([^)]+\)/s);
|
|
49
|
+
expect(pSection).not.toBeNull();
|
|
50
|
+
expect(pSection![0]).toContain("copilotKitMarkdownElement");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should use .copilotKitParagraph (not p) inside blockquote selector", () => {
|
|
54
|
+
// After p->div, blockquote nested paragraph selector must target the class
|
|
55
|
+
expect(cssContent).toMatch(
|
|
56
|
+
/blockquote\.copilotKitMarkdownElement\s+\.copilotKitParagraph\s*\{/,
|
|
57
|
+
);
|
|
58
|
+
expect(cssContent).not.toMatch(
|
|
59
|
+
/blockquote\.copilotKitMarkdownElement\s+p\s*\{/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("other element selectors remain valid", () => {
|
|
64
|
+
const elementSelectors = [
|
|
65
|
+
{ element: "h1", selector: "h1.copilotKitMarkdownElement" },
|
|
66
|
+
{ element: "h2", selector: "h2.copilotKitMarkdownElement" },
|
|
67
|
+
{ element: "h3", selector: "h3.copilotKitMarkdownElement" },
|
|
68
|
+
{ element: "h4", selector: "h4.copilotKitMarkdownElement" },
|
|
69
|
+
{ element: "h5", selector: "h5.copilotKitMarkdownElement" },
|
|
70
|
+
{ element: "h6", selector: "h6.copilotKitMarkdownElement" },
|
|
71
|
+
{ element: "a", selector: "a.copilotKitMarkdownElement" },
|
|
72
|
+
{ element: "pre", selector: "pre.copilotKitMarkdownElement" },
|
|
73
|
+
{
|
|
74
|
+
element: "blockquote",
|
|
75
|
+
selector: "blockquote.copilotKitMarkdownElement",
|
|
76
|
+
},
|
|
77
|
+
{ element: "ul", selector: "ul.copilotKitMarkdownElement" },
|
|
78
|
+
{ element: "li", selector: "li.copilotKitMarkdownElement" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const { element, selector } of elementSelectors) {
|
|
82
|
+
it(`should have ${element} component rendering <${element}> with copilotKitMarkdownElement class`, () => {
|
|
83
|
+
// Verify the component still uses the actual HTML element
|
|
84
|
+
// Some components use arrow syntax, others use function syntax
|
|
85
|
+
const arrowRegex = new RegExp(
|
|
86
|
+
`${element}:\\s*\\(\\{[^}]*\\}\\)\\s*=>\\s*\\(\\s*<${element}[\\s\\S]*?copilotKitMarkdownElement`,
|
|
87
|
+
);
|
|
88
|
+
const funcRegex = new RegExp(
|
|
89
|
+
`${element}\\([^)]*\\)\\s*\\{[\\s\\S]*?<${element}[\\s\\S]*?copilotKitMarkdownElement`,
|
|
90
|
+
);
|
|
91
|
+
const matches =
|
|
92
|
+
arrowRegex.test(tsxContent) || funcRegex.test(tsxContent);
|
|
93
|
+
expect(matches).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it(`should have CSS selector ${selector}`, () => {
|
|
97
|
+
expect(cssContent).toContain(selector);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -59,7 +59,6 @@ const defaultComponents: Components = {
|
|
|
59
59
|
|
|
60
60
|
return (
|
|
61
61
|
<CodeBlock
|
|
62
|
-
key={Math.random()}
|
|
63
62
|
language={(match && match[1]) || ""}
|
|
64
63
|
value={String(children).replace(/\n$/, "")}
|
|
65
64
|
{...props}
|
|
@@ -97,9 +96,9 @@ const defaultComponents: Components = {
|
|
|
97
96
|
</h6>
|
|
98
97
|
),
|
|
99
98
|
p: ({ children, ...props }) => (
|
|
100
|
-
<
|
|
99
|
+
<div className="copilotKitMarkdownElement copilotKitParagraph" {...props}>
|
|
101
100
|
{children}
|
|
102
|
-
</
|
|
101
|
+
</div>
|
|
103
102
|
),
|
|
104
103
|
pre: ({ children, ...props }) => (
|
|
105
104
|
<pre className="copilotKitMarkdownElement" {...props}>
|
|
@@ -127,15 +126,21 @@ const MemoizedReactMarkdown: FC<Options> = memo(
|
|
|
127
126
|
ReactMarkdown,
|
|
128
127
|
(prevProps, nextProps) =>
|
|
129
128
|
prevProps.children === nextProps.children &&
|
|
130
|
-
prevProps.components === nextProps.components
|
|
129
|
+
prevProps.components === nextProps.components &&
|
|
130
|
+
prevProps.urlTransform === nextProps.urlTransform,
|
|
131
131
|
);
|
|
132
132
|
|
|
133
133
|
type MarkdownProps = {
|
|
134
134
|
content: string;
|
|
135
135
|
components?: Components;
|
|
136
|
+
urlTransform?: Options["urlTransform"];
|
|
136
137
|
};
|
|
137
138
|
|
|
138
|
-
export const Markdown = ({
|
|
139
|
+
export const Markdown = ({
|
|
140
|
+
content,
|
|
141
|
+
components,
|
|
142
|
+
urlTransform,
|
|
143
|
+
}: MarkdownProps) => {
|
|
139
144
|
const mergedComponents = useMemo(
|
|
140
145
|
() => ({ ...defaultComponents, ...components }),
|
|
141
146
|
[components],
|
|
@@ -149,6 +154,7 @@ export const Markdown = ({ content, components }: MarkdownProps) => {
|
|
|
149
154
|
[remarkMath, { singleDollarTextMath: false }],
|
|
150
155
|
]}
|
|
151
156
|
rehypePlugins={[rehypeRaw]}
|
|
157
|
+
{...(urlTransform !== undefined ? { urlTransform } : {})}
|
|
152
158
|
>
|
|
153
159
|
{content}
|
|
154
160
|
</MemoizedReactMarkdown>
|
|
@@ -112,9 +112,9 @@ export const Messages = ({
|
|
|
112
112
|
/>
|
|
113
113
|
);
|
|
114
114
|
})}
|
|
115
|
-
{
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
{inProgress &&
|
|
116
|
+
(messages[messages.length - 1]?.role === "user" ||
|
|
117
|
+
messages[messages.length - 1]?.role === "tool") && <LoadingIcon />}
|
|
118
118
|
{interrupt}
|
|
119
119
|
{chatError && ErrorMessage && (
|
|
120
120
|
<ErrorMessage error={chatError} isCurrentMessage />
|
|
@@ -2,6 +2,8 @@ export * from "./props";
|
|
|
2
2
|
export { CopilotPopup } from "./Popup";
|
|
3
3
|
export { CopilotSidebar } from "./Sidebar";
|
|
4
4
|
export { CopilotChat } from "./Chat";
|
|
5
|
+
export { CopilotModal } from "./Modal";
|
|
6
|
+
export type { CopilotModalProps } from "./Modal";
|
|
5
7
|
export { Markdown } from "./Markdown";
|
|
6
8
|
export { AssistantMessage } from "./messages/AssistantMessage";
|
|
7
9
|
export { UserMessage } from "./messages/UserMessage";
|
|
@@ -3,6 +3,7 @@ import { useChatContext } from "../ChatContext";
|
|
|
3
3
|
import { Markdown } from "../Markdown";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import React from "react";
|
|
6
|
+
import { copyToClipboard } from "@copilotkit/shared";
|
|
6
7
|
|
|
7
8
|
export const AssistantMessage = (props: AssistantMessageProps) => {
|
|
8
9
|
const { icons, labels } = useChatContext();
|
|
@@ -19,16 +20,14 @@ export const AssistantMessage = (props: AssistantMessageProps) => {
|
|
|
19
20
|
} = props;
|
|
20
21
|
const [copied, setCopied] = useState(false);
|
|
21
22
|
|
|
22
|
-
const handleCopy = () => {
|
|
23
|
+
const handleCopy = async () => {
|
|
23
24
|
const content = message?.content || "";
|
|
24
|
-
if (content
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
setTimeout(() => setCopied(false), 2000);
|
|
29
|
-
} else if (content) {
|
|
30
|
-
navigator.clipboard.writeText(content);
|
|
25
|
+
if (!content) return;
|
|
26
|
+
|
|
27
|
+
const success = await copyToClipboard(content);
|
|
28
|
+
if (success) {
|
|
31
29
|
setCopied(true);
|
|
30
|
+
if (onCopy) onCopy(content);
|
|
32
31
|
setTimeout(() => setCopied(false), 2000);
|
|
33
32
|
}
|
|
34
33
|
};
|
|
@@ -2,22 +2,21 @@ import { ErrorMessageProps } from "../props";
|
|
|
2
2
|
import { useChatContext } from "../ChatContext";
|
|
3
3
|
import { Markdown } from "../Markdown";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import { copyToClipboard } from "@copilotkit/shared";
|
|
5
6
|
|
|
6
7
|
export const ErrorMessage = (props: ErrorMessageProps) => {
|
|
7
8
|
const { icons, labels } = useChatContext();
|
|
8
9
|
const { error, onRegenerate, onCopy, isCurrentMessage } = props;
|
|
9
10
|
const [copied, setCopied] = useState(false);
|
|
10
11
|
|
|
11
|
-
const handleCopy = () => {
|
|
12
|
+
const handleCopy = async () => {
|
|
12
13
|
const content = error.message;
|
|
13
|
-
if (content
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
setTimeout(() => setCopied(false), 2000);
|
|
18
|
-
} else if (content) {
|
|
19
|
-
navigator.clipboard.writeText(content);
|
|
14
|
+
if (!content) return;
|
|
15
|
+
|
|
16
|
+
const success = await copyToClipboard(content);
|
|
17
|
+
if (success) {
|
|
20
18
|
setCopied(true);
|
|
19
|
+
if (onCopy) onCopy(content);
|
|
21
20
|
setTimeout(() => setCopied(false), 2000);
|
|
22
21
|
}
|
|
23
22
|
};
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
ExclamationMarkTriangleIcon,
|
|
21
21
|
} from "./icons";
|
|
22
22
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
|
23
|
-
import { COPILOTKIT_VERSION } from "@copilotkit/shared";
|
|
23
|
+
import { COPILOTKIT_VERSION, copyToClipboard } from "@copilotkit/shared";
|
|
24
24
|
import { SmallSpinnerIcon } from "../chat/Icons";
|
|
25
25
|
import { CopilotKitHelpModal } from "../help-modal";
|
|
26
26
|
|
|
@@ -84,13 +84,16 @@ export function CopilotDevConsole() {
|
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
useEffect(() => {
|
|
87
|
+
if (!showDevConsole) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
87
90
|
if (dontRunTwiceInDevMode.current === true) {
|
|
88
91
|
return;
|
|
89
92
|
}
|
|
90
93
|
dontRunTwiceInDevMode.current = true;
|
|
91
94
|
|
|
92
95
|
checkForUpdates();
|
|
93
|
-
}, []);
|
|
96
|
+
}, [showDevConsole]);
|
|
94
97
|
|
|
95
98
|
if (!showDevConsole) {
|
|
96
99
|
return null;
|
|
@@ -170,11 +173,12 @@ function VersionInfo({
|
|
|
170
173
|
`&& npm install @copilotkit/runtime@${latestVersion}`,
|
|
171
174
|
].join(" ");
|
|
172
175
|
|
|
173
|
-
const handleCopyClick = () => {
|
|
174
|
-
|
|
176
|
+
const handleCopyClick = async () => {
|
|
177
|
+
const success = await copyToClipboard(installCommand.trim());
|
|
178
|
+
if (success) {
|
|
175
179
|
setCopyStatus("Command copied to clipboard!");
|
|
176
180
|
setTimeout(() => setCopyStatus(""), 1000);
|
|
177
|
-
}
|
|
181
|
+
}
|
|
178
182
|
};
|
|
179
183
|
|
|
180
184
|
if (versionStatus === "update-available" || versionStatus === "outdated") {
|
package/src/css/colors.css
CHANGED
|
@@ -48,7 +48,7 @@ html.dark,
|
|
|
48
48
|
body.dark,
|
|
49
49
|
[data-theme="dark"],
|
|
50
50
|
html[style*="color-scheme: dark"],
|
|
51
|
-
body[style*="color-scheme: dark"]
|
|
51
|
+
body[style*="color-scheme: dark"] {
|
|
52
52
|
/* Main brand/action color - used for buttons, interactive elements */
|
|
53
53
|
--copilot-kit-primary-color: rgb(255, 255, 255);
|
|
54
54
|
/* Color that contrasts with primary - used for text on primary elements */
|
package/src/css/console.css
CHANGED
|
@@ -83,22 +83,28 @@
|
|
|
83
83
|
color: var(--copilot-kit-dev-console-text);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
.dark,
|
|
87
|
-
html.dark,
|
|
88
|
-
body.dark,
|
|
89
|
-
[data-theme="dark"],
|
|
90
|
-
html[style*="color-scheme: dark"]
|
|
86
|
+
.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton,
|
|
87
|
+
html.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton,
|
|
88
|
+
body.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton,
|
|
89
|
+
[data-theme="dark"] .copilotKitDevConsole .copilotKitDebugMenuTriggerButton,
|
|
90
|
+
html[style*="color-scheme: dark"]
|
|
91
|
+
.copilotKitDevConsole
|
|
92
|
+
.copilotKitDebugMenuTriggerButton,
|
|
91
93
|
body[style*="color-scheme: dark"]
|
|
92
94
|
.copilotKitDevConsole
|
|
93
95
|
.copilotKitDebugMenuTriggerButton {
|
|
94
96
|
color: white;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
.dark,
|
|
98
|
-
html.dark,
|
|
99
|
-
body.dark,
|
|
100
|
-
[data-theme="dark"]
|
|
101
|
-
|
|
99
|
+
.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton:hover,
|
|
100
|
+
html.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton:hover,
|
|
101
|
+
body.dark .copilotKitDevConsole .copilotKitDebugMenuTriggerButton:hover,
|
|
102
|
+
[data-theme="dark"]
|
|
103
|
+
.copilotKitDevConsole
|
|
104
|
+
.copilotKitDebugMenuTriggerButton:hover,
|
|
105
|
+
html[style*="color-scheme: dark"]
|
|
106
|
+
.copilotKitDevConsole
|
|
107
|
+
.copilotKitDebugMenuTriggerButton:hover,
|
|
102
108
|
body[style*="color-scheme: dark"]
|
|
103
109
|
.copilotKitDevConsole
|
|
104
110
|
.copilotKitDebugMenuTriggerButton:hover {
|
package/src/css/input.css
CHANGED
|
@@ -145,11 +145,11 @@
|
|
|
145
145
|
margin: 0 !important;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
.dark,
|
|
149
|
-
html.dark,
|
|
150
|
-
body.dark,
|
|
151
|
-
[data-theme="dark"],
|
|
152
|
-
html[style*="color-scheme: dark"],
|
|
148
|
+
.dark .poweredBy,
|
|
149
|
+
html.dark .poweredBy,
|
|
150
|
+
body.dark .poweredBy,
|
|
151
|
+
[data-theme="dark"] .poweredBy,
|
|
152
|
+
html[style*="color-scheme: dark"] .poweredBy,
|
|
153
153
|
body[style*="color-scheme: dark"] .poweredBy {
|
|
154
154
|
color: rgb(69, 69, 69) !important;
|
|
155
155
|
}
|
package/src/css/markdown.css
CHANGED
|
@@ -47,14 +47,14 @@ a.copilotKitMarkdownElement {
|
|
|
47
47
|
text-decoration: underline;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
.copilotKitParagraph {
|
|
51
51
|
padding: 0px;
|
|
52
52
|
margin: 0px;
|
|
53
53
|
line-height: 1.75;
|
|
54
54
|
font-size: 1rem;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
.copilotKitParagraph:not(:last-child),
|
|
58
58
|
pre.copilotKitMarkdownElement:not(:last-child),
|
|
59
59
|
ol.copilotKitMarkdownElement:not(:last-child),
|
|
60
60
|
ul.copilotKitMarkdownElement:not(:last-child),
|
|
@@ -70,7 +70,7 @@ blockquote.copilotKitMarkdownElement {
|
|
|
70
70
|
padding-left: 10px;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
blockquote.copilotKitMarkdownElement
|
|
73
|
+
blockquote.copilotKitMarkdownElement .copilotKitParagraph {
|
|
74
74
|
padding: 0.7em 0;
|
|
75
75
|
}
|
|
76
76
|
|
package/src/css/messages.css
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the sendFunction handling in usePushToTalk.
|
|
5
|
+
*
|
|
6
|
+
* Issue #3011: sendFunction (wrapping sendMessage) returns Promise<void>,
|
|
7
|
+
* but the code did `const message = await sendFunction(transcription);`
|
|
8
|
+
* then `message.id` — causing a TypeError on undefined.
|
|
9
|
+
*
|
|
10
|
+
* Fix: Guard .id access with `if (message)` check, and update SendFunction
|
|
11
|
+
* type to accept `Promise<Message | void>`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("usePushToTalk sendFunction handling", () => {
|
|
15
|
+
it("should handle sendFunction returning void without crashing", async () => {
|
|
16
|
+
// Simulates what sendMessage actually returns: Promise<void>
|
|
17
|
+
const sendFunction = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
let startReadingFromMessageId: string | null = null;
|
|
19
|
+
|
|
20
|
+
const transcription = "Hello world";
|
|
21
|
+
const message = await sendFunction(transcription);
|
|
22
|
+
|
|
23
|
+
// Apply the same guard as the fix
|
|
24
|
+
if (message) {
|
|
25
|
+
startReadingFromMessageId = message.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Should not have set the message id (because message is void)
|
|
29
|
+
expect(startReadingFromMessageId).toBeNull();
|
|
30
|
+
expect(sendFunction).toHaveBeenCalledWith(transcription);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should use message.id when sendFunction returns a message", async () => {
|
|
34
|
+
const sendFunction = vi.fn().mockResolvedValue({
|
|
35
|
+
id: "msg-123",
|
|
36
|
+
content: "test",
|
|
37
|
+
role: "user",
|
|
38
|
+
});
|
|
39
|
+
let startReadingFromMessageId: string | null = null;
|
|
40
|
+
|
|
41
|
+
const message = await sendFunction("Hello");
|
|
42
|
+
|
|
43
|
+
if (message) {
|
|
44
|
+
startReadingFromMessageId = message.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(startReadingFromMessageId).toBe("msg-123");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -58,6 +58,7 @@ const startRecording = async (
|
|
|
58
58
|
|
|
59
59
|
const stopRecording = (
|
|
60
60
|
mediaRecorderRef: MutableRefObject<MediaRecorder | null>,
|
|
61
|
+
mediaStreamRef?: MutableRefObject<MediaStream | null>,
|
|
61
62
|
) => {
|
|
62
63
|
if (
|
|
63
64
|
mediaRecorderRef.current &&
|
|
@@ -65,15 +66,22 @@ const stopRecording = (
|
|
|
65
66
|
) {
|
|
66
67
|
mediaRecorderRef.current.stop();
|
|
67
68
|
}
|
|
69
|
+
// Release microphone tracks to free the device
|
|
70
|
+
if (mediaStreamRef?.current) {
|
|
71
|
+
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
|
72
|
+
mediaStreamRef.current = null;
|
|
73
|
+
}
|
|
68
74
|
};
|
|
69
75
|
|
|
70
76
|
const transcribeAudio = async (
|
|
71
77
|
recordedChunks: Blob[],
|
|
72
78
|
transcribeAudioUrl: string,
|
|
79
|
+
mediaType: string = "audio/mp4",
|
|
73
80
|
) => {
|
|
74
|
-
const
|
|
81
|
+
const extension = mediaType.split("/")[1] || "mp4";
|
|
82
|
+
const completeBlob = new Blob(recordedChunks, { type: mediaType });
|
|
75
83
|
const formData = new FormData();
|
|
76
|
-
formData.append("file", completeBlob,
|
|
84
|
+
formData.append("file", completeBlob, `recording.${extension}`);
|
|
77
85
|
|
|
78
86
|
const response = await fetch(transcribeAudioUrl, {
|
|
79
87
|
method: "POST",
|
|
@@ -112,14 +120,16 @@ const playAudioResponse = (
|
|
|
112
120
|
|
|
113
121
|
export type PushToTalkState = "idle" | "recording" | "transcribing";
|
|
114
122
|
|
|
115
|
-
export type SendFunction = (text: string) => Promise<Message>;
|
|
123
|
+
export type SendFunction = (text: string) => Promise<Message | void>;
|
|
116
124
|
|
|
117
125
|
export const usePushToTalk = ({
|
|
118
126
|
sendFunction,
|
|
119
127
|
inProgress,
|
|
128
|
+
mediaType = "audio/mp4",
|
|
120
129
|
}: {
|
|
121
130
|
sendFunction: SendFunction;
|
|
122
131
|
inProgress: boolean;
|
|
132
|
+
mediaType?: string;
|
|
123
133
|
}) => {
|
|
124
134
|
const [pushToTalkState, setPushToTalkState] =
|
|
125
135
|
useState<PushToTalkState>("idle");
|
|
@@ -146,22 +156,25 @@ export const usePushToTalk = ({
|
|
|
146
156
|
},
|
|
147
157
|
);
|
|
148
158
|
} else {
|
|
149
|
-
stopRecording(mediaRecorderRef);
|
|
159
|
+
stopRecording(mediaRecorderRef, mediaStreamRef);
|
|
150
160
|
if (pushToTalkState === "transcribing") {
|
|
151
161
|
transcribeAudio(
|
|
152
162
|
recordedChunks.current,
|
|
153
163
|
context.copilotApiConfig.transcribeAudioUrl!,
|
|
164
|
+
mediaType,
|
|
154
165
|
).then(async (transcription) => {
|
|
155
166
|
recordedChunks.current = [];
|
|
156
167
|
setPushToTalkState("idle");
|
|
157
168
|
const message = await sendFunction(transcription);
|
|
158
|
-
|
|
169
|
+
if (message) {
|
|
170
|
+
setStartReadingFromMessageId(message.id);
|
|
171
|
+
}
|
|
159
172
|
});
|
|
160
173
|
}
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
return () => {
|
|
164
|
-
stopRecording(mediaRecorderRef);
|
|
177
|
+
stopRecording(mediaRecorderRef, mediaStreamRef);
|
|
165
178
|
};
|
|
166
179
|
}, [pushToTalkState]);
|
|
167
180
|
|