Spaces:
Configuration error
Configuration error
johnliontw
commited on
Commit
·
610dd27
1
Parent(s):
350c9f7
move all project files to root for Hugging Face Space
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +2 -0
- .gcloudignore +5 -0
- .gitattributes +36 -0
- .gitignore +23 -0
- CONTRIBUTING.md +33 -0
- LICENSE +202 -0
- app.yaml +29 -0
- gemini-live-api-demo +0 -1
- index.html +19 -0
- package-lock.json +0 -0
- package.json +68 -0
- public/favicon.ico +0 -0
- public/index.html +49 -0
- public/robots.txt +3 -0
- src/App.scss +173 -0
- src/App.test.tsx +25 -0
- src/App.tsx +74 -0
- src/components/altair/Altair.tsx +107 -0
- src/components/audio-pulse/AudioPulse.tsx +64 -0
- src/components/audio-pulse/audio-pulse.scss +51 -0
- src/components/control-tray/ControlTray.tsx +221 -0
- src/components/control-tray/control-tray.scss +184 -0
- src/components/logger/Logger.tsx +273 -0
- src/components/logger/logger.scss +116 -0
- src/components/logger/mock-logs.ts +151 -0
- src/components/settings-dialog/ResponseModalitySelector.tsx +67 -0
- src/components/settings-dialog/SettingsDialog.tsx +138 -0
- src/components/settings-dialog/VoiceSelector.tsx +85 -0
- src/components/settings-dialog/settings-dialog.scss +175 -0
- src/components/side-panel/SidePanel.tsx +162 -0
- src/components/side-panel/react-select.scss +34 -0
- src/components/side-panel/side-panel.scss +244 -0
- src/contexts/LiveAPIContext.tsx +48 -0
- src/hooks/use-live-api.ts +131 -0
- src/hooks/use-media-stream-mux.ts +23 -0
- src/hooks/use-screen-capture.ts +72 -0
- src/hooks/use-webcam.ts +69 -0
- src/index.css +13 -0
- src/index.tsx +35 -0
- src/lib/audio-recorder.ts +111 -0
- src/lib/audio-streamer.ts +270 -0
- src/lib/audioworklet-registry.ts +43 -0
- src/lib/multimodal-live-client.ts +319 -0
- src/lib/store-logger.ts +65 -0
- src/lib/utils.ts +86 -0
- src/lib/worklets/audio-processing.ts +73 -0
- src/lib/worklets/vol-meter.ts +65 -0
- src/multimodal-live-types.ts +242 -0
- src/react-app-env.d.ts +17 -0
- src/reportWebVitals.ts +31 -0
.env
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
# create your own API KEY at https://aistudio.google.com/apikey
|
2 |
+
REACT_APP_GEMINI_API_KEY='AIzaSyCXUbSmBkh5739e5tB9CJqYEOMtmpz4vBs'
|
.gcloudignore
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Ignore everything except app.yaml and the build directory
|
2 |
+
*
|
3 |
+
!app.yaml
|
4 |
+
!build
|
5 |
+
!build/**
|
.gitattributes
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
readme/thumbnail.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
2 |
+
|
3 |
+
# dependencies
|
4 |
+
/node_modules
|
5 |
+
/.pnp
|
6 |
+
.pnp.js
|
7 |
+
|
8 |
+
# testing
|
9 |
+
/coverage
|
10 |
+
|
11 |
+
# production
|
12 |
+
/build
|
13 |
+
|
14 |
+
# misc
|
15 |
+
.DS_Store
|
16 |
+
.env.local
|
17 |
+
.env.development.local
|
18 |
+
.env.test.local
|
19 |
+
.env.production.local
|
20 |
+
|
21 |
+
npm-debug.log*
|
22 |
+
yarn-debug.log*
|
23 |
+
yarn-error.log*
|
CONTRIBUTING.md
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# How to contribute
|
2 |
+
|
3 |
+
We'd love to accept your patches and contributions to this project.
|
4 |
+
|
5 |
+
## Before you begin
|
6 |
+
|
7 |
+
### Sign our Contributor License Agreement
|
8 |
+
|
9 |
+
Contributions to this project must be accompanied by a
|
10 |
+
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
|
11 |
+
You (or your employer) retain the copyright to your contribution; this simply
|
12 |
+
gives us permission to use and redistribute your contributions as part of the
|
13 |
+
project.
|
14 |
+
|
15 |
+
If you or your current employer have already signed the Google CLA (even if it
|
16 |
+
was for a different project), you probably don't need to do it again.
|
17 |
+
|
18 |
+
Visit <https://cla.developers.google.com/> to see your current agreements or to
|
19 |
+
sign a new one.
|
20 |
+
|
21 |
+
### Review our community guidelines
|
22 |
+
|
23 |
+
This project follows
|
24 |
+
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
|
25 |
+
|
26 |
+
## Contribution process
|
27 |
+
|
28 |
+
### Code reviews
|
29 |
+
|
30 |
+
All submissions, including submissions by project members, require review. We
|
31 |
+
use GitHub pull requests for this purpose. Consult
|
32 |
+
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
|
33 |
+
information on using pull requests.
|
LICENSE
ADDED
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
Apache License
|
3 |
+
Version 2.0, January 2004
|
4 |
+
http://www.apache.org/licenses/
|
5 |
+
|
6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
7 |
+
|
8 |
+
1. Definitions.
|
9 |
+
|
10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
12 |
+
|
13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
14 |
+
the copyright owner that is granting the License.
|
15 |
+
|
16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
17 |
+
other entities that control, are controlled by, or are under common
|
18 |
+
control with that entity. For the purposes of this definition,
|
19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
20 |
+
direction or management of such entity, whether by contract or
|
21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
23 |
+
|
24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
25 |
+
exercising permissions granted by this License.
|
26 |
+
|
27 |
+
"Source" form shall mean the preferred form for making modifications,
|
28 |
+
including but not limited to software source code, documentation
|
29 |
+
source, and configuration files.
|
30 |
+
|
31 |
+
"Object" form shall mean any form resulting from mechanical
|
32 |
+
transformation or translation of a Source form, including but
|
33 |
+
not limited to compiled object code, generated documentation,
|
34 |
+
and conversions to other media types.
|
35 |
+
|
36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
37 |
+
Object form, made available under the License, as indicated by a
|
38 |
+
copyright notice that is included in or attached to the work
|
39 |
+
(an example is provided in the Appendix below).
|
40 |
+
|
41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
42 |
+
form, that is based on (or derived from) the Work and for which the
|
43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
45 |
+
of this License, Derivative Works shall not include works that remain
|
46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
47 |
+
the Work and Derivative Works thereof.
|
48 |
+
|
49 |
+
"Contribution" shall mean any work of authorship, including
|
50 |
+
the original version of the Work and any modifications or additions
|
51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
55 |
+
means any form of electronic, verbal, or written communication sent
|
56 |
+
to the Licensor or its representatives, including but not limited to
|
57 |
+
communication on electronic mailing lists, source code control systems,
|
58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
60 |
+
excluding communication that is conspicuously marked or otherwise
|
61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
62 |
+
|
63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
65 |
+
subsequently incorporated within the Work.
|
66 |
+
|
67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
72 |
+
Work and such Derivative Works in Source or Object form.
|
73 |
+
|
74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
77 |
+
(except as stated in this section) patent license to make, have made,
|
78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
79 |
+
where such license applies only to those patent claims licensable
|
80 |
+
by such Contributor that are necessarily infringed by their
|
81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
83 |
+
institute patent litigation against any entity (including a
|
84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
85 |
+
or a Contribution incorporated within the Work constitutes direct
|
86 |
+
or contributory patent infringement, then any patent licenses
|
87 |
+
granted to You under this License for that Work shall terminate
|
88 |
+
as of the date such litigation is filed.
|
89 |
+
|
90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
91 |
+
Work or Derivative Works thereof in any medium, with or without
|
92 |
+
modifications, and in Source or Object form, provided that You
|
93 |
+
meet the following conditions:
|
94 |
+
|
95 |
+
(a) You must give any other recipients of the Work or
|
96 |
+
Derivative Works a copy of this License; and
|
97 |
+
|
98 |
+
(b) You must cause any modified files to carry prominent notices
|
99 |
+
stating that You changed the files; and
|
100 |
+
|
101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
102 |
+
that You distribute, all copyright, patent, trademark, and
|
103 |
+
attribution notices from the Source form of the Work,
|
104 |
+
excluding those notices that do not pertain to any part of
|
105 |
+
the Derivative Works; and
|
106 |
+
|
107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
108 |
+
distribution, then any Derivative Works that You distribute must
|
109 |
+
include a readable copy of the attribution notices contained
|
110 |
+
within such NOTICE file, excluding those notices that do not
|
111 |
+
pertain to any part of the Derivative Works, in at least one
|
112 |
+
of the following places: within a NOTICE text file distributed
|
113 |
+
as part of the Derivative Works; within the Source form or
|
114 |
+
documentation, if provided along with the Derivative Works; or,
|
115 |
+
within a display generated by the Derivative Works, if and
|
116 |
+
wherever such third-party notices normally appear. The contents
|
117 |
+
of the NOTICE file are for informational purposes only and
|
118 |
+
do not modify the License. You may add Your own attribution
|
119 |
+
notices within Derivative Works that You distribute, alongside
|
120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
121 |
+
that such additional attribution notices cannot be construed
|
122 |
+
as modifying the License.
|
123 |
+
|
124 |
+
You may add Your own copyright statement to Your modifications and
|
125 |
+
may provide additional or different license terms and conditions
|
126 |
+
for use, reproduction, or distribution of Your modifications, or
|
127 |
+
for any such Derivative Works as a whole, provided Your use,
|
128 |
+
reproduction, and distribution of the Work otherwise complies with
|
129 |
+
the conditions stated in this License.
|
130 |
+
|
131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
133 |
+
by You to the Licensor shall be under the terms and conditions of
|
134 |
+
this License, without any additional terms or conditions.
|
135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
136 |
+
the terms of any separate license agreement you may have executed
|
137 |
+
with Licensor regarding such Contributions.
|
138 |
+
|
139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
141 |
+
except as required for reasonable and customary use in describing the
|
142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
143 |
+
|
144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
145 |
+
agreed to in writing, Licensor provides the Work (and each
|
146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
148 |
+
implied, including, without limitation, any warranties or conditions
|
149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
151 |
+
appropriateness of using or redistributing the Work and assume any
|
152 |
+
risks associated with Your exercise of permissions under this License.
|
153 |
+
|
154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
155 |
+
whether in tort (including negligence), contract, or otherwise,
|
156 |
+
unless required by applicable law (such as deliberate and grossly
|
157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
158 |
+
liable to You for damages, including any direct, indirect, special,
|
159 |
+
incidental, or consequential damages of any character arising as a
|
160 |
+
result of this License or out of the use or inability to use the
|
161 |
+
Work (including but not limited to damages for loss of goodwill,
|
162 |
+
work stoppage, computer failure or malfunction, or any and all
|
163 |
+
other commercial damages or losses), even if such Contributor
|
164 |
+
has been advised of the possibility of such damages.
|
165 |
+
|
166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
169 |
+
or other liability obligations and/or rights consistent with this
|
170 |
+
License. However, in accepting such obligations, You may act only
|
171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
172 |
+
of any other Contributor, and only if You agree to indemnify,
|
173 |
+
defend, and hold each Contributor harmless for any liability
|
174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
175 |
+
of your accepting any such warranty or additional liability.
|
176 |
+
|
177 |
+
END OF TERMS AND CONDITIONS
|
178 |
+
|
179 |
+
APPENDIX: How to apply the Apache License to your work.
|
180 |
+
|
181 |
+
To apply the Apache License to your work, attach the following
|
182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
183 |
+
replaced with your own identifying information. (Don't include
|
184 |
+
the brackets!) The text should be enclosed in the appropriate
|
185 |
+
comment syntax for the file format. We also recommend that a
|
186 |
+
file or class name and description of purpose be included on the
|
187 |
+
same "printed page" as the copyright notice for easier
|
188 |
+
identification within third-party archives.
|
189 |
+
|
190 |
+
Copyright [yyyy] [name of copyright owner]
|
191 |
+
|
192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
193 |
+
you may not use this file except in compliance with the License.
|
194 |
+
You may obtain a copy of the License at
|
195 |
+
|
196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
197 |
+
|
198 |
+
Unless required by applicable law or agreed to in writing, software
|
199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
201 |
+
See the License for the specific language governing permissions and
|
202 |
+
limitations under the License.
|
app.yaml
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2024 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
runtime: nodejs20
|
16 |
+
env: standard
|
17 |
+
|
18 |
+
handlers:
|
19 |
+
# serve static files
|
20 |
+
- url: /(.*\..+)$
|
21 |
+
static_files: build/\1
|
22 |
+
upload: build/(.*\..+)$
|
23 |
+
|
24 |
+
# Catch all handler to index.html
|
25 |
+
- url: /.*
|
26 |
+
static_files: build/index.html
|
27 |
+
secure: always
|
28 |
+
redirect_http_response_code: 301
|
29 |
+
upload: buid/index.html
|
gemini-live-api-demo
DELETED
@@ -1 +0,0 @@
|
|
1 |
-
Subproject commit bce815e56fcebe922517028157b106db8c7a2f5f
|
|
|
|
index.html
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html>
|
3 |
+
<head>
|
4 |
+
<meta charset="utf-8" />
|
5 |
+
<meta name="viewport" content="width=device-width" />
|
6 |
+
<title>My static Space</title>
|
7 |
+
<link rel="stylesheet" href="style.css" />
|
8 |
+
</head>
|
9 |
+
<body>
|
10 |
+
<div class="card">
|
11 |
+
<h1>Welcome to your static Space!</h1>
|
12 |
+
<p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
|
13 |
+
<p>
|
14 |
+
Also don't forget to check the
|
15 |
+
<a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
|
16 |
+
</p>
|
17 |
+
</div>
|
18 |
+
</body>
|
19 |
+
</html>
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "multimodal-live-api-web-console",
|
3 |
+
"version": "0.1.0",
|
4 |
+
"homepage": "https://aied-lab.github.io/gemini-live-api-demo",
|
5 |
+
"dependencies": {
|
6 |
+
"classnames": "^2.5.1",
|
7 |
+
"dotenv-flow": "^4.1.0",
|
8 |
+
"eventemitter3": "^5.0.1",
|
9 |
+
"lodash": "^4.17.21",
|
10 |
+
"react": "^18.3.1",
|
11 |
+
"react-dom": "^18.3.1",
|
12 |
+
"react-icons": "^5.3.0",
|
13 |
+
"react-scripts": "5.0.1",
|
14 |
+
"react-select": "^5.8.3",
|
15 |
+
"react-syntax-highlighter": "^15.6.1",
|
16 |
+
"sass": "^1.80.6",
|
17 |
+
"vega": "^5.30.0",
|
18 |
+
"vega-embed": "^6.29.0",
|
19 |
+
"vega-lite": "^5.22.0",
|
20 |
+
"web-vitals": "^2.1.4",
|
21 |
+
"zustand": "^5.0.1"
|
22 |
+
},
|
23 |
+
"scripts": {
|
24 |
+
"start-https": "HTTPS=true react-scripts start",
|
25 |
+
"start": "react-scripts start",
|
26 |
+
"build": "react-scripts build",
|
27 |
+
"test": "react-scripts test",
|
28 |
+
"eject": "react-scripts eject",
|
29 |
+
"predeploy": "npm run build",
|
30 |
+
"deploy": "gh-pages -d build"
|
31 |
+
},
|
32 |
+
"eslintConfig": {
|
33 |
+
"extends": [
|
34 |
+
"react-app",
|
35 |
+
"react-app/jest"
|
36 |
+
]
|
37 |
+
},
|
38 |
+
"browserslist": {
|
39 |
+
"production": [
|
40 |
+
">0.2%",
|
41 |
+
"not dead",
|
42 |
+
"not op_mini all"
|
43 |
+
],
|
44 |
+
"development": [
|
45 |
+
"last 1 chrome version",
|
46 |
+
"last 1 firefox version",
|
47 |
+
"last 1 safari version"
|
48 |
+
]
|
49 |
+
},
|
50 |
+
"devDependencies": {
|
51 |
+
"@google/generative-ai": "^0.21.0",
|
52 |
+
"@testing-library/jest-dom": "^5.17.0",
|
53 |
+
"@testing-library/react": "^13.4.0",
|
54 |
+
"@testing-library/user-event": "^13.5.0",
|
55 |
+
"@types/jest": "^27.5.2",
|
56 |
+
"@types/lodash": "^4.17.13",
|
57 |
+
"@types/node": "^16.18.119",
|
58 |
+
"@types/react": "^18.3.12",
|
59 |
+
"@types/react-dom": "^18.3.1",
|
60 |
+
"@types/react-syntax-highlighter": "^15.5.13",
|
61 |
+
"gh-pages": "^6.3.0",
|
62 |
+
"ts-node": "^10.9.2",
|
63 |
+
"typescript": "^5.6.3"
|
64 |
+
},
|
65 |
+
"overrides": {
|
66 |
+
"typescript": "^5.6.3"
|
67 |
+
}
|
68 |
+
}
|
public/favicon.ico
ADDED
|
public/index.html
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="utf-8" />
|
5 |
+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
7 |
+
<meta name="theme-color" content="#000000" />
|
8 |
+
<meta
|
9 |
+
name="description"
|
10 |
+
content="Web site created using create-react-app"
|
11 |
+
/>
|
12 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
13 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
14 |
+
<link
|
15 |
+
href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
16 |
+
rel="stylesheet"
|
17 |
+
/>
|
18 |
+
<link
|
19 |
+
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block"
|
20 |
+
rel="stylesheet"
|
21 |
+
/>
|
22 |
+
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
23 |
+
<!--
|
24 |
+
Notice the use of %PUBLIC_URL% in the tags above.
|
25 |
+
It will be replaced with the URL of the `public` folder during the build.
|
26 |
+
Only files inside the `public` folder can be referenced from the HTML.
|
27 |
+
|
28 |
+
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
29 |
+
work correctly both with client-side routing and a non-root public URL.
|
30 |
+
Learn how to configure a non-root public URL by running `npm run build`.
|
31 |
+
-->
|
32 |
+
<title>Multimodal Live - Console</title>
|
33 |
+
</head>
|
34 |
+
|
35 |
+
<body>
|
36 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
37 |
+
<div id="root"></div>
|
38 |
+
<!--
|
39 |
+
This HTML file is a template.
|
40 |
+
If you open it directly in the browser, you will see an empty page.
|
41 |
+
|
42 |
+
You can add webfonts, meta tags, or analytics to this file.
|
43 |
+
The build step will place the bundled scripts into the <body> tag.
|
44 |
+
|
45 |
+
To begin the development, run `npm start` or `yarn start`.
|
46 |
+
To create a production bundle, use `npm run build` or `yarn build`.
|
47 |
+
-->
|
48 |
+
</body>
|
49 |
+
</html>
|
public/robots.txt
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# https://www.robotstxt.org/robotstxt.html
|
2 |
+
User-agent: *
|
3 |
+
Disallow:
|
src/App.scss
ADDED
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
:root {
|
2 |
+
--text: white;
|
3 |
+
--gray-200: #b4b8bb;
|
4 |
+
--gray-300: #80868b;
|
5 |
+
--gray-500: #5f6368;
|
6 |
+
--gray-600: #444444;
|
7 |
+
--gray-700: #202020;
|
8 |
+
--gray-800: #171717;
|
9 |
+
--gray-900: #111111;
|
10 |
+
--gray-1000: #0a0a0a;
|
11 |
+
--border-stroke: #444444;
|
12 |
+
--accent-blue: rgb(161, 228, 242);
|
13 |
+
--accent-blue-active-bg: #001233;
|
14 |
+
--accent-blue-active: #98beff;
|
15 |
+
--accent-blue-headers: #448dff;
|
16 |
+
--accent-green: rgb(168, 218, 181);
|
17 |
+
|
18 |
+
--midnight-blue: rgb(0, 18, 51);
|
19 |
+
--blue-30: #99beff;
|
20 |
+
|
21 |
+
--accent-red: #ff4600;
|
22 |
+
|
23 |
+
--background: var(--gray-900);
|
24 |
+
--color: var(--text);
|
25 |
+
|
26 |
+
scrollbar-color: var(--gray-600) var(--gray-900);
|
27 |
+
scrollbar-width: thin;
|
28 |
+
|
29 |
+
--font-family: "Space Mono", monospace;
|
30 |
+
|
31 |
+
/* */
|
32 |
+
--Neutral-00: #000;
|
33 |
+
--Neutral-5: #181a1b;
|
34 |
+
--Neutral-10: #1c1f21;
|
35 |
+
--Neutral-15: #232729;
|
36 |
+
--Neutral-20: #2a2f31;
|
37 |
+
--Neutral-30: #404547;
|
38 |
+
--Neutral-50: #707577;
|
39 |
+
--Neutral-60: #888d8f;
|
40 |
+
--Neutral-80: #c3c6c7;
|
41 |
+
--Neutral-90: #e1e2e3;
|
42 |
+
|
43 |
+
--Green-500: #0d9c53;
|
44 |
+
--Green-700: #025022;
|
45 |
+
|
46 |
+
--Blue-400: #80c1ff;
|
47 |
+
--Blue-500: #1f94ff;
|
48 |
+
--Blue-800: #0f3557;
|
49 |
+
|
50 |
+
--Red-400: #ff9c7a;
|
51 |
+
--Red-500: #ff4600;
|
52 |
+
--Red-600: #e03c00;
|
53 |
+
--Red-700: #bd3000;
|
54 |
+
}
|
55 |
+
|
56 |
+
body {
|
57 |
+
font-family: "Space Mono", monospace;
|
58 |
+
background: var(--Neutral-30);
|
59 |
+
}
|
60 |
+
|
61 |
+
.material-symbols-outlined {
|
62 |
+
&.filled {
|
63 |
+
font-variation-settings:
|
64 |
+
"FILL" 1,
|
65 |
+
"wght" 400,
|
66 |
+
"GRAD" 0,
|
67 |
+
"opsz" 24;
|
68 |
+
}
|
69 |
+
}
|
70 |
+
|
71 |
+
.space-mono-regular {
|
72 |
+
font-family: "Space Mono", monospace;
|
73 |
+
font-weight: 400;
|
74 |
+
font-style: normal;
|
75 |
+
}
|
76 |
+
|
77 |
+
.space-mono-bold {
|
78 |
+
font-family: "Space Mono", monospace;
|
79 |
+
font-weight: 700;
|
80 |
+
font-style: normal;
|
81 |
+
}
|
82 |
+
|
83 |
+
.space-mono-regular-italic {
|
84 |
+
font-family: "Space Mono", monospace;
|
85 |
+
font-weight: 400;
|
86 |
+
font-style: italic;
|
87 |
+
}
|
88 |
+
|
89 |
+
.space-mono-bold-italic {
|
90 |
+
font-family: "Space Mono", monospace;
|
91 |
+
font-weight: 700;
|
92 |
+
font-style: italic;
|
93 |
+
}
|
94 |
+
|
95 |
+
.hidden {
|
96 |
+
display: none;
|
97 |
+
}
|
98 |
+
|
99 |
+
.flex {
|
100 |
+
display: flex;
|
101 |
+
}
|
102 |
+
|
103 |
+
.h-screen-full {
|
104 |
+
height: 100vh;
|
105 |
+
}
|
106 |
+
|
107 |
+
.w-screen-full {
|
108 |
+
width: 100vw;
|
109 |
+
}
|
110 |
+
|
111 |
+
.flex-col {
|
112 |
+
flex-direction: column;
|
113 |
+
}
|
114 |
+
|
115 |
+
@media (prefers-reduced-motion: no-preference) {
|
116 |
+
}
|
117 |
+
|
118 |
+
.streaming-console {
|
119 |
+
background: var(--Neutral-15);
|
120 |
+
color: var(--gray-300);
|
121 |
+
display: flex;
|
122 |
+
height: 100vh;
|
123 |
+
width: 100vw;
|
124 |
+
|
125 |
+
a,
|
126 |
+
a:visited,
|
127 |
+
a:active {
|
128 |
+
color: var(--gray-300);
|
129 |
+
}
|
130 |
+
|
131 |
+
.disabled {
|
132 |
+
pointer-events: none;
|
133 |
+
|
134 |
+
> * {
|
135 |
+
pointer-events: none;
|
136 |
+
}
|
137 |
+
}
|
138 |
+
|
139 |
+
main {
|
140 |
+
position: relative;
|
141 |
+
display: flex;
|
142 |
+
flex-direction: column;
|
143 |
+
align-items: center;
|
144 |
+
justify-content: center;
|
145 |
+
flex-grow: 1;
|
146 |
+
gap: 1rem;
|
147 |
+
max-width: 100%;
|
148 |
+
overflow: hidden;
|
149 |
+
}
|
150 |
+
|
151 |
+
.main-app-area {
|
152 |
+
display: flex;
|
153 |
+
flex: 1;
|
154 |
+
align-items: center;
|
155 |
+
justify-content: center;
|
156 |
+
}
|
157 |
+
|
158 |
+
.function-call {
|
159 |
+
position: absolute;
|
160 |
+
top: 0;
|
161 |
+
width: 100%;
|
162 |
+
height: 50%;
|
163 |
+
overflow-y: auto;
|
164 |
+
}
|
165 |
+
}
|
166 |
+
|
167 |
+
/* video player */
|
168 |
+
.stream {
|
169 |
+
flex-grow: 1;
|
170 |
+
max-width: 90%;
|
171 |
+
border-radius: 32px;
|
172 |
+
max-height: fit-content;
|
173 |
+
}
|
src/App.test.tsx
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import React from 'react';
|
18 |
+
import { render, screen } from '@testing-library/react';
|
19 |
+
import App from './App';
|
20 |
+
|
21 |
+
test('renders learn react link', () => {
|
22 |
+
render(<App />);
|
23 |
+
const linkElement = screen.getByText(/learn react/i);
|
24 |
+
expect(linkElement).toBeInTheDocument();
|
25 |
+
});
|
src/App.tsx
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { useRef, useState } from "react";
|
18 |
+
import "./App.scss";
|
19 |
+
import { LiveAPIProvider } from "./contexts/LiveAPIContext";
|
20 |
+
import SidePanel from "./components/side-panel/SidePanel";
|
21 |
+
import { Altair } from "./components/altair/Altair";
|
22 |
+
import ControlTray from "./components/control-tray/ControlTray";
|
23 |
+
import cn from "classnames";
|
24 |
+
|
25 |
+
const API_KEY = process.env.REACT_APP_GEMINI_API_KEY as string;
|
26 |
+
if (typeof API_KEY !== "string") {
|
27 |
+
throw new Error("set REACT_APP_GEMINI_API_KEY in .env");
|
28 |
+
}
|
29 |
+
|
30 |
+
const host = "generativelanguage.googleapis.com";
|
31 |
+
const uri = `wss://${host}/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent`;
|
32 |
+
|
33 |
+
function App() {
|
34 |
+
// this video reference is used for displaying the active stream, whether that is the webcam or screen capture
|
35 |
+
// feel free to style as you see fit
|
36 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
37 |
+
// either the screen capture, the video or null, if null we hide it
|
38 |
+
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
|
39 |
+
|
40 |
+
return (
|
41 |
+
<div className="App">
|
42 |
+
<LiveAPIProvider url={uri} apiKey={API_KEY}>
|
43 |
+
<div className="streaming-console">
|
44 |
+
<SidePanel />
|
45 |
+
<main>
|
46 |
+
<div className="main-app-area">
|
47 |
+
{/* APP goes here */}
|
48 |
+
<Altair />
|
49 |
+
<video
|
50 |
+
className={cn("stream", {
|
51 |
+
hidden: !videoRef.current || !videoStream,
|
52 |
+
})}
|
53 |
+
ref={videoRef}
|
54 |
+
autoPlay
|
55 |
+
playsInline
|
56 |
+
/>
|
57 |
+
</div>
|
58 |
+
|
59 |
+
<ControlTray
|
60 |
+
videoRef={videoRef}
|
61 |
+
supportsVideo={true}
|
62 |
+
onVideoStreamChange={setVideoStream}
|
63 |
+
enableEditingSettings={true}
|
64 |
+
>
|
65 |
+
{/* put your own buttons here */}
|
66 |
+
</ControlTray>
|
67 |
+
</main>
|
68 |
+
</div>
|
69 |
+
</LiveAPIProvider>
|
70 |
+
</div>
|
71 |
+
);
|
72 |
+
}
|
73 |
+
|
74 |
+
export default App;
|
src/components/altair/Altair.tsx
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
import { type FunctionDeclaration, SchemaType } from "@google/generative-ai";
|
17 |
+
import { useEffect, useRef, useState, memo } from "react";
|
18 |
+
import vegaEmbed from "vega-embed";
|
19 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
20 |
+
import { ToolCall } from "../../multimodal-live-types";
|
21 |
+
|
22 |
+
const declaration: FunctionDeclaration = {
|
23 |
+
name: "render_altair",
|
24 |
+
description: "Displays an altair graph in json format.",
|
25 |
+
parameters: {
|
26 |
+
type: SchemaType.OBJECT,
|
27 |
+
properties: {
|
28 |
+
json_graph: {
|
29 |
+
type: SchemaType.STRING,
|
30 |
+
description:
|
31 |
+
"JSON STRING representation of the graph to render. Must be a string, not a json object",
|
32 |
+
},
|
33 |
+
},
|
34 |
+
required: ["json_graph"],
|
35 |
+
},
|
36 |
+
};
|
37 |
+
|
38 |
+
function AltairComponent() {
|
39 |
+
const [jsonString, setJSONString] = useState<string>("");
|
40 |
+
const { client, setConfig } = useLiveAPIContext();
|
41 |
+
|
42 |
+
useEffect(() => {
|
43 |
+
setConfig({
|
44 |
+
model: "models/gemini-2.0-flash-exp",
|
45 |
+
generationConfig: {
|
46 |
+
responseModalities: "audio",
|
47 |
+
speechConfig: {
|
48 |
+
voiceConfig: { prebuiltVoiceConfig: { voiceName: "Aoede" } },
|
49 |
+
},
|
50 |
+
},
|
51 |
+
systemInstruction: {
|
52 |
+
parts: [
|
53 |
+
{
|
54 |
+
text: 'You are my helpful assistant. Any time I ask you for a graph call the "render_altair" function I have provided you. Dont ask for additional information just make your best judgement.',
|
55 |
+
},
|
56 |
+
],
|
57 |
+
},
|
58 |
+
tools: [
|
59 |
+
// there is a free-tier quota for search
|
60 |
+
{ googleSearch: {} },
|
61 |
+
{ functionDeclarations: [declaration] },
|
62 |
+
],
|
63 |
+
});
|
64 |
+
}, [setConfig]);
|
65 |
+
|
66 |
+
useEffect(() => {
|
67 |
+
const onToolCall = (toolCall: ToolCall) => {
|
68 |
+
console.log(`got toolcall`, toolCall);
|
69 |
+
const fc = toolCall.functionCalls.find(
|
70 |
+
(fc) => fc.name === declaration.name,
|
71 |
+
);
|
72 |
+
if (fc) {
|
73 |
+
const str = (fc.args as any).json_graph;
|
74 |
+
setJSONString(str);
|
75 |
+
}
|
76 |
+
// send data for the response of your tool call
|
77 |
+
// in this case Im just saying it was successful
|
78 |
+
if (toolCall.functionCalls.length) {
|
79 |
+
setTimeout(
|
80 |
+
() =>
|
81 |
+
client.sendToolResponse({
|
82 |
+
functionResponses: toolCall.functionCalls.map((fc) => ({
|
83 |
+
response: { output: { success: true } },
|
84 |
+
id: fc.id,
|
85 |
+
})),
|
86 |
+
}),
|
87 |
+
200,
|
88 |
+
);
|
89 |
+
}
|
90 |
+
};
|
91 |
+
client.on("toolcall", onToolCall);
|
92 |
+
return () => {
|
93 |
+
client.off("toolcall", onToolCall);
|
94 |
+
};
|
95 |
+
}, [client]);
|
96 |
+
|
97 |
+
const embedRef = useRef<HTMLDivElement>(null);
|
98 |
+
|
99 |
+
useEffect(() => {
|
100 |
+
if (embedRef.current && jsonString) {
|
101 |
+
vegaEmbed(embedRef.current, JSON.parse(jsonString));
|
102 |
+
}
|
103 |
+
}, [embedRef, jsonString]);
|
104 |
+
return <div className="vega-embed" ref={embedRef} />;
|
105 |
+
}
|
106 |
+
|
107 |
+
export const Altair = memo(AltairComponent);
|
src/components/audio-pulse/AudioPulse.tsx
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import "./audio-pulse.scss";
|
18 |
+
import React from "react";
|
19 |
+
import { useEffect, useRef } from "react";
|
20 |
+
import c from "classnames";
|
21 |
+
|
22 |
+
const lineCount = 3;
|
23 |
+
|
24 |
+
export type AudioPulseProps = {
|
25 |
+
active: boolean;
|
26 |
+
volume: number;
|
27 |
+
hover?: boolean;
|
28 |
+
};
|
29 |
+
|
30 |
+
export default function AudioPulse({ active, volume, hover }: AudioPulseProps) {
|
31 |
+
const lines = useRef<HTMLDivElement[]>([]);
|
32 |
+
|
33 |
+
useEffect(() => {
|
34 |
+
let timeout: number | null = null;
|
35 |
+
const update = () => {
|
36 |
+
lines.current.forEach(
|
37 |
+
(line, i) =>
|
38 |
+
(line.style.height = `${Math.min(
|
39 |
+
24,
|
40 |
+
4 + volume * (i === 1 ? 400 : 60),
|
41 |
+
)}px`),
|
42 |
+
);
|
43 |
+
timeout = window.setTimeout(update, 100);
|
44 |
+
};
|
45 |
+
|
46 |
+
update();
|
47 |
+
|
48 |
+
return () => clearTimeout((timeout as number)!);
|
49 |
+
}, [volume]);
|
50 |
+
|
51 |
+
return (
|
52 |
+
<div className={c("audioPulse", { active, hover })}>
|
53 |
+
{Array(lineCount)
|
54 |
+
.fill(null)
|
55 |
+
.map((_, i) => (
|
56 |
+
<div
|
57 |
+
key={i}
|
58 |
+
ref={(el) => (lines.current[i] = el!)}
|
59 |
+
style={{ animationDelay: `${i * 133}ms` }}
|
60 |
+
/>
|
61 |
+
))}
|
62 |
+
</div>
|
63 |
+
);
|
64 |
+
}
|
src/components/audio-pulse/audio-pulse.scss
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.audioPulse {
|
2 |
+
display: flex;
|
3 |
+
width: 24px;
|
4 |
+
justify-content: space-evenly;
|
5 |
+
align-items: center;
|
6 |
+
transition: all 0.5s;
|
7 |
+
|
8 |
+
& > div {
|
9 |
+
background-color: var(--Neutral-30);
|
10 |
+
border-radius: 1000px;
|
11 |
+
width: 4px;
|
12 |
+
min-height: 4px;
|
13 |
+
border-radius: 1000px;
|
14 |
+
transition: height 0.1s;
|
15 |
+
}
|
16 |
+
|
17 |
+
&.hover > div {
|
18 |
+
animation: hover 1.4s infinite alternate ease-in-out;
|
19 |
+
}
|
20 |
+
|
21 |
+
height: 4px;
|
22 |
+
transition: opacity 0.333s;
|
23 |
+
|
24 |
+
&.active {
|
25 |
+
opacity: 1;
|
26 |
+
|
27 |
+
& > div {
|
28 |
+
background-color: var(--Neutral-80);
|
29 |
+
}
|
30 |
+
}
|
31 |
+
}
|
32 |
+
|
33 |
+
@keyframes hover {
|
34 |
+
from {
|
35 |
+
transform: translateY(0);
|
36 |
+
}
|
37 |
+
|
38 |
+
to {
|
39 |
+
transform: translateY(-3.5px);
|
40 |
+
}
|
41 |
+
}
|
42 |
+
|
43 |
+
@keyframes pulse {
|
44 |
+
from {
|
45 |
+
scale: 1 1;
|
46 |
+
}
|
47 |
+
|
48 |
+
to {
|
49 |
+
scale: 1.2 1.2;
|
50 |
+
}
|
51 |
+
}
|
src/components/control-tray/ControlTray.tsx
ADDED
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import cn from "classnames";
|
18 |
+
|
19 |
+
import { memo, ReactNode, RefObject, useEffect, useRef, useState } from "react";
|
20 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
21 |
+
import { UseMediaStreamResult } from "../../hooks/use-media-stream-mux";
|
22 |
+
import { useScreenCapture } from "../../hooks/use-screen-capture";
|
23 |
+
import { useWebcam } from "../../hooks/use-webcam";
|
24 |
+
import { AudioRecorder } from "../../lib/audio-recorder";
|
25 |
+
import AudioPulse from "../audio-pulse/AudioPulse";
|
26 |
+
import "./control-tray.scss";
|
27 |
+
import SettingsDialog from "../settings-dialog/SettingsDialog";
|
28 |
+
|
29 |
+
export type ControlTrayProps = {
|
30 |
+
videoRef: RefObject<HTMLVideoElement>;
|
31 |
+
children?: ReactNode;
|
32 |
+
supportsVideo: boolean;
|
33 |
+
onVideoStreamChange?: (stream: MediaStream | null) => void;
|
34 |
+
enableEditingSettings?: boolean;
|
35 |
+
};
|
36 |
+
|
37 |
+
type MediaStreamButtonProps = {
|
38 |
+
isStreaming: boolean;
|
39 |
+
onIcon: string;
|
40 |
+
offIcon: string;
|
41 |
+
start: () => Promise<any>;
|
42 |
+
stop: () => any;
|
43 |
+
};
|
44 |
+
|
45 |
+
/**
|
46 |
+
* button used for triggering webcam or screen-capture
|
47 |
+
*/
|
48 |
+
const MediaStreamButton = memo(
|
49 |
+
({ isStreaming, onIcon, offIcon, start, stop }: MediaStreamButtonProps) =>
|
50 |
+
isStreaming ? (
|
51 |
+
<button className="action-button" onClick={stop}>
|
52 |
+
<span className="material-symbols-outlined">{onIcon}</span>
|
53 |
+
</button>
|
54 |
+
) : (
|
55 |
+
<button className="action-button" onClick={start}>
|
56 |
+
<span className="material-symbols-outlined">{offIcon}</span>
|
57 |
+
</button>
|
58 |
+
)
|
59 |
+
);
|
60 |
+
|
61 |
+
function ControlTray({
|
62 |
+
videoRef,
|
63 |
+
children,
|
64 |
+
onVideoStreamChange = () => {},
|
65 |
+
supportsVideo,
|
66 |
+
enableEditingSettings,
|
67 |
+
}: ControlTrayProps) {
|
68 |
+
const videoStreams = [useWebcam(), useScreenCapture()];
|
69 |
+
const [activeVideoStream, setActiveVideoStream] =
|
70 |
+
useState<MediaStream | null>(null);
|
71 |
+
const [webcam, screenCapture] = videoStreams;
|
72 |
+
const [inVolume, setInVolume] = useState(0);
|
73 |
+
const [audioRecorder] = useState(() => new AudioRecorder());
|
74 |
+
const [muted, setMuted] = useState(false);
|
75 |
+
const renderCanvasRef = useRef<HTMLCanvasElement>(null);
|
76 |
+
const connectButtonRef = useRef<HTMLButtonElement>(null);
|
77 |
+
|
78 |
+
const { client, connected, connect, disconnect, volume } =
|
79 |
+
useLiveAPIContext();
|
80 |
+
|
81 |
+
useEffect(() => {
|
82 |
+
if (!connected && connectButtonRef.current) {
|
83 |
+
connectButtonRef.current.focus();
|
84 |
+
}
|
85 |
+
}, [connected]);
|
86 |
+
useEffect(() => {
|
87 |
+
document.documentElement.style.setProperty(
|
88 |
+
"--volume",
|
89 |
+
`${Math.max(5, Math.min(inVolume * 200, 8))}px`
|
90 |
+
);
|
91 |
+
}, [inVolume]);
|
92 |
+
|
93 |
+
useEffect(() => {
|
94 |
+
const onData = (base64: string) => {
|
95 |
+
client.sendRealtimeInput([
|
96 |
+
{
|
97 |
+
mimeType: "audio/pcm;rate=16000",
|
98 |
+
data: base64,
|
99 |
+
},
|
100 |
+
]);
|
101 |
+
};
|
102 |
+
if (connected && !muted && audioRecorder) {
|
103 |
+
audioRecorder.on("data", onData).on("volume", setInVolume).start();
|
104 |
+
} else {
|
105 |
+
audioRecorder.stop();
|
106 |
+
}
|
107 |
+
return () => {
|
108 |
+
audioRecorder.off("data", onData).off("volume", setInVolume);
|
109 |
+
};
|
110 |
+
}, [connected, client, muted, audioRecorder]);
|
111 |
+
|
112 |
+
useEffect(() => {
|
113 |
+
if (videoRef.current) {
|
114 |
+
videoRef.current.srcObject = activeVideoStream;
|
115 |
+
}
|
116 |
+
|
117 |
+
let timeoutId = -1;
|
118 |
+
|
119 |
+
function sendVideoFrame() {
|
120 |
+
const video = videoRef.current;
|
121 |
+
const canvas = renderCanvasRef.current;
|
122 |
+
|
123 |
+
if (!video || !canvas) {
|
124 |
+
return;
|
125 |
+
}
|
126 |
+
|
127 |
+
const ctx = canvas.getContext("2d")!;
|
128 |
+
canvas.width = video.videoWidth * 0.25;
|
129 |
+
canvas.height = video.videoHeight * 0.25;
|
130 |
+
if (canvas.width + canvas.height > 0) {
|
131 |
+
ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height);
|
132 |
+
const base64 = canvas.toDataURL("image/jpeg", 1.0);
|
133 |
+
const data = base64.slice(base64.indexOf(",") + 1, Infinity);
|
134 |
+
client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]);
|
135 |
+
}
|
136 |
+
if (connected) {
|
137 |
+
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 0.5);
|
138 |
+
}
|
139 |
+
}
|
140 |
+
if (connected && activeVideoStream !== null) {
|
141 |
+
requestAnimationFrame(sendVideoFrame);
|
142 |
+
}
|
143 |
+
return () => {
|
144 |
+
clearTimeout(timeoutId);
|
145 |
+
};
|
146 |
+
}, [connected, activeVideoStream, client, videoRef]);
|
147 |
+
|
148 |
+
//handler for swapping from one video-stream to the next
|
149 |
+
const changeStreams = (next?: UseMediaStreamResult) => async () => {
|
150 |
+
if (next) {
|
151 |
+
const mediaStream = await next.start();
|
152 |
+
setActiveVideoStream(mediaStream);
|
153 |
+
onVideoStreamChange(mediaStream);
|
154 |
+
} else {
|
155 |
+
setActiveVideoStream(null);
|
156 |
+
onVideoStreamChange(null);
|
157 |
+
}
|
158 |
+
|
159 |
+
videoStreams.filter((msr) => msr !== next).forEach((msr) => msr.stop());
|
160 |
+
};
|
161 |
+
|
162 |
+
return (
|
163 |
+
<section className="control-tray">
|
164 |
+
<canvas style={{ display: "none" }} ref={renderCanvasRef} />
|
165 |
+
<nav className={cn("actions-nav", { disabled: !connected })}>
|
166 |
+
<button
|
167 |
+
className={cn("action-button mic-button")}
|
168 |
+
onClick={() => setMuted(!muted)}
|
169 |
+
>
|
170 |
+
{!muted ? (
|
171 |
+
<span className="material-symbols-outlined filled">mic</span>
|
172 |
+
) : (
|
173 |
+
<span className="material-symbols-outlined filled">mic_off</span>
|
174 |
+
)}
|
175 |
+
</button>
|
176 |
+
|
177 |
+
<div className="action-button no-action outlined">
|
178 |
+
<AudioPulse volume={volume} active={connected} hover={false} />
|
179 |
+
</div>
|
180 |
+
|
181 |
+
{supportsVideo && (
|
182 |
+
<>
|
183 |
+
<MediaStreamButton
|
184 |
+
isStreaming={screenCapture.isStreaming}
|
185 |
+
start={changeStreams(screenCapture)}
|
186 |
+
stop={changeStreams()}
|
187 |
+
onIcon="cancel_presentation"
|
188 |
+
offIcon="present_to_all"
|
189 |
+
/>
|
190 |
+
<MediaStreamButton
|
191 |
+
isStreaming={webcam.isStreaming}
|
192 |
+
start={changeStreams(webcam)}
|
193 |
+
stop={changeStreams()}
|
194 |
+
onIcon="videocam_off"
|
195 |
+
offIcon="videocam"
|
196 |
+
/>
|
197 |
+
</>
|
198 |
+
)}
|
199 |
+
{children}
|
200 |
+
</nav>
|
201 |
+
|
202 |
+
<div className={cn("connection-container", { connected })}>
|
203 |
+
<div className="connection-button-container">
|
204 |
+
<button
|
205 |
+
ref={connectButtonRef}
|
206 |
+
className={cn("action-button connect-toggle", { connected })}
|
207 |
+
onClick={connected ? disconnect : connect}
|
208 |
+
>
|
209 |
+
<span className="material-symbols-outlined filled">
|
210 |
+
{connected ? "pause" : "play_arrow"}
|
211 |
+
</span>
|
212 |
+
</button>
|
213 |
+
</div>
|
214 |
+
<span className="text-indicator">Streaming</span>
|
215 |
+
</div>
|
216 |
+
{enableEditingSettings ? <SettingsDialog /> : ""}
|
217 |
+
</section>
|
218 |
+
);
|
219 |
+
}
|
220 |
+
|
221 |
+
export default memo(ControlTray);
|
src/components/control-tray/control-tray.scss
ADDED
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.action-button {
|
2 |
+
display: flex;
|
3 |
+
align-items: center;
|
4 |
+
justify-content: center;
|
5 |
+
background: var(--Neutral-20);
|
6 |
+
color: var(--Neutral-60);
|
7 |
+
font-size: 1.25rem;
|
8 |
+
line-height: 1.75rem;
|
9 |
+
text-transform: lowercase;
|
10 |
+
cursor: pointer;
|
11 |
+
animation: opacity-pulse 3s ease-in infinite;
|
12 |
+
transition: all 0.2s ease-in-out;
|
13 |
+
width: 48px;
|
14 |
+
height: 48px;
|
15 |
+
border-radius: 18px;
|
16 |
+
border: 1px solid rgba(0, 0, 0, 0);
|
17 |
+
user-select: none;
|
18 |
+
cursor: pointer;
|
19 |
+
|
20 |
+
&:focus {
|
21 |
+
border: 2px solid var(--Neutral-20);
|
22 |
+
outline: 2px solid var(--Neutral-80);
|
23 |
+
}
|
24 |
+
|
25 |
+
&.outlined {
|
26 |
+
background: var(--Neutral-2);
|
27 |
+
border: 1px solid var(--Neutral-20);
|
28 |
+
}
|
29 |
+
|
30 |
+
.no-action {
|
31 |
+
pointer-events: none;
|
32 |
+
}
|
33 |
+
|
34 |
+
&:hover {
|
35 |
+
background: rgba(0, 0, 0, 0);
|
36 |
+
border: 1px solid var(--Neutral-20);
|
37 |
+
}
|
38 |
+
|
39 |
+
&.connected {
|
40 |
+
background: var(--Blue-800);
|
41 |
+
color: var(--Blue-500);
|
42 |
+
|
43 |
+
&:hover {
|
44 |
+
border: 1px solid var(--Blue-500);
|
45 |
+
}
|
46 |
+
}
|
47 |
+
}
|
48 |
+
|
49 |
+
@property --volume {
|
50 |
+
syntax: "length";
|
51 |
+
inherit: false;
|
52 |
+
initial-value: 0px;
|
53 |
+
}
|
54 |
+
|
55 |
+
.disabled .mic-button,
|
56 |
+
.mic-button.disabled {
|
57 |
+
&:before {
|
58 |
+
background: rgba(0, 0, 0, 0);
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
.mic-button {
|
63 |
+
position: relative;
|
64 |
+
background-color: var(--accent-red);
|
65 |
+
z-index: 1;
|
66 |
+
color: black;
|
67 |
+
transition: all 0.2s ease-in;
|
68 |
+
|
69 |
+
&:focus {
|
70 |
+
border: 2px solid var(--Neutral-20);
|
71 |
+
outline: 2px solid var(--Red-500);
|
72 |
+
}
|
73 |
+
|
74 |
+
&:hover {
|
75 |
+
background-color: var(--Red-400);
|
76 |
+
}
|
77 |
+
|
78 |
+
&:before {
|
79 |
+
position: absolute;
|
80 |
+
z-index: -1;
|
81 |
+
top: calc(var(--volume) * -1);
|
82 |
+
left: calc(var(--volume) * -1);
|
83 |
+
display: block;
|
84 |
+
content: "";
|
85 |
+
opacity: 0.35;
|
86 |
+
background-color: var(--Red-500);
|
87 |
+
width: calc(100% + var(--volume) * 2);
|
88 |
+
height: calc(100% + var(--volume) * 2);
|
89 |
+
border-radius: 24px;
|
90 |
+
transition: all 0.02s ease-in-out;
|
91 |
+
}
|
92 |
+
}
|
93 |
+
|
94 |
+
.connect-toggle {
|
95 |
+
&:focus {
|
96 |
+
border: 2px solid var(--Neutral-20);
|
97 |
+
outline: 2px solid var(--Neutral-80);
|
98 |
+
}
|
99 |
+
|
100 |
+
&:not(.connected) {
|
101 |
+
background-color: var(--Blue-500);
|
102 |
+
color: var(--Neutral-5);
|
103 |
+
}
|
104 |
+
}
|
105 |
+
|
106 |
+
.control-tray {
|
107 |
+
position: absolute;
|
108 |
+
bottom: 0;
|
109 |
+
left: 50%;
|
110 |
+
transform: translate(-50%, 0);
|
111 |
+
display: inline-flex;
|
112 |
+
justify-content: center;
|
113 |
+
align-items: flex-start;
|
114 |
+
gap: 8px;
|
115 |
+
padding-bottom: 18px;
|
116 |
+
|
117 |
+
.disabled .action-button,
|
118 |
+
.action-button.disabled {
|
119 |
+
background: rgba(0, 0, 0, 0);
|
120 |
+
border: 1px solid var(--Neutral-30, #404547);
|
121 |
+
color: var(--Neutral-30);
|
122 |
+
}
|
123 |
+
|
124 |
+
.connection-container {
|
125 |
+
display: flex;
|
126 |
+
flex-direction: column;
|
127 |
+
justify-content: center;
|
128 |
+
align-items: center;
|
129 |
+
gap: 4px;
|
130 |
+
|
131 |
+
.connection-button-container {
|
132 |
+
border-radius: 27px;
|
133 |
+
border: 1px solid var(--Neutral-30);
|
134 |
+
background: var(--Neutral-5);
|
135 |
+
padding: 10px;
|
136 |
+
}
|
137 |
+
|
138 |
+
.text-indicator {
|
139 |
+
font-size: 11px;
|
140 |
+
color: var(--Blue-500);
|
141 |
+
user-select: none;
|
142 |
+
}
|
143 |
+
|
144 |
+
&:not(.connected) {
|
145 |
+
.text-indicator {
|
146 |
+
opacity: 0;
|
147 |
+
}
|
148 |
+
}
|
149 |
+
}
|
150 |
+
}
|
151 |
+
|
152 |
+
.actions-nav {
|
153 |
+
background: var(--Neutral-5);
|
154 |
+
border: 1px solid var(--Neutral-30);
|
155 |
+
border-radius: 27px;
|
156 |
+
display: inline-flex;
|
157 |
+
gap: 12px;
|
158 |
+
align-items: center;
|
159 |
+
overflow: clip;
|
160 |
+
padding: 10px;
|
161 |
+
|
162 |
+
transition: all 0.6s ease-in;
|
163 |
+
|
164 |
+
&>* {
|
165 |
+
display: flex;
|
166 |
+
align-items: center;
|
167 |
+
flex-direction: column;
|
168 |
+
gap: 1rem;
|
169 |
+
}
|
170 |
+
}
|
171 |
+
|
172 |
+
@keyframes opacity-pulse {
|
173 |
+
0% {
|
174 |
+
opacity: 0.9;
|
175 |
+
}
|
176 |
+
|
177 |
+
50% {
|
178 |
+
opacity: 1;
|
179 |
+
}
|
180 |
+
|
181 |
+
100% {
|
182 |
+
opacity: 0.9;
|
183 |
+
}
|
184 |
+
}
|
src/components/logger/Logger.tsx
ADDED
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import "./logger.scss";
|
18 |
+
|
19 |
+
import { Part } from "@google/generative-ai";
|
20 |
+
import cn from "classnames";
|
21 |
+
import { ReactNode } from "react";
|
22 |
+
import { useLoggerStore } from "../../lib/store-logger";
|
23 |
+
import SyntaxHighlighter from "react-syntax-highlighter";
|
24 |
+
import { vs2015 as dark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
25 |
+
import {
|
26 |
+
ClientContentMessage,
|
27 |
+
isClientContentMessage,
|
28 |
+
isInterrupted,
|
29 |
+
isModelTurn,
|
30 |
+
isServerContentMessage,
|
31 |
+
isToolCallCancellationMessage,
|
32 |
+
isToolCallMessage,
|
33 |
+
isToolResponseMessage,
|
34 |
+
isTurnComplete,
|
35 |
+
ModelTurn,
|
36 |
+
ServerContentMessage,
|
37 |
+
StreamingLog,
|
38 |
+
ToolCallCancellationMessage,
|
39 |
+
ToolCallMessage,
|
40 |
+
ToolResponseMessage,
|
41 |
+
} from "../../multimodal-live-types";
|
42 |
+
|
43 |
+
const formatTime = (d: Date) => d.toLocaleTimeString().slice(0, -3);
|
44 |
+
|
45 |
+
const LogEntry = ({
|
46 |
+
log,
|
47 |
+
MessageComponent,
|
48 |
+
}: {
|
49 |
+
log: StreamingLog;
|
50 |
+
MessageComponent: ({
|
51 |
+
message,
|
52 |
+
}: {
|
53 |
+
message: StreamingLog["message"];
|
54 |
+
}) => ReactNode;
|
55 |
+
}): JSX.Element => (
|
56 |
+
<li
|
57 |
+
className={cn(
|
58 |
+
`plain-log`,
|
59 |
+
`source-${log.type.slice(0, log.type.indexOf("."))}`,
|
60 |
+
{
|
61 |
+
receive: log.type.includes("receive"),
|
62 |
+
send: log.type.includes("send"),
|
63 |
+
},
|
64 |
+
)}
|
65 |
+
>
|
66 |
+
<span className="timestamp">{formatTime(log.date)}</span>
|
67 |
+
<span className="source">{log.type}</span>
|
68 |
+
<span className="message">
|
69 |
+
<MessageComponent message={log.message} />
|
70 |
+
</span>
|
71 |
+
{log.count && <span className="count">{log.count}</span>}
|
72 |
+
</li>
|
73 |
+
);
|
74 |
+
|
75 |
+
const PlainTextMessage = ({
|
76 |
+
message,
|
77 |
+
}: {
|
78 |
+
message: StreamingLog["message"];
|
79 |
+
}) => <span>{message as string}</span>;
|
80 |
+
|
81 |
+
type Message = { message: StreamingLog["message"] };
|
82 |
+
|
83 |
+
const AnyMessage = ({ message }: Message) => (
|
84 |
+
<pre>{JSON.stringify(message, null, " ")}</pre>
|
85 |
+
);
|
86 |
+
|
87 |
+
function tryParseCodeExecutionResult(output: string) {
|
88 |
+
try {
|
89 |
+
const json = JSON.parse(output);
|
90 |
+
return JSON.stringify(json, null, " ");
|
91 |
+
} catch (e) {
|
92 |
+
return output;
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
const RenderPart = ({ part }: { part: Part }) =>
|
97 |
+
part.text && part.text.length ? (
|
98 |
+
<p className="part part-text">{part.text}</p>
|
99 |
+
) : part.executableCode ? (
|
100 |
+
<div className="part part-executableCode">
|
101 |
+
<h5>executableCode: {part.executableCode.language}</h5>
|
102 |
+
<SyntaxHighlighter
|
103 |
+
language={part.executableCode.language.toLowerCase()}
|
104 |
+
style={dark}
|
105 |
+
>
|
106 |
+
{part.executableCode.code}
|
107 |
+
</SyntaxHighlighter>
|
108 |
+
</div>
|
109 |
+
) : part.codeExecutionResult ? (
|
110 |
+
<div className="part part-codeExecutionResult">
|
111 |
+
<h5>codeExecutionResult: {part.codeExecutionResult.outcome}</h5>
|
112 |
+
<SyntaxHighlighter language="json" style={dark}>
|
113 |
+
{tryParseCodeExecutionResult(part.codeExecutionResult.output)}
|
114 |
+
</SyntaxHighlighter>
|
115 |
+
</div>
|
116 |
+
) : (
|
117 |
+
<div className="part part-inlinedata">
|
118 |
+
<h5>Inline Data: {part.inlineData?.mimeType}</h5>
|
119 |
+
</div>
|
120 |
+
);
|
121 |
+
|
122 |
+
const ClientContentLog = ({ message }: Message) => {
|
123 |
+
const { turns, turnComplete } = (message as ClientContentMessage)
|
124 |
+
.clientContent;
|
125 |
+
return (
|
126 |
+
<div className="rich-log client-content user">
|
127 |
+
<h4 className="roler-user">User</h4>
|
128 |
+
{turns.map((turn, i) => (
|
129 |
+
<div key={`message-turn-${i}`}>
|
130 |
+
{turn.parts
|
131 |
+
.filter((part) => !(part.text && part.text === "\n"))
|
132 |
+
.map((part, j) => (
|
133 |
+
<RenderPart part={part} key={`message-turh-${i}-part-${j}`} />
|
134 |
+
))}
|
135 |
+
</div>
|
136 |
+
))}
|
137 |
+
{!turnComplete ? <span>turnComplete: false</span> : ""}
|
138 |
+
</div>
|
139 |
+
);
|
140 |
+
};
|
141 |
+
|
142 |
+
const ToolCallLog = ({ message }: Message) => {
|
143 |
+
const { toolCall } = message as ToolCallMessage;
|
144 |
+
return (
|
145 |
+
<div className={cn("rich-log tool-call")}>
|
146 |
+
{toolCall.functionCalls.map((fc, i) => (
|
147 |
+
<div key={fc.id} className="part part-functioncall">
|
148 |
+
<h5>Function call: {fc.name}</h5>
|
149 |
+
<SyntaxHighlighter language="json" style={dark}>
|
150 |
+
{JSON.stringify(fc, null, " ")}
|
151 |
+
</SyntaxHighlighter>
|
152 |
+
</div>
|
153 |
+
))}
|
154 |
+
</div>
|
155 |
+
);
|
156 |
+
};
|
157 |
+
|
158 |
+
const ToolCallCancellationLog = ({ message }: Message): JSX.Element => (
|
159 |
+
<div className={cn("rich-log tool-call-cancellation")}>
|
160 |
+
<span>
|
161 |
+
{" "}
|
162 |
+
ids:{" "}
|
163 |
+
{(message as ToolCallCancellationMessage).toolCallCancellation.ids.map(
|
164 |
+
(id) => (
|
165 |
+
<span className="inline-code" key={`cancel-${id}`}>
|
166 |
+
"{id}"
|
167 |
+
</span>
|
168 |
+
),
|
169 |
+
)}
|
170 |
+
</span>
|
171 |
+
</div>
|
172 |
+
);
|
173 |
+
|
174 |
+
const ToolResponseLog = ({ message }: Message): JSX.Element => (
|
175 |
+
<div className={cn("rich-log tool-response")}>
|
176 |
+
{(message as ToolResponseMessage).toolResponse.functionResponses.map(
|
177 |
+
(fc) => (
|
178 |
+
<div key={`tool-response-${fc.id}`} className="part">
|
179 |
+
<h5>Function Response: {fc.id}</h5>
|
180 |
+
<SyntaxHighlighter language="json" style={dark}>
|
181 |
+
{JSON.stringify(fc.response, null, " ")}
|
182 |
+
</SyntaxHighlighter>
|
183 |
+
</div>
|
184 |
+
),
|
185 |
+
)}
|
186 |
+
</div>
|
187 |
+
);
|
188 |
+
|
189 |
+
const ModelTurnLog = ({ message }: Message): JSX.Element => {
|
190 |
+
const serverContent = (message as ServerContentMessage).serverContent;
|
191 |
+
const { modelTurn } = serverContent as ModelTurn;
|
192 |
+
const { parts } = modelTurn;
|
193 |
+
|
194 |
+
return (
|
195 |
+
<div className="rich-log model-turn model">
|
196 |
+
<h4 className="role-model">Model</h4>
|
197 |
+
{parts
|
198 |
+
.filter((part) => !(part.text && part.text === "\n"))
|
199 |
+
.map((part, j) => (
|
200 |
+
<RenderPart part={part} key={`model-turn-part-${j}`} />
|
201 |
+
))}
|
202 |
+
</div>
|
203 |
+
);
|
204 |
+
};
|
205 |
+
|
206 |
+
const CustomPlainTextLog = (msg: string) => () => (
|
207 |
+
<PlainTextMessage message={msg} />
|
208 |
+
);
|
209 |
+
|
210 |
+
export type LoggerFilterType = "conversations" | "tools" | "none";
|
211 |
+
|
212 |
+
export type LoggerProps = {
|
213 |
+
filter: LoggerFilterType;
|
214 |
+
};
|
215 |
+
|
216 |
+
const filters: Record<LoggerFilterType, (log: StreamingLog) => boolean> = {
|
217 |
+
tools: (log: StreamingLog) =>
|
218 |
+
isToolCallMessage(log.message) ||
|
219 |
+
isToolResponseMessage(log.message) ||
|
220 |
+
isToolCallCancellationMessage(log.message),
|
221 |
+
conversations: (log: StreamingLog) =>
|
222 |
+
isClientContentMessage(log.message) || isServerContentMessage(log.message),
|
223 |
+
none: () => true,
|
224 |
+
};
|
225 |
+
|
226 |
+
const component = (log: StreamingLog) => {
|
227 |
+
if (typeof log.message === "string") {
|
228 |
+
return PlainTextMessage;
|
229 |
+
}
|
230 |
+
if (isClientContentMessage(log.message)) {
|
231 |
+
return ClientContentLog;
|
232 |
+
}
|
233 |
+
if (isToolCallMessage(log.message)) {
|
234 |
+
return ToolCallLog;
|
235 |
+
}
|
236 |
+
if (isToolCallCancellationMessage(log.message)) {
|
237 |
+
return ToolCallCancellationLog;
|
238 |
+
}
|
239 |
+
if (isToolResponseMessage(log.message)) {
|
240 |
+
return ToolResponseLog;
|
241 |
+
}
|
242 |
+
if (isServerContentMessage(log.message)) {
|
243 |
+
const { serverContent } = log.message;
|
244 |
+
if (isInterrupted(serverContent)) {
|
245 |
+
return CustomPlainTextLog("interrupted");
|
246 |
+
}
|
247 |
+
if (isTurnComplete(serverContent)) {
|
248 |
+
return CustomPlainTextLog("turnComplete");
|
249 |
+
}
|
250 |
+
if (isModelTurn(serverContent)) {
|
251 |
+
return ModelTurnLog;
|
252 |
+
}
|
253 |
+
}
|
254 |
+
return AnyMessage;
|
255 |
+
};
|
256 |
+
|
257 |
+
export default function Logger({ filter = "none" }: LoggerProps) {
|
258 |
+
const { logs } = useLoggerStore();
|
259 |
+
|
260 |
+
const filterFn = filters[filter];
|
261 |
+
|
262 |
+
return (
|
263 |
+
<div className="logger">
|
264 |
+
<ul className="logger-list">
|
265 |
+
{logs.filter(filterFn).map((log, key) => {
|
266 |
+
return (
|
267 |
+
<LogEntry MessageComponent={component(log)} log={log} key={key} />
|
268 |
+
);
|
269 |
+
})}
|
270 |
+
</ul>
|
271 |
+
</div>
|
272 |
+
);
|
273 |
+
}
|
src/components/logger/logger.scss
ADDED
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.logger {
|
2 |
+
color: var(--gray-300);
|
3 |
+
width: 100%;
|
4 |
+
max-width: 100%;
|
5 |
+
display: block;
|
6 |
+
|
7 |
+
.logger-list {
|
8 |
+
padding: 0 0px 0 25px;
|
9 |
+
overflow-x: hidden;
|
10 |
+
width: calc(100% - 45px);
|
11 |
+
}
|
12 |
+
|
13 |
+
.user h4 {
|
14 |
+
color: var(--Green-500);
|
15 |
+
}
|
16 |
+
|
17 |
+
.model h4 {
|
18 |
+
color: var(--Blue-500);
|
19 |
+
}
|
20 |
+
|
21 |
+
.rich-log {
|
22 |
+
display: flex;
|
23 |
+
justify-content: center;
|
24 |
+
gap: 4px;
|
25 |
+
|
26 |
+
pre {
|
27 |
+
overflow-x: auto;
|
28 |
+
}
|
29 |
+
|
30 |
+
display: block;
|
31 |
+
|
32 |
+
h4 {
|
33 |
+
font-size: 14px;
|
34 |
+
text-transform: uppercase;
|
35 |
+
padding: 8px 0;
|
36 |
+
margin: 0;
|
37 |
+
}
|
38 |
+
|
39 |
+
h5 {
|
40 |
+
margin: 0;
|
41 |
+
padding-bottom: 8px;
|
42 |
+
border-bottom: 1px solid var(--Neutral-20);
|
43 |
+
}
|
44 |
+
|
45 |
+
.part {
|
46 |
+
background: var(--Neutral-5);
|
47 |
+
padding: 14px;
|
48 |
+
margin-bottom: 4px;
|
49 |
+
color: var(--Neutral-90);
|
50 |
+
border-radius: 8px;
|
51 |
+
}
|
52 |
+
}
|
53 |
+
|
54 |
+
.plain-log {
|
55 |
+
&>* {
|
56 |
+
padding-right: 4px;
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
.inline-code:not(:last-child) {
|
61 |
+
font-style: italic;
|
62 |
+
|
63 |
+
&::after {
|
64 |
+
content: ", ";
|
65 |
+
}
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
.logger li {
|
70 |
+
display: block;
|
71 |
+
padding: 8px 0;
|
72 |
+
color: var(--Neutral-50, #707577);
|
73 |
+
font-family: "Space Mono";
|
74 |
+
font-size: 14px;
|
75 |
+
font-style: normal;
|
76 |
+
font-weight: 400;
|
77 |
+
line-height: normal;
|
78 |
+
}
|
79 |
+
|
80 |
+
.logger li .timestamp {
|
81 |
+
width: 70px;
|
82 |
+
flex-grow: 0;
|
83 |
+
flex-shrink: 0;
|
84 |
+
color: var(--Neutral-50);
|
85 |
+
}
|
86 |
+
|
87 |
+
.logger li .source {
|
88 |
+
flex-shrink: 0;
|
89 |
+
font-weight: bold;
|
90 |
+
}
|
91 |
+
|
92 |
+
.logger li.source-server,
|
93 |
+
.logger li.receive {
|
94 |
+
color: var(--Blue-500);
|
95 |
+
}
|
96 |
+
|
97 |
+
.logger li.source-client,
|
98 |
+
.logger li.send:not(.source-server) {
|
99 |
+
color: var(--Green-500);
|
100 |
+
}
|
101 |
+
|
102 |
+
.logger li .count {
|
103 |
+
background-color: var(--Neutral-5);
|
104 |
+
font-size: x-small;
|
105 |
+
padding: 0em 0.6em;
|
106 |
+
padding: 0.3em 0.5em;
|
107 |
+
line-height: 1em;
|
108 |
+
vertical-align: middle;
|
109 |
+
border-radius: 8px;
|
110 |
+
color: var(--Blue-500);
|
111 |
+
}
|
112 |
+
|
113 |
+
.logger li .message {
|
114 |
+
flex-grow: 1;
|
115 |
+
color: var(--Neutral-50);
|
116 |
+
}
|
src/components/logger/mock-logs.ts
ADDED
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
/**
|
18 |
+
* this module is just mock data, intended to make it easier to develop and style the logger
|
19 |
+
*/
|
20 |
+
import type { StreamingLog } from "../../multimodal-live-types";
|
21 |
+
|
22 |
+
const soundLogs = (n: number): StreamingLog[] =>
|
23 |
+
new Array(n).fill(0).map(
|
24 |
+
(): StreamingLog => ({
|
25 |
+
date: new Date(),
|
26 |
+
type: "server.audio",
|
27 |
+
message: "buffer (11250)",
|
28 |
+
}),
|
29 |
+
);
|
30 |
+
//
|
31 |
+
const realtimeLogs = (n: number): StreamingLog[] =>
|
32 |
+
new Array(n).fill(0).map(
|
33 |
+
(): StreamingLog => ({
|
34 |
+
date: new Date(),
|
35 |
+
type: "client.realtimeInput",
|
36 |
+
message: "audio",
|
37 |
+
}),
|
38 |
+
);
|
39 |
+
|
40 |
+
export const mockLogs: StreamingLog[] = [
|
41 |
+
{
|
42 |
+
date: new Date(),
|
43 |
+
type: "client.open",
|
44 |
+
message: "connected to socket",
|
45 |
+
},
|
46 |
+
...realtimeLogs(10),
|
47 |
+
...soundLogs(10),
|
48 |
+
{
|
49 |
+
date: new Date(),
|
50 |
+
type: "receive.content",
|
51 |
+
message: {
|
52 |
+
serverContent: {
|
53 |
+
interrupted: true,
|
54 |
+
},
|
55 |
+
},
|
56 |
+
},
|
57 |
+
{
|
58 |
+
date: new Date(),
|
59 |
+
type: "receive.content",
|
60 |
+
message: {
|
61 |
+
serverContent: {
|
62 |
+
turnComplete: true,
|
63 |
+
},
|
64 |
+
},
|
65 |
+
},
|
66 |
+
//this one is just a string
|
67 |
+
// {
|
68 |
+
// date: new Date(),
|
69 |
+
// type: "server.send",
|
70 |
+
// message: {
|
71 |
+
// serverContent: {
|
72 |
+
// turnComplete: true,
|
73 |
+
// },
|
74 |
+
// },
|
75 |
+
// },
|
76 |
+
...realtimeLogs(10),
|
77 |
+
...soundLogs(20),
|
78 |
+
{
|
79 |
+
date: new Date(),
|
80 |
+
type: "receive.content",
|
81 |
+
message: {
|
82 |
+
serverContent: {
|
83 |
+
modelTurn: {
|
84 |
+
parts: [{ text: "Hey its text" }, { text: "more" }],
|
85 |
+
},
|
86 |
+
},
|
87 |
+
},
|
88 |
+
},
|
89 |
+
{
|
90 |
+
date: new Date(),
|
91 |
+
type: "client.send",
|
92 |
+
message: {
|
93 |
+
clientContent: {
|
94 |
+
turns: [
|
95 |
+
{
|
96 |
+
role: "User",
|
97 |
+
parts: [
|
98 |
+
{
|
99 |
+
text: "How much wood could a woodchuck chuck if a woodchuck could chuck wood",
|
100 |
+
},
|
101 |
+
],
|
102 |
+
},
|
103 |
+
],
|
104 |
+
turnComplete: true,
|
105 |
+
},
|
106 |
+
},
|
107 |
+
},
|
108 |
+
{
|
109 |
+
date: new Date(),
|
110 |
+
type: "server.toolCall",
|
111 |
+
message: {
|
112 |
+
toolCall: {
|
113 |
+
functionCalls: [
|
114 |
+
{
|
115 |
+
id: "akadjlasdfla-askls",
|
116 |
+
name: "take_photo",
|
117 |
+
args: {},
|
118 |
+
},
|
119 |
+
{
|
120 |
+
id: "akldjsjskldsj-102",
|
121 |
+
name: "move_camera",
|
122 |
+
args: { x: 20, y: 4 },
|
123 |
+
},
|
124 |
+
],
|
125 |
+
},
|
126 |
+
},
|
127 |
+
},
|
128 |
+
{
|
129 |
+
date: new Date(),
|
130 |
+
type: "server.toolCallCancellation",
|
131 |
+
message: {
|
132 |
+
toolCallCancellation: {
|
133 |
+
ids: ["akladfjadslfk", "adkafsdljfsdk"],
|
134 |
+
},
|
135 |
+
},
|
136 |
+
},
|
137 |
+
{
|
138 |
+
date: new Date(),
|
139 |
+
type: "client.toolResponse",
|
140 |
+
message: {
|
141 |
+
toolResponse: {
|
142 |
+
functionResponses: [
|
143 |
+
{
|
144 |
+
response: { success: true },
|
145 |
+
id: "akslaj-10102",
|
146 |
+
},
|
147 |
+
],
|
148 |
+
},
|
149 |
+
},
|
150 |
+
},
|
151 |
+
];
|
src/components/settings-dialog/ResponseModalitySelector.tsx
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback, useState } from "react";
|
2 |
+
import Select from "react-select";
|
3 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
4 |
+
|
5 |
+
const options = [
|
6 |
+
{ value: "audio", label: "audio" },
|
7 |
+
{ value: "text", label: "text" },
|
8 |
+
];
|
9 |
+
|
10 |
+
export default function ResponseModalitySelector() {
|
11 |
+
const { config, setConfig } = useLiveAPIContext();
|
12 |
+
|
13 |
+
const [selectedOption, setSelectedOption] = useState<{
|
14 |
+
value: string;
|
15 |
+
label: string;
|
16 |
+
} | null>(options[0]);
|
17 |
+
|
18 |
+
const updateConfig = useCallback(
|
19 |
+
(modality: "audio" | "text" | undefined) => {
|
20 |
+
setConfig({
|
21 |
+
...config,
|
22 |
+
generationConfig: {
|
23 |
+
...config.generationConfig,
|
24 |
+
responseModalities: modality,
|
25 |
+
},
|
26 |
+
});
|
27 |
+
},
|
28 |
+
[config, setConfig]
|
29 |
+
);
|
30 |
+
|
31 |
+
return (
|
32 |
+
<div className="select-group">
|
33 |
+
<label htmlFor="response-modality-selector">Response modality</label>
|
34 |
+
<Select
|
35 |
+
id="response-modality-selector"
|
36 |
+
className="react-select"
|
37 |
+
classNamePrefix="react-select"
|
38 |
+
styles={{
|
39 |
+
control: (baseStyles) => ({
|
40 |
+
...baseStyles,
|
41 |
+
background: "var(--Neutral-15)",
|
42 |
+
color: "var(--Neutral-90)",
|
43 |
+
minHeight: "33px",
|
44 |
+
maxHeight: "33px",
|
45 |
+
border: 0,
|
46 |
+
}),
|
47 |
+
option: (styles, { isFocused, isSelected }) => ({
|
48 |
+
...styles,
|
49 |
+
backgroundColor: isFocused
|
50 |
+
? "var(--Neutral-30)"
|
51 |
+
: isSelected
|
52 |
+
? "var(--Neutral-20)"
|
53 |
+
: undefined,
|
54 |
+
}),
|
55 |
+
}}
|
56 |
+
defaultValue={selectedOption}
|
57 |
+
options={options}
|
58 |
+
onChange={(e) => {
|
59 |
+
setSelectedOption(e);
|
60 |
+
if (e && (e.value === "audio" || e.value === "text")) {
|
61 |
+
updateConfig(e.value);
|
62 |
+
}
|
63 |
+
}}
|
64 |
+
/>
|
65 |
+
</div>
|
66 |
+
);
|
67 |
+
}
|
src/components/settings-dialog/SettingsDialog.tsx
ADDED
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
ChangeEvent,
|
3 |
+
FormEventHandler,
|
4 |
+
useCallback,
|
5 |
+
useMemo,
|
6 |
+
useState,
|
7 |
+
} from "react";
|
8 |
+
import "./settings-dialog.scss";
|
9 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
10 |
+
import { LiveConfig } from "../../multimodal-live-types";
|
11 |
+
import {
|
12 |
+
FunctionDeclaration,
|
13 |
+
FunctionDeclarationsTool,
|
14 |
+
Tool,
|
15 |
+
} from "@google/generative-ai";
|
16 |
+
import VoiceSelector from "./VoiceSelector";
|
17 |
+
import ResponseModalitySelector from "./ResponseModalitySelector";
|
18 |
+
|
19 |
+
export default function SettingsDialog() {
|
20 |
+
const [open, setOpen] = useState(false);
|
21 |
+
const { config, setConfig, connected } = useLiveAPIContext();
|
22 |
+
const functionDeclarations: FunctionDeclaration[] = useMemo(() => {
|
23 |
+
if (!Array.isArray(config.tools)) {
|
24 |
+
return [];
|
25 |
+
}
|
26 |
+
return (config.tools as Tool[])
|
27 |
+
.filter((t: Tool): t is FunctionDeclarationsTool =>
|
28 |
+
Array.isArray((t as any).functionDeclarations)
|
29 |
+
)
|
30 |
+
.map((t) => t.functionDeclarations)
|
31 |
+
.filter((fc) => !!fc)
|
32 |
+
.flat();
|
33 |
+
}, [config]);
|
34 |
+
|
35 |
+
const systemInstruction = useMemo(() => {
|
36 |
+
const s = config.systemInstruction?.parts.find((p) => p.text)?.text || "";
|
37 |
+
|
38 |
+
return s;
|
39 |
+
}, [config]);
|
40 |
+
|
41 |
+
const updateConfig: FormEventHandler<HTMLTextAreaElement> = useCallback(
|
42 |
+
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
43 |
+
const newConfig: LiveConfig = {
|
44 |
+
...config,
|
45 |
+
systemInstruction: {
|
46 |
+
parts: [{ text: event.target.value }],
|
47 |
+
},
|
48 |
+
};
|
49 |
+
setConfig(newConfig);
|
50 |
+
},
|
51 |
+
[config, setConfig]
|
52 |
+
);
|
53 |
+
|
54 |
+
const updateFunctionDescription = useCallback(
|
55 |
+
(editedFdName: string, newDescription: string) => {
|
56 |
+
const newConfig: LiveConfig = {
|
57 |
+
...config,
|
58 |
+
tools:
|
59 |
+
config.tools?.map((tool) => {
|
60 |
+
const fdTool = tool as FunctionDeclarationsTool;
|
61 |
+
if (!Array.isArray(fdTool.functionDeclarations)) {
|
62 |
+
return tool;
|
63 |
+
}
|
64 |
+
return {
|
65 |
+
...tool,
|
66 |
+
functionDeclarations: fdTool.functionDeclarations.map((fd) =>
|
67 |
+
fd.name === editedFdName
|
68 |
+
? { ...fd, description: newDescription }
|
69 |
+
: fd
|
70 |
+
),
|
71 |
+
};
|
72 |
+
}) || [],
|
73 |
+
};
|
74 |
+
setConfig(newConfig);
|
75 |
+
},
|
76 |
+
[config, setConfig]
|
77 |
+
);
|
78 |
+
|
79 |
+
return (
|
80 |
+
<div className="settings-dialog">
|
81 |
+
<button
|
82 |
+
className="action-button material-symbols-outlined"
|
83 |
+
onClick={() => setOpen(!open)}
|
84 |
+
>
|
85 |
+
settings
|
86 |
+
</button>
|
87 |
+
<dialog className="dialog" style={{ display: open ? "block" : "none" }}>
|
88 |
+
<div className={`dialog-container ${connected ? "disabled" : ""}`}>
|
89 |
+
{connected && (
|
90 |
+
<div className="connected-indicator">
|
91 |
+
<p>
|
92 |
+
These settings can only be applied before connecting and will
|
93 |
+
override other settings.
|
94 |
+
</p>
|
95 |
+
</div>
|
96 |
+
)}
|
97 |
+
<div className="mode-selectors">
|
98 |
+
<ResponseModalitySelector />
|
99 |
+
<VoiceSelector />
|
100 |
+
</div>
|
101 |
+
|
102 |
+
<h3>System Instructions</h3>
|
103 |
+
<textarea
|
104 |
+
className="system"
|
105 |
+
onChange={updateConfig}
|
106 |
+
value={systemInstruction}
|
107 |
+
/>
|
108 |
+
<h4>Function declarations</h4>
|
109 |
+
<div className="function-declarations">
|
110 |
+
<div className="fd-rows">
|
111 |
+
{functionDeclarations.map((fd, fdKey) => (
|
112 |
+
<div className="fd-row" key={`function-${fdKey}`}>
|
113 |
+
<span className="fd-row-name">{fd.name}</span>
|
114 |
+
<span className="fd-row-args">
|
115 |
+
{Object.keys(fd.parameters?.properties || {}).map(
|
116 |
+
(item, k) => (
|
117 |
+
<span key={k}>{item}</span>
|
118 |
+
)
|
119 |
+
)}
|
120 |
+
</span>
|
121 |
+
<input
|
122 |
+
key={`fd-${fd.description}`}
|
123 |
+
className="fd-row-description"
|
124 |
+
type="text"
|
125 |
+
defaultValue={fd.description}
|
126 |
+
onBlur={(e) =>
|
127 |
+
updateFunctionDescription(fd.name, e.target.value)
|
128 |
+
}
|
129 |
+
/>
|
130 |
+
</div>
|
131 |
+
))}
|
132 |
+
</div>
|
133 |
+
</div>
|
134 |
+
</div>
|
135 |
+
</dialog>
|
136 |
+
</div>
|
137 |
+
);
|
138 |
+
}
|
src/components/settings-dialog/VoiceSelector.tsx
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback, useEffect, useState } from "react";
|
2 |
+
import Select from "react-select";
|
3 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
4 |
+
|
5 |
+
const voiceOptions = [
|
6 |
+
{ value: "Puck", label: "Puck" },
|
7 |
+
{ value: "Charon", label: "Charon" },
|
8 |
+
{ value: "Kore", label: "Kore" },
|
9 |
+
{ value: "Fenrir", label: "Fenrir" },
|
10 |
+
{ value: "Aoede", label: "Aoede" },
|
11 |
+
];
|
12 |
+
|
13 |
+
export default function VoiceSelector() {
|
14 |
+
const { config, setConfig } = useLiveAPIContext();
|
15 |
+
|
16 |
+
useEffect(() => {
|
17 |
+
const voiceName =
|
18 |
+
config.generationConfig?.speechConfig?.voiceConfig?.prebuiltVoiceConfig
|
19 |
+
?.voiceName || "Atari02";
|
20 |
+
const voiceOption = { value: voiceName, label: voiceName };
|
21 |
+
setSelectedOption(voiceOption);
|
22 |
+
}, [config]);
|
23 |
+
|
24 |
+
const [selectedOption, setSelectedOption] = useState<{
|
25 |
+
value: string;
|
26 |
+
label: string;
|
27 |
+
} | null>(voiceOptions[5]);
|
28 |
+
|
29 |
+
const updateConfig = useCallback(
|
30 |
+
(voiceName: string) => {
|
31 |
+
setConfig({
|
32 |
+
...config,
|
33 |
+
generationConfig: {
|
34 |
+
...config.generationConfig,
|
35 |
+
speechConfig: {
|
36 |
+
voiceConfig: {
|
37 |
+
prebuiltVoiceConfig: {
|
38 |
+
voiceName: voiceName,
|
39 |
+
},
|
40 |
+
},
|
41 |
+
},
|
42 |
+
},
|
43 |
+
});
|
44 |
+
},
|
45 |
+
[config, setConfig]
|
46 |
+
);
|
47 |
+
|
48 |
+
return (
|
49 |
+
<div className="select-group">
|
50 |
+
<label htmlFor="voice-selector">Voice</label>
|
51 |
+
<Select
|
52 |
+
id="voice-selector"
|
53 |
+
className="react-select"
|
54 |
+
classNamePrefix="react-select"
|
55 |
+
styles={{
|
56 |
+
control: (baseStyles) => ({
|
57 |
+
...baseStyles,
|
58 |
+
background: "var(--Neutral-15)",
|
59 |
+
color: "var(--Neutral-90)",
|
60 |
+
minHeight: "33px",
|
61 |
+
maxHeight: "33px",
|
62 |
+
border: 0,
|
63 |
+
}),
|
64 |
+
option: (styles, { isFocused, isSelected }) => ({
|
65 |
+
...styles,
|
66 |
+
backgroundColor: isFocused
|
67 |
+
? "var(--Neutral-30)"
|
68 |
+
: isSelected
|
69 |
+
? "var(--Neutral-20)"
|
70 |
+
: undefined,
|
71 |
+
}),
|
72 |
+
}}
|
73 |
+
value={selectedOption}
|
74 |
+
defaultValue={selectedOption}
|
75 |
+
options={voiceOptions}
|
76 |
+
onChange={(e) => {
|
77 |
+
setSelectedOption(e);
|
78 |
+
if (e) {
|
79 |
+
updateConfig(e.value);
|
80 |
+
}
|
81 |
+
}}
|
82 |
+
/>
|
83 |
+
</div>
|
84 |
+
);
|
85 |
+
}
|
src/components/settings-dialog/settings-dialog.scss
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.select-group {
|
2 |
+
display: flex;
|
3 |
+
flex-direction: column;
|
4 |
+
gap: 4px;
|
5 |
+
height: 70px;
|
6 |
+
& > label {
|
7 |
+
font-size: 10px;
|
8 |
+
}
|
9 |
+
}
|
10 |
+
|
11 |
+
.settings-dialog {
|
12 |
+
display: flex;
|
13 |
+
align-items: center;
|
14 |
+
justify-content: center;
|
15 |
+
gap: 4px;
|
16 |
+
height: 70px;
|
17 |
+
|
18 |
+
.connected-indicator {
|
19 |
+
font-style: italic;
|
20 |
+
}
|
21 |
+
|
22 |
+
/*.connected-indicator {
|
23 |
+
&:before {
|
24 |
+
position: absolute;
|
25 |
+
top: 0;
|
26 |
+
left: 0;
|
27 |
+
z-index: 0;
|
28 |
+
display: block;
|
29 |
+
content: "";
|
30 |
+
width: 100%;
|
31 |
+
height: 100%;
|
32 |
+
background-color: var(--Neutral-5);
|
33 |
+
opacity: 0.85;
|
34 |
+
}
|
35 |
+
z-index: 1;
|
36 |
+
|
37 |
+
position: absolute;
|
38 |
+
top: 0;
|
39 |
+
left: 0;
|
40 |
+
width: 100%;
|
41 |
+
height: 100%;
|
42 |
+
display: flex;
|
43 |
+
align-items: center;
|
44 |
+
justify-content: center;
|
45 |
+
font-size: 12px;
|
46 |
+
font-weight: 500;
|
47 |
+
color: var(--Neutral-80);
|
48 |
+
|
49 |
+
p {
|
50 |
+
z-index: 1;
|
51 |
+
}
|
52 |
+
}*/
|
53 |
+
|
54 |
+
h4 {
|
55 |
+
margin-left: 4px;
|
56 |
+
margin-bottom: 10px;
|
57 |
+
}
|
58 |
+
|
59 |
+
button {
|
60 |
+
background: none;
|
61 |
+
border: 0;
|
62 |
+
}
|
63 |
+
|
64 |
+
.dialog {
|
65 |
+
font-family: "Space mono";
|
66 |
+
background: var(--Neutral-5);
|
67 |
+
border-radius: 18px;
|
68 |
+
color: var(--Neutral-80);
|
69 |
+
border: 0;
|
70 |
+
padding: 0;
|
71 |
+
margin: 0;
|
72 |
+
position: fixed;
|
73 |
+
top: -400px;
|
74 |
+
right: 0;
|
75 |
+
width: 696px;
|
76 |
+
height: 593px;
|
77 |
+
transform: translate(-25%, -50%);
|
78 |
+
}
|
79 |
+
|
80 |
+
.dialog-container {
|
81 |
+
box-sizing: border-box;
|
82 |
+
padding: 32px;
|
83 |
+
max-height: 100%;
|
84 |
+
overflow-y: auto;
|
85 |
+
overflow-x: hidden;
|
86 |
+
}
|
87 |
+
|
88 |
+
.small {
|
89 |
+
font-size: 80%;
|
90 |
+
}
|
91 |
+
|
92 |
+
.mode-selectors {
|
93 |
+
display: flex;
|
94 |
+
gap: 1rem;
|
95 |
+
padding: 8px 0;
|
96 |
+
}
|
97 |
+
|
98 |
+
textarea.system {
|
99 |
+
border-radius: 12px;
|
100 |
+
background-color: var(--Neutral-15);
|
101 |
+
color: var(--Neutral-80);
|
102 |
+
margin-top: 8px;
|
103 |
+
font-family:
|
104 |
+
Google Sans,
|
105 |
+
sans-serif;
|
106 |
+
line-height: 21px;
|
107 |
+
font-size: 16px;
|
108 |
+
field-sizing: content;
|
109 |
+
width: calc(100% - 16px);
|
110 |
+
min-height: 150px;
|
111 |
+
height: 150px;
|
112 |
+
padding: 8px;
|
113 |
+
border: 0;
|
114 |
+
resize: vertical;
|
115 |
+
box-sizing: border-box;
|
116 |
+
}
|
117 |
+
|
118 |
+
.function-declarations {
|
119 |
+
font-size: 66%;
|
120 |
+
width: 100%;
|
121 |
+
/* Enables vertical scrolling */
|
122 |
+
}
|
123 |
+
|
124 |
+
.fd-rows {
|
125 |
+
display: grid;
|
126 |
+
grid-template-columns: 1fr 0.5fr 1.5fr;
|
127 |
+
row-gap: 6px;
|
128 |
+
/* Three columns, last one takes remaining space */
|
129 |
+
}
|
130 |
+
|
131 |
+
.fd-row-name {
|
132 |
+
font-family: "Space mono";
|
133 |
+
font-size: 12px;
|
134 |
+
font-weight: bold;
|
135 |
+
color: var(--Blue-400);
|
136 |
+
font-weight: bold;
|
137 |
+
border-radius: 8px;
|
138 |
+
border: 1px solid var(--Neutral-20, #2a2f31);
|
139 |
+
padding: 10px;
|
140 |
+
}
|
141 |
+
|
142 |
+
.fd-row-args {
|
143 |
+
padding: 12px;
|
144 |
+
}
|
145 |
+
|
146 |
+
.fd-row-args > *:not(:last-child)::after {
|
147 |
+
content: ", ";
|
148 |
+
}
|
149 |
+
|
150 |
+
.fd-row {
|
151 |
+
display: contents;
|
152 |
+
color: var(--Neutral-70);
|
153 |
+
/* Make data-row participate in the grid layout of data-rows, without being an additional grid row */
|
154 |
+
align-items: center;
|
155 |
+
/* Vertically center the content of the row */
|
156 |
+
/* Bottom border for each row */
|
157 |
+
height: 35px;
|
158 |
+
/* The row height */
|
159 |
+
}
|
160 |
+
|
161 |
+
.fd-row-description {
|
162 |
+
flex: 1;
|
163 |
+
background: transparent;
|
164 |
+
border: none;
|
165 |
+
color: inherit;
|
166 |
+
font-size: inherit;
|
167 |
+
padding: 2px 4px;
|
168 |
+
|
169 |
+
&:hover,
|
170 |
+
&:focus {
|
171 |
+
background: var(--Neutral-20);
|
172 |
+
outline: none;
|
173 |
+
}
|
174 |
+
}
|
175 |
+
}
|
src/components/side-panel/SidePanel.tsx
ADDED
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import "./react-select.scss";
|
18 |
+
import cn from "classnames";
|
19 |
+
import { useEffect, useRef, useState } from "react";
|
20 |
+
import { RiSidebarFoldLine, RiSidebarUnfoldLine } from "react-icons/ri";
|
21 |
+
import Select from "react-select";
|
22 |
+
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
23 |
+
import { useLoggerStore } from "../../lib/store-logger";
|
24 |
+
import Logger, { LoggerFilterType } from "../logger/Logger";
|
25 |
+
import "./side-panel.scss";
|
26 |
+
|
27 |
+
const filterOptions = [
|
28 |
+
{ value: "conversations", label: "Conversations" },
|
29 |
+
{ value: "tools", label: "Tool Use" },
|
30 |
+
{ value: "none", label: "All" },
|
31 |
+
];
|
32 |
+
|
33 |
+
export default function SidePanel() {
|
34 |
+
const { connected, client } = useLiveAPIContext();
|
35 |
+
const [open, setOpen] = useState(true);
|
36 |
+
const loggerRef = useRef<HTMLDivElement>(null);
|
37 |
+
const loggerLastHeightRef = useRef<number>(-1);
|
38 |
+
const { log, logs } = useLoggerStore();
|
39 |
+
|
40 |
+
const [textInput, setTextInput] = useState("");
|
41 |
+
const [selectedOption, setSelectedOption] = useState<{
|
42 |
+
value: string;
|
43 |
+
label: string;
|
44 |
+
} | null>(null);
|
45 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
46 |
+
|
47 |
+
//scroll the log to the bottom when new logs come in
|
48 |
+
useEffect(() => {
|
49 |
+
if (loggerRef.current) {
|
50 |
+
const el = loggerRef.current;
|
51 |
+
const scrollHeight = el.scrollHeight;
|
52 |
+
if (scrollHeight !== loggerLastHeightRef.current) {
|
53 |
+
el.scrollTop = scrollHeight;
|
54 |
+
loggerLastHeightRef.current = scrollHeight;
|
55 |
+
}
|
56 |
+
}
|
57 |
+
}, [logs]);
|
58 |
+
|
59 |
+
// listen for log events and store them
|
60 |
+
useEffect(() => {
|
61 |
+
client.on("log", log);
|
62 |
+
return () => {
|
63 |
+
client.off("log", log);
|
64 |
+
};
|
65 |
+
}, [client, log]);
|
66 |
+
|
67 |
+
const handleSubmit = () => {
|
68 |
+
client.send([{ text: textInput }]);
|
69 |
+
|
70 |
+
setTextInput("");
|
71 |
+
if (inputRef.current) {
|
72 |
+
inputRef.current.innerText = "";
|
73 |
+
}
|
74 |
+
};
|
75 |
+
|
76 |
+
return (
|
77 |
+
<div className={`side-panel ${open ? "open" : ""}`}>
|
78 |
+
<header className="top">
|
79 |
+
<h2>Console</h2>
|
80 |
+
{open ? (
|
81 |
+
<button className="opener" onClick={() => setOpen(false)}>
|
82 |
+
<RiSidebarFoldLine color="#b4b8bb" />
|
83 |
+
</button>
|
84 |
+
) : (
|
85 |
+
<button className="opener" onClick={() => setOpen(true)}>
|
86 |
+
<RiSidebarUnfoldLine color="#b4b8bb" />
|
87 |
+
</button>
|
88 |
+
)}
|
89 |
+
</header>
|
90 |
+
<section className="indicators">
|
91 |
+
<Select
|
92 |
+
className="react-select"
|
93 |
+
classNamePrefix="react-select"
|
94 |
+
styles={{
|
95 |
+
control: (baseStyles) => ({
|
96 |
+
...baseStyles,
|
97 |
+
background: "var(--Neutral-15)",
|
98 |
+
color: "var(--Neutral-90)",
|
99 |
+
minHeight: "33px",
|
100 |
+
maxHeight: "33px",
|
101 |
+
border: 0,
|
102 |
+
}),
|
103 |
+
option: (styles, { isFocused, isSelected }) => ({
|
104 |
+
...styles,
|
105 |
+
backgroundColor: isFocused
|
106 |
+
? "var(--Neutral-30)"
|
107 |
+
: isSelected
|
108 |
+
? "var(--Neutral-20)"
|
109 |
+
: undefined,
|
110 |
+
}),
|
111 |
+
}}
|
112 |
+
defaultValue={selectedOption}
|
113 |
+
options={filterOptions}
|
114 |
+
onChange={(e) => {
|
115 |
+
setSelectedOption(e);
|
116 |
+
}}
|
117 |
+
/>
|
118 |
+
<div className={cn("streaming-indicator", { connected })}>
|
119 |
+
{connected
|
120 |
+
? `🔵${open ? " Streaming" : ""}`
|
121 |
+
: `⏸️${open ? " Paused" : ""}`}
|
122 |
+
</div>
|
123 |
+
</section>
|
124 |
+
<div className="side-panel-container" ref={loggerRef}>
|
125 |
+
<Logger
|
126 |
+
filter={(selectedOption?.value as LoggerFilterType) || "none"}
|
127 |
+
/>
|
128 |
+
</div>
|
129 |
+
<div className={cn("input-container", { disabled: !connected })}>
|
130 |
+
<div className="input-content">
|
131 |
+
<textarea
|
132 |
+
className="input-area"
|
133 |
+
ref={inputRef}
|
134 |
+
onKeyDown={(e) => {
|
135 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
136 |
+
e.preventDefault();
|
137 |
+
e.stopPropagation();
|
138 |
+
handleSubmit();
|
139 |
+
}
|
140 |
+
}}
|
141 |
+
onChange={(e) => setTextInput(e.target.value)}
|
142 |
+
value={textInput}
|
143 |
+
></textarea>
|
144 |
+
<span
|
145 |
+
className={cn("input-content-placeholder", {
|
146 |
+
hidden: textInput.length,
|
147 |
+
})}
|
148 |
+
>
|
149 |
+
Type something...
|
150 |
+
</span>
|
151 |
+
|
152 |
+
<button
|
153 |
+
className="send-button material-symbols-outlined filled"
|
154 |
+
onClick={handleSubmit}
|
155 |
+
>
|
156 |
+
send
|
157 |
+
</button>
|
158 |
+
</div>
|
159 |
+
</div>
|
160 |
+
</div>
|
161 |
+
);
|
162 |
+
}
|
src/components/side-panel/react-select.scss
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.react-select {
|
2 |
+
background: var(--Neutral-20);
|
3 |
+
color: var(--Neutral-90);
|
4 |
+
width: 193px;
|
5 |
+
height: 30px;
|
6 |
+
|
7 |
+
.react-select__single-value {
|
8 |
+
color: var(--Neutral-90);
|
9 |
+
}
|
10 |
+
|
11 |
+
.react-select__menu {
|
12 |
+
background: var(--Neutral-20);
|
13 |
+
color: var(--Neutral-90);
|
14 |
+
}
|
15 |
+
|
16 |
+
.react-select__option {
|
17 |
+
}
|
18 |
+
|
19 |
+
.react-select__value-container {
|
20 |
+
}
|
21 |
+
|
22 |
+
.react-select__indicators {
|
23 |
+
}
|
24 |
+
|
25 |
+
.react-select__option:hover,
|
26 |
+
.react-select__option:focus,
|
27 |
+
.react-select_option:focus-within {
|
28 |
+
background: var(--Neutral-30);
|
29 |
+
}
|
30 |
+
|
31 |
+
.react-select__option--is-focused: {
|
32 |
+
background: var(--Neutral-30);
|
33 |
+
}
|
34 |
+
}
|
src/components/side-panel/side-panel.scss
ADDED
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.side-panel {
|
2 |
+
background: var(--Neutral-00);
|
3 |
+
width: 40px;
|
4 |
+
/* when closed */
|
5 |
+
display: flex;
|
6 |
+
flex-direction: column;
|
7 |
+
height: 100vh;
|
8 |
+
transition: all 0.2s ease-in;
|
9 |
+
font-family: Arial, sans-serif;
|
10 |
+
border-right: 1px solid var(--gray-600);
|
11 |
+
color: var(--Neutral-90, #e1e2e3);
|
12 |
+
font-family: var(--font-family);
|
13 |
+
font-size: 13px;
|
14 |
+
font-style: normal;
|
15 |
+
font-weight: 400;
|
16 |
+
line-height: 160%;
|
17 |
+
/* 20.8px */
|
18 |
+
|
19 |
+
.hidden {
|
20 |
+
display: none !important;
|
21 |
+
}
|
22 |
+
|
23 |
+
&.open {
|
24 |
+
.top {
|
25 |
+
h2 {
|
26 |
+
left: 0%;
|
27 |
+
display: block;
|
28 |
+
opacity: 1;
|
29 |
+
}
|
30 |
+
}
|
31 |
+
}
|
32 |
+
|
33 |
+
.top {
|
34 |
+
display: flex;
|
35 |
+
width: calc(100% - 45px);
|
36 |
+
justify-content: space-between;
|
37 |
+
align-items: center;
|
38 |
+
padding: 12px 20px 12px 25px;
|
39 |
+
border-bottom: 1px solid var(--Neutral-20);
|
40 |
+
|
41 |
+
h2 {
|
42 |
+
position: relative;
|
43 |
+
color: var(--Neutral-90, #e1e2e3);
|
44 |
+
font-family: "Google Sans";
|
45 |
+
font-size: 21px;
|
46 |
+
font-style: normal;
|
47 |
+
font-weight: 500;
|
48 |
+
line-height: 16px;
|
49 |
+
/* 100% */
|
50 |
+
|
51 |
+
opacity: 0;
|
52 |
+
display: none;
|
53 |
+
left: -100%;
|
54 |
+
transition:
|
55 |
+
opacity 0.2s ease-in,
|
56 |
+
left 0.2s ease-in,
|
57 |
+
display 0.2s ease-in;
|
58 |
+
transition-behavior: allow-discrete;
|
59 |
+
|
60 |
+
@starting-style {
|
61 |
+
left: 0%;
|
62 |
+
opacity: 1;
|
63 |
+
}
|
64 |
+
}
|
65 |
+
}
|
66 |
+
|
67 |
+
.opener {
|
68 |
+
height: 30px;
|
69 |
+
transition: transform 0.2s ease-in;
|
70 |
+
}
|
71 |
+
|
72 |
+
&:not(.open) {
|
73 |
+
.side-panel-container {
|
74 |
+
opacity: 0;
|
75 |
+
display: none;
|
76 |
+
transition: all 0.2s ease-in allow-discrete;
|
77 |
+
transition-delay: 0.1s;
|
78 |
+
}
|
79 |
+
|
80 |
+
.indicators .streaming-indicator {
|
81 |
+
width: 30px;
|
82 |
+
opacity: 0;
|
83 |
+
}
|
84 |
+
|
85 |
+
.opener {
|
86 |
+
transform: translate(-50%, 0);
|
87 |
+
}
|
88 |
+
|
89 |
+
.input-container {
|
90 |
+
opacity: 0;
|
91 |
+
display: none;
|
92 |
+
transition: all 0.2s ease-in allow-discrete;
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
.indicators {
|
97 |
+
display: flex;
|
98 |
+
padding: 24px 25px;
|
99 |
+
justify-content: flex-end;
|
100 |
+
gap: 21px;
|
101 |
+
|
102 |
+
.streaming-indicator {
|
103 |
+
user-select: none;
|
104 |
+
border-radius: 4px;
|
105 |
+
border: 1px solid var(--Neutral-20, #2a2f31);
|
106 |
+
background: var(--Neutral-10, #1c1f21);
|
107 |
+
display: flex;
|
108 |
+
width: 136px;
|
109 |
+
height: 30px;
|
110 |
+
padding-left: 4px;
|
111 |
+
justify-content: center;
|
112 |
+
align-items: center;
|
113 |
+
gap: 6px;
|
114 |
+
flex-shrink: 0;
|
115 |
+
text-align: center;
|
116 |
+
font-family: "Space Mono";
|
117 |
+
font-size: 14px;
|
118 |
+
font-style: normal;
|
119 |
+
font-weight: 400;
|
120 |
+
line-height: normal;
|
121 |
+
transition: width 0.2s ease-in;
|
122 |
+
|
123 |
+
&.connected {
|
124 |
+
color: var(--Blue-500, #0d9c53);
|
125 |
+
}
|
126 |
+
}
|
127 |
+
}
|
128 |
+
|
129 |
+
.side-panel-container {
|
130 |
+
align-self: flex-end;
|
131 |
+
width: 400px;
|
132 |
+
flex-grow: 1;
|
133 |
+
overflow-x: hidden;
|
134 |
+
overflow-y: auto;
|
135 |
+
/*scrollbar-gutter: stable both-edges;*/
|
136 |
+
}
|
137 |
+
|
138 |
+
.input-container {
|
139 |
+
height: 50px;
|
140 |
+
flex-grow: 0;
|
141 |
+
flex-shrink: 0;
|
142 |
+
border-top: 1px solid var(--Neutral-20);
|
143 |
+
padding: 14px 25px;
|
144 |
+
overflow: hidden;
|
145 |
+
|
146 |
+
.input-content {
|
147 |
+
position: relative;
|
148 |
+
background: var(--Neutral-10);
|
149 |
+
border: 1px solid var(--Neutral-15);
|
150 |
+
height: 22px;
|
151 |
+
border-radius: 10px;
|
152 |
+
padding: 11px 18px;
|
153 |
+
|
154 |
+
.send-button {
|
155 |
+
position: absolute;
|
156 |
+
top: 50%;
|
157 |
+
right: 0;
|
158 |
+
transform: translate(0, -50%);
|
159 |
+
background: none;
|
160 |
+
border: 0;
|
161 |
+
color: var(--Neutral-20);
|
162 |
+
cursor: pointer;
|
163 |
+
transition: color 0.1s ease-in;
|
164 |
+
z-index: 2;
|
165 |
+
|
166 |
+
&:hover {
|
167 |
+
color: var(--Neutral-60);
|
168 |
+
}
|
169 |
+
}
|
170 |
+
|
171 |
+
.input-area {
|
172 |
+
background: none;
|
173 |
+
color: var(--Neutral-90);
|
174 |
+
field-sizing: content;
|
175 |
+
position: absolute;
|
176 |
+
top: 0;
|
177 |
+
left: 0;
|
178 |
+
z-index: 2;
|
179 |
+
display: inline-block;
|
180 |
+
width: calc(100% - 72px);
|
181 |
+
max-height: 20px;
|
182 |
+
outline: none;
|
183 |
+
--webkit-box-flex: 1;
|
184 |
+
flex: 1;
|
185 |
+
word-break: break-word;
|
186 |
+
overflow: auto;
|
187 |
+
padding: 14px 18px;
|
188 |
+
border: 0;
|
189 |
+
resize: none;
|
190 |
+
}
|
191 |
+
|
192 |
+
.input-content-placeholder {
|
193 |
+
position: absolute;
|
194 |
+
left: 0;
|
195 |
+
top: 0;
|
196 |
+
display: flex;
|
197 |
+
align-items: center;
|
198 |
+
z-index: 1;
|
199 |
+
height: 100%;
|
200 |
+
width: 100%;
|
201 |
+
pointer-events: none;
|
202 |
+
user-select: none;
|
203 |
+
padding: 0px 18px;
|
204 |
+
white-space: pre-wrap;
|
205 |
+
}
|
206 |
+
}
|
207 |
+
}
|
208 |
+
}
|
209 |
+
|
210 |
+
.side-panel.open {
|
211 |
+
width: 400px;
|
212 |
+
height: 100vh;
|
213 |
+
}
|
214 |
+
|
215 |
+
.side-panel-responses,
|
216 |
+
.side-panel-requests {
|
217 |
+
flex-grow: 1;
|
218 |
+
flex-shrink: 1;
|
219 |
+
overflow-x: hidden;
|
220 |
+
overflow-y: auto;
|
221 |
+
width: 100%;
|
222 |
+
display: block;
|
223 |
+
margin-left: 8px;
|
224 |
+
}
|
225 |
+
|
226 |
+
.top {
|
227 |
+
width: 100%;
|
228 |
+
flex-grow: 0;
|
229 |
+
flex-shrink: 0;
|
230 |
+
height: 30px;
|
231 |
+
display: flex;
|
232 |
+
align-self: flex-end;
|
233 |
+
align-items: center;
|
234 |
+
transition: all 0.2s ease-in;
|
235 |
+
}
|
236 |
+
|
237 |
+
.top button {
|
238 |
+
background: transparent;
|
239 |
+
border: 0;
|
240 |
+
cursor: pointer;
|
241 |
+
font-size: 1.25rem;
|
242 |
+
line-height: 1.75rem;
|
243 |
+
padding: 4px;
|
244 |
+
}
|
src/contexts/LiveAPIContext.tsx
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { createContext, FC, ReactNode, useContext } from "react";
|
18 |
+
import { useLiveAPI, UseLiveAPIResults } from "../hooks/use-live-api";
|
19 |
+
|
20 |
+
const LiveAPIContext = createContext<UseLiveAPIResults | undefined>(undefined);
|
21 |
+
|
22 |
+
export type LiveAPIProviderProps = {
|
23 |
+
children: ReactNode;
|
24 |
+
url?: string;
|
25 |
+
apiKey: string;
|
26 |
+
};
|
27 |
+
|
28 |
+
export const LiveAPIProvider: FC<LiveAPIProviderProps> = ({
|
29 |
+
url,
|
30 |
+
apiKey,
|
31 |
+
children,
|
32 |
+
}) => {
|
33 |
+
const liveAPI = useLiveAPI({ url, apiKey });
|
34 |
+
|
35 |
+
return (
|
36 |
+
<LiveAPIContext.Provider value={liveAPI}>
|
37 |
+
{children}
|
38 |
+
</LiveAPIContext.Provider>
|
39 |
+
);
|
40 |
+
};
|
41 |
+
|
42 |
+
export const useLiveAPIContext = () => {
|
43 |
+
const context = useContext(LiveAPIContext);
|
44 |
+
if (!context) {
|
45 |
+
throw new Error("useLiveAPIContext must be used wihin a LiveAPIProvider");
|
46 |
+
}
|
47 |
+
return context;
|
48 |
+
};
|
src/hooks/use-live-api.ts
ADDED
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
18 |
+
import {
|
19 |
+
MultimodalLiveAPIClientConnection,
|
20 |
+
MultimodalLiveClient,
|
21 |
+
} from "../lib/multimodal-live-client";
|
22 |
+
import { LiveConfig } from "../multimodal-live-types";
|
23 |
+
import { AudioStreamer } from "../lib/audio-streamer";
|
24 |
+
import { audioContext } from "../lib/utils";
|
25 |
+
import VolMeterWorket from "../lib/worklets/vol-meter";
|
26 |
+
|
27 |
+
export type UseLiveAPIResults = {
|
28 |
+
client: MultimodalLiveClient;
|
29 |
+
setConfig: (config: LiveConfig) => void;
|
30 |
+
config: LiveConfig;
|
31 |
+
connected: boolean;
|
32 |
+
connect: () => Promise<void>;
|
33 |
+
disconnect: () => Promise<void>;
|
34 |
+
volume: number;
|
35 |
+
};
|
36 |
+
|
37 |
+
export function useLiveAPI({
|
38 |
+
url,
|
39 |
+
apiKey,
|
40 |
+
}: MultimodalLiveAPIClientConnection): UseLiveAPIResults {
|
41 |
+
const client = useMemo(
|
42 |
+
() => new MultimodalLiveClient({ url, apiKey }),
|
43 |
+
[url, apiKey],
|
44 |
+
);
|
45 |
+
const audioStreamerRef = useRef<AudioStreamer | null>(null);
|
46 |
+
|
47 |
+
const [connected, setConnected] = useState(false);
|
48 |
+
const [config, setConfig] = useState<LiveConfig>({
|
49 |
+
model: "models/gemini-2.0-flash-live-001",
|
50 |
+
systemInstruction: {
|
51 |
+
parts: [
|
52 |
+
{ text: "Please respond in English." }
|
53 |
+
]
|
54 |
+
},
|
55 |
+
generationConfig: {
|
56 |
+
responseModalities: "audio",
|
57 |
+
speechConfig: {
|
58 |
+
voiceConfig: {
|
59 |
+
prebuiltVoiceConfig: {
|
60 |
+
voiceName: "Charon"
|
61 |
+
}
|
62 |
+
}
|
63 |
+
}
|
64 |
+
}
|
65 |
+
});
|
66 |
+
const [volume, setVolume] = useState(0);
|
67 |
+
|
68 |
+
// register audio for streaming server -> speakers
|
69 |
+
useEffect(() => {
|
70 |
+
if (!audioStreamerRef.current) {
|
71 |
+
audioContext({ id: "audio-out" }).then((audioCtx: AudioContext) => {
|
72 |
+
audioStreamerRef.current = new AudioStreamer(audioCtx);
|
73 |
+
audioStreamerRef.current
|
74 |
+
.addWorklet<any>("vumeter-out", VolMeterWorket, (ev: any) => {
|
75 |
+
setVolume(ev.data.volume);
|
76 |
+
})
|
77 |
+
.then(() => {
|
78 |
+
// Successfully added worklet
|
79 |
+
});
|
80 |
+
});
|
81 |
+
}
|
82 |
+
}, [audioStreamerRef]);
|
83 |
+
|
84 |
+
useEffect(() => {
|
85 |
+
const onClose = () => {
|
86 |
+
setConnected(false);
|
87 |
+
};
|
88 |
+
|
89 |
+
const stopAudioStreamer = () => audioStreamerRef.current?.stop();
|
90 |
+
|
91 |
+
const onAudio = (data: ArrayBuffer) =>
|
92 |
+
audioStreamerRef.current?.addPCM16(new Uint8Array(data));
|
93 |
+
|
94 |
+
client
|
95 |
+
.on("close", onClose)
|
96 |
+
.on("interrupted", stopAudioStreamer)
|
97 |
+
.on("audio", onAudio);
|
98 |
+
|
99 |
+
return () => {
|
100 |
+
client
|
101 |
+
.off("close", onClose)
|
102 |
+
.off("interrupted", stopAudioStreamer)
|
103 |
+
.off("audio", onAudio);
|
104 |
+
};
|
105 |
+
}, [client]);
|
106 |
+
|
107 |
+
const connect = useCallback(async () => {
|
108 |
+
console.log(config);
|
109 |
+
if (!config) {
|
110 |
+
throw new Error("config has not been set");
|
111 |
+
}
|
112 |
+
client.disconnect();
|
113 |
+
await client.connect(config);
|
114 |
+
setConnected(true);
|
115 |
+
}, [client, setConnected, config]);
|
116 |
+
|
117 |
+
const disconnect = useCallback(async () => {
|
118 |
+
client.disconnect();
|
119 |
+
setConnected(false);
|
120 |
+
}, [setConnected, client]);
|
121 |
+
|
122 |
+
return {
|
123 |
+
client,
|
124 |
+
config,
|
125 |
+
setConfig,
|
126 |
+
connected,
|
127 |
+
connect,
|
128 |
+
disconnect,
|
129 |
+
volume,
|
130 |
+
};
|
131 |
+
}
|
src/hooks/use-media-stream-mux.ts
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
export type UseMediaStreamResult = {
|
18 |
+
type: "webcam" | "screen";
|
19 |
+
start: () => Promise<MediaStream>;
|
20 |
+
stop: () => void;
|
21 |
+
isStreaming: boolean;
|
22 |
+
stream: MediaStream | null;
|
23 |
+
};
|
src/hooks/use-screen-capture.ts
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { useState, useEffect } from "react";
|
18 |
+
import { UseMediaStreamResult } from "./use-media-stream-mux";
|
19 |
+
|
20 |
+
export function useScreenCapture(): UseMediaStreamResult {
|
21 |
+
const [stream, setStream] = useState<MediaStream | null>(null);
|
22 |
+
const [isStreaming, setIsStreaming] = useState(false);
|
23 |
+
|
24 |
+
useEffect(() => {
|
25 |
+
const handleStreamEnded = () => {
|
26 |
+
setIsStreaming(false);
|
27 |
+
setStream(null);
|
28 |
+
};
|
29 |
+
if (stream) {
|
30 |
+
stream
|
31 |
+
.getTracks()
|
32 |
+
.forEach((track) => track.addEventListener("ended", handleStreamEnded));
|
33 |
+
return () => {
|
34 |
+
stream
|
35 |
+
.getTracks()
|
36 |
+
.forEach((track) =>
|
37 |
+
track.removeEventListener("ended", handleStreamEnded),
|
38 |
+
);
|
39 |
+
};
|
40 |
+
}
|
41 |
+
}, [stream]);
|
42 |
+
|
43 |
+
const start = async () => {
|
44 |
+
// const controller = new CaptureController();
|
45 |
+
// controller.setFocusBehavior("no-focus-change");
|
46 |
+
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
47 |
+
video: true,
|
48 |
+
// controller
|
49 |
+
});
|
50 |
+
setStream(mediaStream);
|
51 |
+
setIsStreaming(true);
|
52 |
+
return mediaStream;
|
53 |
+
};
|
54 |
+
|
55 |
+
const stop = () => {
|
56 |
+
if (stream) {
|
57 |
+
stream.getTracks().forEach((track) => track.stop());
|
58 |
+
setStream(null);
|
59 |
+
setIsStreaming(false);
|
60 |
+
}
|
61 |
+
};
|
62 |
+
|
63 |
+
const result: UseMediaStreamResult = {
|
64 |
+
type: "screen",
|
65 |
+
start,
|
66 |
+
stop,
|
67 |
+
isStreaming,
|
68 |
+
stream,
|
69 |
+
};
|
70 |
+
|
71 |
+
return result;
|
72 |
+
}
|
src/hooks/use-webcam.ts
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { useState, useEffect } from "react";
|
18 |
+
import { UseMediaStreamResult } from "./use-media-stream-mux";
|
19 |
+
|
20 |
+
export function useWebcam(): UseMediaStreamResult {
|
21 |
+
const [stream, setStream] = useState<MediaStream | null>(null);
|
22 |
+
const [isStreaming, setIsStreaming] = useState(false);
|
23 |
+
|
24 |
+
useEffect(() => {
|
25 |
+
const handleStreamEnded = () => {
|
26 |
+
setIsStreaming(false);
|
27 |
+
setStream(null);
|
28 |
+
};
|
29 |
+
if (stream) {
|
30 |
+
stream
|
31 |
+
.getTracks()
|
32 |
+
.forEach((track) => track.addEventListener("ended", handleStreamEnded));
|
33 |
+
return () => {
|
34 |
+
stream
|
35 |
+
.getTracks()
|
36 |
+
.forEach((track) =>
|
37 |
+
track.removeEventListener("ended", handleStreamEnded),
|
38 |
+
);
|
39 |
+
};
|
40 |
+
}
|
41 |
+
}, [stream]);
|
42 |
+
|
43 |
+
const start = async () => {
|
44 |
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
45 |
+
video: true,
|
46 |
+
});
|
47 |
+
setStream(mediaStream);
|
48 |
+
setIsStreaming(true);
|
49 |
+
return mediaStream;
|
50 |
+
};
|
51 |
+
|
52 |
+
const stop = () => {
|
53 |
+
if (stream) {
|
54 |
+
stream.getTracks().forEach((track) => track.stop());
|
55 |
+
setStream(null);
|
56 |
+
setIsStreaming(false);
|
57 |
+
}
|
58 |
+
};
|
59 |
+
|
60 |
+
const result: UseMediaStreamResult = {
|
61 |
+
type: "webcam",
|
62 |
+
start,
|
63 |
+
stop,
|
64 |
+
isStreaming,
|
65 |
+
stream,
|
66 |
+
};
|
67 |
+
|
68 |
+
return result;
|
69 |
+
}
|
src/index.css
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
body {
|
2 |
+
margin: 0;
|
3 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
4 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
5 |
+
sans-serif;
|
6 |
+
-webkit-font-smoothing: antialiased;
|
7 |
+
-moz-osx-font-smoothing: grayscale;
|
8 |
+
}
|
9 |
+
|
10 |
+
code {
|
11 |
+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
12 |
+
monospace;
|
13 |
+
}
|
src/index.tsx
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import React from 'react';
|
18 |
+
import ReactDOM from 'react-dom/client';
|
19 |
+
import './index.css';
|
20 |
+
import App from './App';
|
21 |
+
import reportWebVitals from './reportWebVitals';
|
22 |
+
|
23 |
+
const root = ReactDOM.createRoot(
|
24 |
+
document.getElementById('root') as HTMLElement
|
25 |
+
);
|
26 |
+
root.render(
|
27 |
+
<React.StrictMode>
|
28 |
+
<App />
|
29 |
+
</React.StrictMode>
|
30 |
+
);
|
31 |
+
|
32 |
+
// If you want to start measuring performance in your app, pass a function
|
33 |
+
// to log results (for example: reportWebVitals(console.log))
|
34 |
+
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
35 |
+
reportWebVitals();
|
src/lib/audio-recorder.ts
ADDED
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { audioContext } from "./utils";
|
18 |
+
import AudioRecordingWorklet from "./worklets/audio-processing";
|
19 |
+
import VolMeterWorket from "./worklets/vol-meter";
|
20 |
+
|
21 |
+
import { createWorketFromSrc } from "./audioworklet-registry";
|
22 |
+
import EventEmitter from "eventemitter3";
|
23 |
+
|
24 |
+
function arrayBufferToBase64(buffer: ArrayBuffer) {
|
25 |
+
var binary = "";
|
26 |
+
var bytes = new Uint8Array(buffer);
|
27 |
+
var len = bytes.byteLength;
|
28 |
+
for (var i = 0; i < len; i++) {
|
29 |
+
binary += String.fromCharCode(bytes[i]);
|
30 |
+
}
|
31 |
+
return window.btoa(binary);
|
32 |
+
}
|
33 |
+
|
34 |
+
export class AudioRecorder extends EventEmitter {
|
35 |
+
stream: MediaStream | undefined;
|
36 |
+
audioContext: AudioContext | undefined;
|
37 |
+
source: MediaStreamAudioSourceNode | undefined;
|
38 |
+
recording: boolean = false;
|
39 |
+
recordingWorklet: AudioWorkletNode | undefined;
|
40 |
+
vuWorklet: AudioWorkletNode | undefined;
|
41 |
+
|
42 |
+
private starting: Promise<void> | null = null;
|
43 |
+
|
44 |
+
constructor(public sampleRate = 16000) {
|
45 |
+
super();
|
46 |
+
}
|
47 |
+
|
48 |
+
async start() {
|
49 |
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
50 |
+
throw new Error("Could not request user media");
|
51 |
+
}
|
52 |
+
|
53 |
+
this.starting = new Promise(async (resolve, reject) => {
|
54 |
+
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
55 |
+
this.audioContext = await audioContext({ sampleRate: this.sampleRate });
|
56 |
+
this.source = this.audioContext.createMediaStreamSource(this.stream);
|
57 |
+
|
58 |
+
const workletName = "audio-recorder-worklet";
|
59 |
+
const src = createWorketFromSrc(workletName, AudioRecordingWorklet);
|
60 |
+
|
61 |
+
await this.audioContext.audioWorklet.addModule(src);
|
62 |
+
this.recordingWorklet = new AudioWorkletNode(
|
63 |
+
this.audioContext,
|
64 |
+
workletName,
|
65 |
+
);
|
66 |
+
|
67 |
+
this.recordingWorklet.port.onmessage = async (ev: MessageEvent) => {
|
68 |
+
// worklet processes recording floats and messages converted buffer
|
69 |
+
const arrayBuffer = ev.data.data.int16arrayBuffer;
|
70 |
+
|
71 |
+
if (arrayBuffer) {
|
72 |
+
const arrayBufferString = arrayBufferToBase64(arrayBuffer);
|
73 |
+
this.emit("data", arrayBufferString);
|
74 |
+
}
|
75 |
+
};
|
76 |
+
this.source.connect(this.recordingWorklet);
|
77 |
+
|
78 |
+
// vu meter worklet
|
79 |
+
const vuWorkletName = "vu-meter";
|
80 |
+
await this.audioContext.audioWorklet.addModule(
|
81 |
+
createWorketFromSrc(vuWorkletName, VolMeterWorket),
|
82 |
+
);
|
83 |
+
this.vuWorklet = new AudioWorkletNode(this.audioContext, vuWorkletName);
|
84 |
+
this.vuWorklet.port.onmessage = (ev: MessageEvent) => {
|
85 |
+
this.emit("volume", ev.data.volume);
|
86 |
+
};
|
87 |
+
|
88 |
+
this.source.connect(this.vuWorklet);
|
89 |
+
this.recording = true;
|
90 |
+
resolve();
|
91 |
+
this.starting = null;
|
92 |
+
});
|
93 |
+
}
|
94 |
+
|
95 |
+
stop() {
|
96 |
+
// its plausible that stop would be called before start completes
|
97 |
+
// such as if the websocket immediately hangs up
|
98 |
+
const handleStop = () => {
|
99 |
+
this.source?.disconnect();
|
100 |
+
this.stream?.getTracks().forEach((track) => track.stop());
|
101 |
+
this.stream = undefined;
|
102 |
+
this.recordingWorklet = undefined;
|
103 |
+
this.vuWorklet = undefined;
|
104 |
+
};
|
105 |
+
if (this.starting) {
|
106 |
+
this.starting.then(handleStop);
|
107 |
+
return;
|
108 |
+
}
|
109 |
+
handleStop();
|
110 |
+
}
|
111 |
+
}
|
src/lib/audio-streamer.ts
ADDED
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import {
|
18 |
+
createWorketFromSrc,
|
19 |
+
registeredWorklets,
|
20 |
+
} from "./audioworklet-registry";
|
21 |
+
|
22 |
+
export class AudioStreamer {
|
23 |
+
public audioQueue: Float32Array[] = [];
|
24 |
+
private isPlaying: boolean = false;
|
25 |
+
private sampleRate: number = 24000;
|
26 |
+
private bufferSize: number = 7680;
|
27 |
+
private processingBuffer: Float32Array = new Float32Array(0);
|
28 |
+
private scheduledTime: number = 0;
|
29 |
+
public gainNode: GainNode;
|
30 |
+
public source: AudioBufferSourceNode;
|
31 |
+
private isStreamComplete: boolean = false;
|
32 |
+
private checkInterval: number | null = null;
|
33 |
+
private initialBufferTime: number = 0.1; //0.1 // 100ms initial buffer
|
34 |
+
private endOfQueueAudioSource: AudioBufferSourceNode | null = null;
|
35 |
+
|
36 |
+
public onComplete = () => {};
|
37 |
+
|
38 |
+
constructor(public context: AudioContext) {
|
39 |
+
this.gainNode = this.context.createGain();
|
40 |
+
this.source = this.context.createBufferSource();
|
41 |
+
this.gainNode.connect(this.context.destination);
|
42 |
+
this.addPCM16 = this.addPCM16.bind(this);
|
43 |
+
}
|
44 |
+
|
45 |
+
async addWorklet<T extends (d: any) => void>(
|
46 |
+
workletName: string,
|
47 |
+
workletSrc: string,
|
48 |
+
handler: T,
|
49 |
+
): Promise<this> {
|
50 |
+
let workletsRecord = registeredWorklets.get(this.context);
|
51 |
+
if (workletsRecord && workletsRecord[workletName]) {
|
52 |
+
// the worklet already exists on this context
|
53 |
+
// add the new handler to it
|
54 |
+
workletsRecord[workletName].handlers.push(handler);
|
55 |
+
return Promise.resolve(this);
|
56 |
+
//throw new Error(`Worklet ${workletName} already exists on context`);
|
57 |
+
}
|
58 |
+
|
59 |
+
if (!workletsRecord) {
|
60 |
+
registeredWorklets.set(this.context, {});
|
61 |
+
workletsRecord = registeredWorklets.get(this.context)!;
|
62 |
+
}
|
63 |
+
|
64 |
+
// create new record to fill in as becomes available
|
65 |
+
workletsRecord[workletName] = { handlers: [handler] };
|
66 |
+
|
67 |
+
const src = createWorketFromSrc(workletName, workletSrc);
|
68 |
+
await this.context.audioWorklet.addModule(src);
|
69 |
+
const worklet = new AudioWorkletNode(this.context, workletName);
|
70 |
+
|
71 |
+
//add the node into the map
|
72 |
+
workletsRecord[workletName].node = worklet;
|
73 |
+
|
74 |
+
return this;
|
75 |
+
}
|
76 |
+
|
77 |
+
addPCM16(chunk: Uint8Array) {
|
78 |
+
const float32Array = new Float32Array(chunk.length / 2);
|
79 |
+
const dataView = new DataView(chunk.buffer);
|
80 |
+
|
81 |
+
for (let i = 0; i < chunk.length / 2; i++) {
|
82 |
+
try {
|
83 |
+
const int16 = dataView.getInt16(i * 2, true);
|
84 |
+
float32Array[i] = int16 / 32768;
|
85 |
+
} catch (e) {
|
86 |
+
console.error(e);
|
87 |
+
// console.log(
|
88 |
+
// `dataView.length: ${dataView.byteLength}, i * 2: ${i * 2}`,
|
89 |
+
// );
|
90 |
+
}
|
91 |
+
}
|
92 |
+
|
93 |
+
const newBuffer = new Float32Array(
|
94 |
+
this.processingBuffer.length + float32Array.length,
|
95 |
+
);
|
96 |
+
newBuffer.set(this.processingBuffer);
|
97 |
+
newBuffer.set(float32Array, this.processingBuffer.length);
|
98 |
+
this.processingBuffer = newBuffer;
|
99 |
+
|
100 |
+
while (this.processingBuffer.length >= this.bufferSize) {
|
101 |
+
const buffer = this.processingBuffer.slice(0, this.bufferSize);
|
102 |
+
this.audioQueue.push(buffer);
|
103 |
+
this.processingBuffer = this.processingBuffer.slice(this.bufferSize);
|
104 |
+
}
|
105 |
+
|
106 |
+
if (!this.isPlaying) {
|
107 |
+
this.isPlaying = true;
|
108 |
+
// Initialize scheduledTime only when we start playing
|
109 |
+
this.scheduledTime = this.context.currentTime + this.initialBufferTime;
|
110 |
+
this.scheduleNextBuffer();
|
111 |
+
}
|
112 |
+
}
|
113 |
+
|
114 |
+
private createAudioBuffer(audioData: Float32Array): AudioBuffer {
|
115 |
+
const audioBuffer = this.context.createBuffer(
|
116 |
+
1,
|
117 |
+
audioData.length,
|
118 |
+
this.sampleRate,
|
119 |
+
);
|
120 |
+
audioBuffer.getChannelData(0).set(audioData);
|
121 |
+
return audioBuffer;
|
122 |
+
}
|
123 |
+
|
124 |
+
private scheduleNextBuffer() {
|
125 |
+
const SCHEDULE_AHEAD_TIME = 0.2;
|
126 |
+
|
127 |
+
while (
|
128 |
+
this.audioQueue.length > 0 &&
|
129 |
+
this.scheduledTime < this.context.currentTime + SCHEDULE_AHEAD_TIME
|
130 |
+
) {
|
131 |
+
const audioData = this.audioQueue.shift()!;
|
132 |
+
const audioBuffer = this.createAudioBuffer(audioData);
|
133 |
+
const source = this.context.createBufferSource();
|
134 |
+
|
135 |
+
if (this.audioQueue.length === 0) {
|
136 |
+
if (this.endOfQueueAudioSource) {
|
137 |
+
this.endOfQueueAudioSource.onended = null;
|
138 |
+
}
|
139 |
+
this.endOfQueueAudioSource = source;
|
140 |
+
source.onended = () => {
|
141 |
+
if (
|
142 |
+
!this.audioQueue.length &&
|
143 |
+
this.endOfQueueAudioSource === source
|
144 |
+
) {
|
145 |
+
this.endOfQueueAudioSource = null;
|
146 |
+
this.onComplete();
|
147 |
+
}
|
148 |
+
};
|
149 |
+
}
|
150 |
+
|
151 |
+
source.buffer = audioBuffer;
|
152 |
+
source.connect(this.gainNode);
|
153 |
+
|
154 |
+
const worklets = registeredWorklets.get(this.context);
|
155 |
+
|
156 |
+
if (worklets) {
|
157 |
+
Object.entries(worklets).forEach(([workletName, graph]) => {
|
158 |
+
const { node, handlers } = graph;
|
159 |
+
if (node) {
|
160 |
+
source.connect(node);
|
161 |
+
node.port.onmessage = function (ev: MessageEvent) {
|
162 |
+
handlers.forEach((handler) => {
|
163 |
+
handler.call(node.port, ev);
|
164 |
+
});
|
165 |
+
};
|
166 |
+
node.connect(this.context.destination);
|
167 |
+
}
|
168 |
+
});
|
169 |
+
}
|
170 |
+
|
171 |
+
// i added this trying to fix clicks
|
172 |
+
// this.gainNode.gain.setValueAtTime(0, 0);
|
173 |
+
// this.gainNode.gain.linearRampToValueAtTime(1, 1);
|
174 |
+
|
175 |
+
// Ensure we never schedule in the past
|
176 |
+
const startTime = Math.max(this.scheduledTime, this.context.currentTime);
|
177 |
+
source.start(startTime);
|
178 |
+
|
179 |
+
this.scheduledTime = startTime + audioBuffer.duration;
|
180 |
+
}
|
181 |
+
|
182 |
+
if (this.audioQueue.length === 0 && this.processingBuffer.length === 0) {
|
183 |
+
if (this.isStreamComplete) {
|
184 |
+
this.isPlaying = false;
|
185 |
+
if (this.checkInterval) {
|
186 |
+
clearInterval(this.checkInterval);
|
187 |
+
this.checkInterval = null;
|
188 |
+
}
|
189 |
+
} else {
|
190 |
+
if (!this.checkInterval) {
|
191 |
+
this.checkInterval = window.setInterval(() => {
|
192 |
+
if (
|
193 |
+
this.audioQueue.length > 0 ||
|
194 |
+
this.processingBuffer.length >= this.bufferSize
|
195 |
+
) {
|
196 |
+
this.scheduleNextBuffer();
|
197 |
+
}
|
198 |
+
}, 100) as unknown as number;
|
199 |
+
}
|
200 |
+
}
|
201 |
+
} else {
|
202 |
+
const nextCheckTime =
|
203 |
+
(this.scheduledTime - this.context.currentTime) * 1000;
|
204 |
+
setTimeout(
|
205 |
+
() => this.scheduleNextBuffer(),
|
206 |
+
Math.max(0, nextCheckTime - 50),
|
207 |
+
);
|
208 |
+
}
|
209 |
+
}
|
210 |
+
|
211 |
+
stop() {
|
212 |
+
this.isPlaying = false;
|
213 |
+
this.isStreamComplete = true;
|
214 |
+
this.audioQueue = [];
|
215 |
+
this.processingBuffer = new Float32Array(0);
|
216 |
+
this.scheduledTime = this.context.currentTime;
|
217 |
+
|
218 |
+
if (this.checkInterval) {
|
219 |
+
clearInterval(this.checkInterval);
|
220 |
+
this.checkInterval = null;
|
221 |
+
}
|
222 |
+
|
223 |
+
this.gainNode.gain.linearRampToValueAtTime(
|
224 |
+
0,
|
225 |
+
this.context.currentTime + 0.1,
|
226 |
+
);
|
227 |
+
|
228 |
+
setTimeout(() => {
|
229 |
+
this.gainNode.disconnect();
|
230 |
+
this.gainNode = this.context.createGain();
|
231 |
+
this.gainNode.connect(this.context.destination);
|
232 |
+
}, 200);
|
233 |
+
}
|
234 |
+
|
235 |
+
async resume() {
|
236 |
+
if (this.context.state === "suspended") {
|
237 |
+
await this.context.resume();
|
238 |
+
}
|
239 |
+
this.isStreamComplete = false;
|
240 |
+
this.scheduledTime = this.context.currentTime + this.initialBufferTime;
|
241 |
+
this.gainNode.gain.setValueAtTime(1, this.context.currentTime);
|
242 |
+
}
|
243 |
+
|
244 |
+
complete() {
|
245 |
+
this.isStreamComplete = true;
|
246 |
+
if (this.processingBuffer.length > 0) {
|
247 |
+
this.audioQueue.push(this.processingBuffer);
|
248 |
+
this.processingBuffer = new Float32Array(0);
|
249 |
+
if (this.isPlaying) {
|
250 |
+
this.scheduleNextBuffer();
|
251 |
+
}
|
252 |
+
} else {
|
253 |
+
this.onComplete();
|
254 |
+
}
|
255 |
+
}
|
256 |
+
}
|
257 |
+
|
258 |
+
// // Usage example:
|
259 |
+
// const audioStreamer = new AudioStreamer();
|
260 |
+
//
|
261 |
+
// // In your streaming code:
|
262 |
+
// function handleChunk(chunk: Uint8Array) {
|
263 |
+
// audioStreamer.handleChunk(chunk);
|
264 |
+
// }
|
265 |
+
//
|
266 |
+
// // To start playing (call this in response to a user interaction)
|
267 |
+
// await audioStreamer.resume();
|
268 |
+
//
|
269 |
+
// // To stop playing
|
270 |
+
// // audioStreamer.stop();
|
src/lib/audioworklet-registry.ts
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
/**
|
18 |
+
* A registry to map attached worklets by their audio-context
|
19 |
+
* any module using `audioContext.audioWorklet.addModule(` should register the worklet here
|
20 |
+
*/
|
21 |
+
export type WorkletGraph = {
|
22 |
+
node?: AudioWorkletNode;
|
23 |
+
handlers: Array<(this: MessagePort, ev: MessageEvent) => any>;
|
24 |
+
};
|
25 |
+
|
26 |
+
export const registeredWorklets: Map<
|
27 |
+
AudioContext,
|
28 |
+
Record<string, WorkletGraph>
|
29 |
+
> = new Map();
|
30 |
+
|
31 |
+
export const createWorketFromSrc = (
|
32 |
+
workletName: string,
|
33 |
+
workletSrc: string,
|
34 |
+
) => {
|
35 |
+
const script = new Blob(
|
36 |
+
[`registerProcessor("${workletName}", ${workletSrc})`],
|
37 |
+
{
|
38 |
+
type: "application/javascript",
|
39 |
+
},
|
40 |
+
);
|
41 |
+
|
42 |
+
return URL.createObjectURL(script);
|
43 |
+
};
|
src/lib/multimodal-live-client.ts
ADDED
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { Content, GenerativeContentBlob, Part } from "@google/generative-ai";
|
18 |
+
import { EventEmitter } from "eventemitter3";
|
19 |
+
import { difference } from "lodash";
|
20 |
+
import {
|
21 |
+
ClientContentMessage,
|
22 |
+
isInterrupted,
|
23 |
+
isModelTurn,
|
24 |
+
isServerContentMessage,
|
25 |
+
isSetupCompleteMessage,
|
26 |
+
isToolCallCancellationMessage,
|
27 |
+
isToolCallMessage,
|
28 |
+
isTurnComplete,
|
29 |
+
LiveIncomingMessage,
|
30 |
+
ModelTurn,
|
31 |
+
RealtimeInputMessage,
|
32 |
+
ServerContent,
|
33 |
+
SetupMessage,
|
34 |
+
StreamingLog,
|
35 |
+
ToolCall,
|
36 |
+
ToolCallCancellation,
|
37 |
+
ToolResponseMessage,
|
38 |
+
type LiveConfig,
|
39 |
+
} from "../multimodal-live-types";
|
40 |
+
import { blobToJSON, base64ToArrayBuffer } from "./utils";
|
41 |
+
|
42 |
+
/**
|
43 |
+
* the events that this client will emit
|
44 |
+
*/
|
45 |
+
interface MultimodalLiveClientEventTypes {
|
46 |
+
open: () => void;
|
47 |
+
log: (log: StreamingLog) => void;
|
48 |
+
close: (event: CloseEvent) => void;
|
49 |
+
audio: (data: ArrayBuffer) => void;
|
50 |
+
content: (data: ServerContent) => void;
|
51 |
+
interrupted: () => void;
|
52 |
+
setupcomplete: () => void;
|
53 |
+
turncomplete: () => void;
|
54 |
+
toolcall: (toolCall: ToolCall) => void;
|
55 |
+
toolcallcancellation: (toolcallCancellation: ToolCallCancellation) => void;
|
56 |
+
}
|
57 |
+
|
58 |
+
export type MultimodalLiveAPIClientConnection = {
|
59 |
+
url?: string;
|
60 |
+
apiKey: string;
|
61 |
+
};
|
62 |
+
|
63 |
+
/**
|
64 |
+
* A event-emitting class that manages the connection to the websocket and emits
|
65 |
+
* events to the rest of the application.
|
66 |
+
* If you dont want to use react you can still use this.
|
67 |
+
*/
|
68 |
+
export class MultimodalLiveClient extends EventEmitter<MultimodalLiveClientEventTypes> {
|
69 |
+
public ws: WebSocket | null = null;
|
70 |
+
protected config: LiveConfig | null = null;
|
71 |
+
public url: string = "";
|
72 |
+
public getConfig() {
|
73 |
+
return { ...this.config };
|
74 |
+
}
|
75 |
+
|
76 |
+
constructor({ url, apiKey }: MultimodalLiveAPIClientConnection) {
|
77 |
+
super();
|
78 |
+
url =
|
79 |
+
url ||
|
80 |
+
`wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent`;
|
81 |
+
url += `?key=${apiKey}`;
|
82 |
+
this.url = url;
|
83 |
+
this.send = this.send.bind(this);
|
84 |
+
}
|
85 |
+
|
86 |
+
log(type: string, message: StreamingLog["message"]) {
|
87 |
+
const log: StreamingLog = {
|
88 |
+
date: new Date(),
|
89 |
+
type,
|
90 |
+
message,
|
91 |
+
};
|
92 |
+
this.emit("log", log);
|
93 |
+
}
|
94 |
+
|
95 |
+
connect(config: LiveConfig): Promise<boolean> {
|
96 |
+
this.config = config;
|
97 |
+
|
98 |
+
const ws = new WebSocket(this.url);
|
99 |
+
|
100 |
+
ws.addEventListener("message", async (evt: MessageEvent) => {
|
101 |
+
if (evt.data instanceof Blob) {
|
102 |
+
this.receive(evt.data);
|
103 |
+
} else {
|
104 |
+
console.log("non blob message", evt);
|
105 |
+
}
|
106 |
+
});
|
107 |
+
return new Promise((resolve, reject) => {
|
108 |
+
const onError = (ev: Event) => {
|
109 |
+
this.disconnect(ws);
|
110 |
+
const message = `Could not connect to "${this.url}"`;
|
111 |
+
this.log(`server.${ev.type}`, message);
|
112 |
+
reject(new Error(message));
|
113 |
+
};
|
114 |
+
ws.addEventListener("error", onError);
|
115 |
+
ws.addEventListener("open", (ev: Event) => {
|
116 |
+
if (!this.config) {
|
117 |
+
reject("Invalid config sent to `connect(config)`");
|
118 |
+
return;
|
119 |
+
}
|
120 |
+
this.log(`client.${ev.type}`, `connected to socket`);
|
121 |
+
this.emit("open");
|
122 |
+
|
123 |
+
this.ws = ws;
|
124 |
+
|
125 |
+
const setupMessage: SetupMessage = {
|
126 |
+
setup: this.config,
|
127 |
+
};
|
128 |
+
this._sendDirect(setupMessage);
|
129 |
+
this.log("client.send", "setup");
|
130 |
+
|
131 |
+
ws.removeEventListener("error", onError);
|
132 |
+
ws.addEventListener("close", (ev: CloseEvent) => {
|
133 |
+
this.disconnect(ws);
|
134 |
+
let reason = ev.reason || "";
|
135 |
+
if (reason.toLowerCase().includes("error")) {
|
136 |
+
const prelude = "ERROR]";
|
137 |
+
const preludeIndex = reason.indexOf(prelude);
|
138 |
+
if (preludeIndex > 0) {
|
139 |
+
reason = reason.slice(
|
140 |
+
preludeIndex + prelude.length + 1,
|
141 |
+
Infinity
|
142 |
+
);
|
143 |
+
}
|
144 |
+
}
|
145 |
+
this.log(
|
146 |
+
`server.${ev.type}`,
|
147 |
+
`disconnected ${reason ? `with reason: ${reason}` : ``}`
|
148 |
+
);
|
149 |
+
this.emit("close", ev);
|
150 |
+
});
|
151 |
+
resolve(true);
|
152 |
+
});
|
153 |
+
});
|
154 |
+
}
|
155 |
+
|
156 |
+
disconnect(ws?: WebSocket) {
|
157 |
+
// could be that this is an old websocket and theres already a new instance
|
158 |
+
// only close it if its still the correct reference
|
159 |
+
if ((!ws || this.ws === ws) && this.ws) {
|
160 |
+
this.ws.close();
|
161 |
+
this.ws = null;
|
162 |
+
this.log("client.close", `Disconnected`);
|
163 |
+
return true;
|
164 |
+
}
|
165 |
+
return false;
|
166 |
+
}
|
167 |
+
|
168 |
+
protected async receive(blob: Blob) {
|
169 |
+
const response: LiveIncomingMessage = (await blobToJSON(
|
170 |
+
blob
|
171 |
+
)) as LiveIncomingMessage;
|
172 |
+
if (isToolCallMessage(response)) {
|
173 |
+
this.log("server.toolCall", response);
|
174 |
+
this.emit("toolcall", response.toolCall);
|
175 |
+
return;
|
176 |
+
}
|
177 |
+
if (isToolCallCancellationMessage(response)) {
|
178 |
+
this.log("receive.toolCallCancellation", response);
|
179 |
+
this.emit("toolcallcancellation", response.toolCallCancellation);
|
180 |
+
return;
|
181 |
+
}
|
182 |
+
|
183 |
+
if (isSetupCompleteMessage(response)) {
|
184 |
+
this.log("server.send", "setupComplete");
|
185 |
+
this.emit("setupcomplete");
|
186 |
+
return;
|
187 |
+
}
|
188 |
+
|
189 |
+
// this json also might be `contentUpdate { interrupted: true }`
|
190 |
+
// or contentUpdate { end_of_turn: true }
|
191 |
+
if (isServerContentMessage(response)) {
|
192 |
+
const { serverContent } = response;
|
193 |
+
if (isInterrupted(serverContent)) {
|
194 |
+
this.log("receive.serverContent", "interrupted");
|
195 |
+
this.emit("interrupted");
|
196 |
+
return;
|
197 |
+
}
|
198 |
+
if (isTurnComplete(serverContent)) {
|
199 |
+
this.log("server.send", "turnComplete");
|
200 |
+
this.emit("turncomplete");
|
201 |
+
//plausible theres more to the message, continue
|
202 |
+
}
|
203 |
+
|
204 |
+
if (isModelTurn(serverContent)) {
|
205 |
+
let parts: Part[] = serverContent.modelTurn.parts;
|
206 |
+
|
207 |
+
// when its audio that is returned for modelTurn
|
208 |
+
const audioParts = parts.filter(
|
209 |
+
(p) => p.inlineData && p.inlineData.mimeType.startsWith("audio/pcm")
|
210 |
+
);
|
211 |
+
const base64s = audioParts.map((p) => p.inlineData?.data);
|
212 |
+
|
213 |
+
// strip the audio parts out of the modelTurn
|
214 |
+
const otherParts = difference(parts, audioParts);
|
215 |
+
// console.log("otherParts", otherParts);
|
216 |
+
|
217 |
+
base64s.forEach((b64) => {
|
218 |
+
if (b64) {
|
219 |
+
const data = base64ToArrayBuffer(b64);
|
220 |
+
this.emit("audio", data);
|
221 |
+
this.log(`server.audio`, `buffer (${data.byteLength})`);
|
222 |
+
}
|
223 |
+
});
|
224 |
+
if (!otherParts.length) {
|
225 |
+
return;
|
226 |
+
}
|
227 |
+
|
228 |
+
parts = otherParts;
|
229 |
+
|
230 |
+
const content: ModelTurn = { modelTurn: { parts } };
|
231 |
+
this.emit("content", content);
|
232 |
+
this.log(`server.content`, response);
|
233 |
+
}
|
234 |
+
} else {
|
235 |
+
console.log("received unmatched message", response);
|
236 |
+
}
|
237 |
+
}
|
238 |
+
|
239 |
+
/**
|
240 |
+
* send realtimeInput, this is base64 chunks of "audio/pcm" and/or "image/jpg"
|
241 |
+
*/
|
242 |
+
sendRealtimeInput(chunks: GenerativeContentBlob[]) {
|
243 |
+
let hasAudio = false;
|
244 |
+
let hasVideo = false;
|
245 |
+
for (let i = 0; i < chunks.length; i++) {
|
246 |
+
const ch = chunks[i];
|
247 |
+
if (ch.mimeType.includes("audio")) {
|
248 |
+
hasAudio = true;
|
249 |
+
}
|
250 |
+
if (ch.mimeType.includes("image")) {
|
251 |
+
hasVideo = true;
|
252 |
+
}
|
253 |
+
if (hasAudio && hasVideo) {
|
254 |
+
break;
|
255 |
+
}
|
256 |
+
}
|
257 |
+
const message =
|
258 |
+
hasAudio && hasVideo
|
259 |
+
? "audio + video"
|
260 |
+
: hasAudio
|
261 |
+
? "audio"
|
262 |
+
: hasVideo
|
263 |
+
? "video"
|
264 |
+
: "unknown";
|
265 |
+
|
266 |
+
const data: RealtimeInputMessage = {
|
267 |
+
realtimeInput: {
|
268 |
+
mediaChunks: chunks,
|
269 |
+
},
|
270 |
+
};
|
271 |
+
this._sendDirect(data);
|
272 |
+
this.log(`client.realtimeInput`, message);
|
273 |
+
}
|
274 |
+
|
275 |
+
/**
|
276 |
+
* send a response to a function call and provide the id of the functions you are responding to
|
277 |
+
*/
|
278 |
+
sendToolResponse(toolResponse: ToolResponseMessage["toolResponse"]) {
|
279 |
+
const message: ToolResponseMessage = {
|
280 |
+
toolResponse,
|
281 |
+
};
|
282 |
+
|
283 |
+
this._sendDirect(message);
|
284 |
+
this.log(`client.toolResponse`, message);
|
285 |
+
}
|
286 |
+
|
287 |
+
/**
|
288 |
+
* send normal content parts such as { text }
|
289 |
+
*/
|
290 |
+
send(parts: Part | Part[], turnComplete: boolean = true) {
|
291 |
+
parts = Array.isArray(parts) ? parts : [parts];
|
292 |
+
const content: Content = {
|
293 |
+
role: "user",
|
294 |
+
parts,
|
295 |
+
};
|
296 |
+
|
297 |
+
const clientContentRequest: ClientContentMessage = {
|
298 |
+
clientContent: {
|
299 |
+
turns: [content],
|
300 |
+
turnComplete,
|
301 |
+
},
|
302 |
+
};
|
303 |
+
|
304 |
+
this._sendDirect(clientContentRequest);
|
305 |
+
this.log(`client.send`, clientContentRequest);
|
306 |
+
}
|
307 |
+
|
308 |
+
/**
|
309 |
+
* used internally to send all messages
|
310 |
+
* don't use directly unless trying to send an unsupported message type
|
311 |
+
*/
|
312 |
+
_sendDirect(request: object) {
|
313 |
+
if (!this.ws) {
|
314 |
+
throw new Error("WebSocket is not connected");
|
315 |
+
}
|
316 |
+
const str = JSON.stringify(request);
|
317 |
+
this.ws.send(str);
|
318 |
+
}
|
319 |
+
}
|
src/lib/store-logger.ts
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { create } from "zustand";
|
18 |
+
import { StreamingLog } from "../multimodal-live-types";
|
19 |
+
import { mockLogs } from "../components/logger/mock-logs";
|
20 |
+
|
21 |
+
interface StoreLoggerState {
|
22 |
+
maxLogs: number;
|
23 |
+
logs: StreamingLog[];
|
24 |
+
log: (streamingLog: StreamingLog) => void;
|
25 |
+
clearLogs: () => void;
|
26 |
+
}
|
27 |
+
|
28 |
+
export const useLoggerStore = create<StoreLoggerState>((set, get) => ({
|
29 |
+
maxLogs: 500,
|
30 |
+
logs: [], //mockLogs,
|
31 |
+
log: ({ date, type, message }: StreamingLog) => {
|
32 |
+
set((state) => {
|
33 |
+
const prevLog = state.logs.at(-1);
|
34 |
+
if (prevLog && prevLog.type === type && prevLog.message === message) {
|
35 |
+
return {
|
36 |
+
logs: [
|
37 |
+
...state.logs.slice(0, -1),
|
38 |
+
{
|
39 |
+
date,
|
40 |
+
type,
|
41 |
+
message,
|
42 |
+
count: prevLog.count ? prevLog.count + 1 : 1,
|
43 |
+
} as StreamingLog,
|
44 |
+
],
|
45 |
+
};
|
46 |
+
}
|
47 |
+
return {
|
48 |
+
logs: [
|
49 |
+
...state.logs.slice(-(get().maxLogs - 1)),
|
50 |
+
{
|
51 |
+
date,
|
52 |
+
type,
|
53 |
+
message,
|
54 |
+
} as StreamingLog,
|
55 |
+
],
|
56 |
+
};
|
57 |
+
});
|
58 |
+
},
|
59 |
+
|
60 |
+
clearLogs: () => {
|
61 |
+
console.log("clear log");
|
62 |
+
set({ logs: [] });
|
63 |
+
},
|
64 |
+
setMaxLogs: (n: number) => set({ maxLogs: n }),
|
65 |
+
}));
|
src/lib/utils.ts
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
export type GetAudioContextOptions = AudioContextOptions & {
|
18 |
+
id?: string;
|
19 |
+
};
|
20 |
+
|
21 |
+
const map: Map<string, AudioContext> = new Map();
|
22 |
+
|
23 |
+
export const audioContext: (
|
24 |
+
options?: GetAudioContextOptions,
|
25 |
+
) => Promise<AudioContext> = (() => {
|
26 |
+
const didInteract = new Promise((res) => {
|
27 |
+
window.addEventListener("pointerdown", res, { once: true });
|
28 |
+
window.addEventListener("keydown", res, { once: true });
|
29 |
+
});
|
30 |
+
|
31 |
+
return async (options?: GetAudioContextOptions) => {
|
32 |
+
try {
|
33 |
+
const a = new Audio();
|
34 |
+
a.src =
|
35 |
+
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA";
|
36 |
+
await a.play();
|
37 |
+
if (options?.id && map.has(options.id)) {
|
38 |
+
const ctx = map.get(options.id);
|
39 |
+
if (ctx) {
|
40 |
+
return ctx;
|
41 |
+
}
|
42 |
+
}
|
43 |
+
const ctx = new AudioContext(options);
|
44 |
+
if (options?.id) {
|
45 |
+
map.set(options.id, ctx);
|
46 |
+
}
|
47 |
+
return ctx;
|
48 |
+
} catch (e) {
|
49 |
+
await didInteract;
|
50 |
+
if (options?.id && map.has(options.id)) {
|
51 |
+
const ctx = map.get(options.id);
|
52 |
+
if (ctx) {
|
53 |
+
return ctx;
|
54 |
+
}
|
55 |
+
}
|
56 |
+
const ctx = new AudioContext(options);
|
57 |
+
if (options?.id) {
|
58 |
+
map.set(options.id, ctx);
|
59 |
+
}
|
60 |
+
return ctx;
|
61 |
+
}
|
62 |
+
};
|
63 |
+
})();
|
64 |
+
|
65 |
+
export const blobToJSON = (blob: Blob) =>
|
66 |
+
new Promise((resolve, reject) => {
|
67 |
+
const reader = new FileReader();
|
68 |
+
reader.onload = () => {
|
69 |
+
if (reader.result) {
|
70 |
+
const json = JSON.parse(reader.result as string);
|
71 |
+
resolve(json);
|
72 |
+
} else {
|
73 |
+
reject("oops");
|
74 |
+
}
|
75 |
+
};
|
76 |
+
reader.readAsText(blob);
|
77 |
+
});
|
78 |
+
|
79 |
+
export function base64ToArrayBuffer(base64: string) {
|
80 |
+
var binaryString = atob(base64);
|
81 |
+
var bytes = new Uint8Array(binaryString.length);
|
82 |
+
for (let i = 0; i < binaryString.length; i++) {
|
83 |
+
bytes[i] = binaryString.charCodeAt(i);
|
84 |
+
}
|
85 |
+
return bytes.buffer;
|
86 |
+
}
|
src/lib/worklets/audio-processing.ts
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
const AudioRecordingWorklet = `
|
18 |
+
class AudioProcessingWorklet extends AudioWorkletProcessor {
|
19 |
+
|
20 |
+
// send and clear buffer every 2048 samples,
|
21 |
+
// which at 16khz is about 8 times a second
|
22 |
+
buffer = new Int16Array(2048);
|
23 |
+
|
24 |
+
// current write index
|
25 |
+
bufferWriteIndex = 0;
|
26 |
+
|
27 |
+
constructor() {
|
28 |
+
super();
|
29 |
+
this.hasAudio = false;
|
30 |
+
}
|
31 |
+
|
32 |
+
/**
|
33 |
+
* @param inputs Float32Array[][] [input#][channel#][sample#] so to access first inputs 1st channel inputs[0][0]
|
34 |
+
* @param outputs Float32Array[][]
|
35 |
+
*/
|
36 |
+
process(inputs) {
|
37 |
+
if (inputs[0].length) {
|
38 |
+
const channel0 = inputs[0][0];
|
39 |
+
this.processChunk(channel0);
|
40 |
+
}
|
41 |
+
return true;
|
42 |
+
}
|
43 |
+
|
44 |
+
sendAndClearBuffer(){
|
45 |
+
this.port.postMessage({
|
46 |
+
event: "chunk",
|
47 |
+
data: {
|
48 |
+
int16arrayBuffer: this.buffer.slice(0, this.bufferWriteIndex).buffer,
|
49 |
+
},
|
50 |
+
});
|
51 |
+
this.bufferWriteIndex = 0;
|
52 |
+
}
|
53 |
+
|
54 |
+
processChunk(float32Array) {
|
55 |
+
const l = float32Array.length;
|
56 |
+
|
57 |
+
for (let i = 0; i < l; i++) {
|
58 |
+
// convert float32 -1 to 1 to int16 -32768 to 32767
|
59 |
+
const int16Value = float32Array[i] * 32768;
|
60 |
+
this.buffer[this.bufferWriteIndex++] = int16Value;
|
61 |
+
if(this.bufferWriteIndex >= this.buffer.length) {
|
62 |
+
this.sendAndClearBuffer();
|
63 |
+
}
|
64 |
+
}
|
65 |
+
|
66 |
+
if(this.bufferWriteIndex >= this.buffer.length) {
|
67 |
+
this.sendAndClearBuffer();
|
68 |
+
}
|
69 |
+
}
|
70 |
+
}
|
71 |
+
`;
|
72 |
+
|
73 |
+
export default AudioRecordingWorklet;
|
src/lib/worklets/vol-meter.ts
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
const VolMeterWorket = `
|
18 |
+
class VolMeter extends AudioWorkletProcessor {
|
19 |
+
volume
|
20 |
+
updateIntervalInMS
|
21 |
+
nextUpdateFrame
|
22 |
+
|
23 |
+
constructor() {
|
24 |
+
super()
|
25 |
+
this.volume = 0
|
26 |
+
this.updateIntervalInMS = 25
|
27 |
+
this.nextUpdateFrame = this.updateIntervalInMS
|
28 |
+
this.port.onmessage = event => {
|
29 |
+
if (event.data.updateIntervalInMS) {
|
30 |
+
this.updateIntervalInMS = event.data.updateIntervalInMS
|
31 |
+
}
|
32 |
+
}
|
33 |
+
}
|
34 |
+
|
35 |
+
get intervalInFrames() {
|
36 |
+
return (this.updateIntervalInMS / 1000) * sampleRate
|
37 |
+
}
|
38 |
+
|
39 |
+
process(inputs) {
|
40 |
+
const input = inputs[0]
|
41 |
+
|
42 |
+
if (input.length > 0) {
|
43 |
+
const samples = input[0]
|
44 |
+
let sum = 0
|
45 |
+
let rms = 0
|
46 |
+
|
47 |
+
for (let i = 0; i < samples.length; ++i) {
|
48 |
+
sum += samples[i] * samples[i]
|
49 |
+
}
|
50 |
+
|
51 |
+
rms = Math.sqrt(sum / samples.length)
|
52 |
+
this.volume = Math.max(rms, this.volume * 0.7)
|
53 |
+
|
54 |
+
this.nextUpdateFrame -= samples.length
|
55 |
+
if (this.nextUpdateFrame < 0) {
|
56 |
+
this.nextUpdateFrame += this.intervalInFrames
|
57 |
+
this.port.postMessage({volume: this.volume})
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
return true
|
62 |
+
}
|
63 |
+
}`;
|
64 |
+
|
65 |
+
export default VolMeterWorket;
|
src/multimodal-live-types.ts
ADDED
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import type {
|
18 |
+
Content,
|
19 |
+
FunctionCall,
|
20 |
+
GenerationConfig,
|
21 |
+
GenerativeContentBlob,
|
22 |
+
Part,
|
23 |
+
Tool,
|
24 |
+
} from "@google/generative-ai";
|
25 |
+
|
26 |
+
/**
|
27 |
+
* this module contains type-definitions and Type-Guards
|
28 |
+
*/
|
29 |
+
|
30 |
+
// Type-definitions
|
31 |
+
|
32 |
+
/* outgoing types */
|
33 |
+
|
34 |
+
/**
|
35 |
+
* the config to initiate the session
|
36 |
+
*/
|
37 |
+
export type LiveConfig = {
|
38 |
+
model: string;
|
39 |
+
systemInstruction?: { parts: Part[] };
|
40 |
+
generationConfig?: Partial<LiveGenerationConfig>;
|
41 |
+
tools?: Array<Tool | { googleSearch: {} } | { codeExecution: {} }>;
|
42 |
+
};
|
43 |
+
|
44 |
+
export type LiveGenerationConfig = GenerationConfig & {
|
45 |
+
responseModalities: "text" | "audio" | "image";
|
46 |
+
speechConfig?: {
|
47 |
+
voiceConfig?: {
|
48 |
+
prebuiltVoiceConfig?: {
|
49 |
+
voiceName: "Puck" | "Charon" | "Kore" | "Fenrir" | "Aoede" | string;
|
50 |
+
};
|
51 |
+
};
|
52 |
+
};
|
53 |
+
};
|
54 |
+
|
55 |
+
export type LiveOutgoingMessage =
|
56 |
+
| SetupMessage
|
57 |
+
| ClientContentMessage
|
58 |
+
| RealtimeInputMessage
|
59 |
+
| ToolResponseMessage;
|
60 |
+
|
61 |
+
export type SetupMessage = {
|
62 |
+
setup: LiveConfig;
|
63 |
+
};
|
64 |
+
|
65 |
+
export type ClientContentMessage = {
|
66 |
+
clientContent: {
|
67 |
+
turns: Content[];
|
68 |
+
turnComplete: boolean;
|
69 |
+
};
|
70 |
+
};
|
71 |
+
|
72 |
+
export type RealtimeInputMessage = {
|
73 |
+
realtimeInput: {
|
74 |
+
mediaChunks: GenerativeContentBlob[];
|
75 |
+
};
|
76 |
+
};
|
77 |
+
|
78 |
+
export type ToolResponseMessage = {
|
79 |
+
toolResponse: {
|
80 |
+
functionResponses: LiveFunctionResponse[];
|
81 |
+
};
|
82 |
+
};
|
83 |
+
|
84 |
+
export type ToolResponse = ToolResponseMessage["toolResponse"];
|
85 |
+
|
86 |
+
export type LiveFunctionResponse = {
|
87 |
+
response: object;
|
88 |
+
id: string;
|
89 |
+
};
|
90 |
+
|
91 |
+
/** Incoming types */
|
92 |
+
|
93 |
+
export type LiveIncomingMessage =
|
94 |
+
| ToolCallCancellationMessage
|
95 |
+
| ToolCallMessage
|
96 |
+
| ServerContentMessage
|
97 |
+
| SetupCompleteMessage;
|
98 |
+
|
99 |
+
export type SetupCompleteMessage = { setupComplete: {} };
|
100 |
+
|
101 |
+
export type ServerContentMessage = {
|
102 |
+
serverContent: ServerContent;
|
103 |
+
};
|
104 |
+
|
105 |
+
export type ServerContent = ModelTurn | TurnComplete | Interrupted;
|
106 |
+
|
107 |
+
export type ModelTurn = {
|
108 |
+
modelTurn: {
|
109 |
+
parts: Part[];
|
110 |
+
};
|
111 |
+
};
|
112 |
+
|
113 |
+
export type TurnComplete = { turnComplete: boolean };
|
114 |
+
|
115 |
+
export type Interrupted = { interrupted: true };
|
116 |
+
|
117 |
+
export type ToolCallCancellationMessage = {
|
118 |
+
toolCallCancellation: {
|
119 |
+
ids: string[];
|
120 |
+
};
|
121 |
+
};
|
122 |
+
|
123 |
+
export type ToolCallCancellation =
|
124 |
+
ToolCallCancellationMessage["toolCallCancellation"];
|
125 |
+
|
126 |
+
export type ToolCallMessage = {
|
127 |
+
toolCall: ToolCall;
|
128 |
+
};
|
129 |
+
|
130 |
+
export type LiveFunctionCall = FunctionCall & {
|
131 |
+
id: string;
|
132 |
+
};
|
133 |
+
|
134 |
+
/**
|
135 |
+
* A `toolCall` message
|
136 |
+
*/
|
137 |
+
export type ToolCall = {
|
138 |
+
functionCalls: LiveFunctionCall[];
|
139 |
+
};
|
140 |
+
|
141 |
+
/** log types */
|
142 |
+
export type StreamingLog = {
|
143 |
+
date: Date;
|
144 |
+
type: string;
|
145 |
+
count?: number;
|
146 |
+
message: string | LiveOutgoingMessage | LiveIncomingMessage;
|
147 |
+
};
|
148 |
+
|
149 |
+
// Type-Guards
|
150 |
+
|
151 |
+
const prop = (a: any, prop: string, kind: string = "object") =>
|
152 |
+
typeof a === "object" && typeof a[prop] === "object";
|
153 |
+
|
154 |
+
// outgoing messages
|
155 |
+
export const isSetupMessage = (a: unknown): a is SetupMessage =>
|
156 |
+
prop(a, "setup");
|
157 |
+
|
158 |
+
export const isClientContentMessage = (a: unknown): a is ClientContentMessage =>
|
159 |
+
prop(a, "clientContent");
|
160 |
+
|
161 |
+
export const isRealtimeInputMessage = (a: unknown): a is RealtimeInputMessage =>
|
162 |
+
prop(a, "realtimeInput");
|
163 |
+
|
164 |
+
export const isToolResponseMessage = (a: unknown): a is ToolResponseMessage =>
|
165 |
+
prop(a, "toolResponse");
|
166 |
+
|
167 |
+
// incoming messages
|
168 |
+
export const isSetupCompleteMessage = (a: unknown): a is SetupCompleteMessage =>
|
169 |
+
prop(a, "setupComplete");
|
170 |
+
|
171 |
+
export const isServerContentMessage = (a: any): a is ServerContentMessage =>
|
172 |
+
prop(a, "serverContent");
|
173 |
+
|
174 |
+
export const isToolCallMessage = (a: any): a is ToolCallMessage =>
|
175 |
+
prop(a, "toolCall");
|
176 |
+
|
177 |
+
export const isToolCallCancellationMessage = (
|
178 |
+
a: unknown,
|
179 |
+
): a is ToolCallCancellationMessage =>
|
180 |
+
prop(a, "toolCallCancellation") &&
|
181 |
+
isToolCallCancellation((a as any).toolCallCancellation);
|
182 |
+
|
183 |
+
export const isModelTurn = (a: any): a is ModelTurn =>
|
184 |
+
typeof (a as ModelTurn).modelTurn === "object";
|
185 |
+
|
186 |
+
export const isTurnComplete = (a: any): a is TurnComplete =>
|
187 |
+
typeof (a as TurnComplete).turnComplete === "boolean";
|
188 |
+
|
189 |
+
export const isInterrupted = (a: any): a is Interrupted =>
|
190 |
+
(a as Interrupted).interrupted;
|
191 |
+
|
192 |
+
export function isToolCall(value: unknown): value is ToolCall {
|
193 |
+
if (!value || typeof value !== "object") return false;
|
194 |
+
|
195 |
+
const candidate = value as Record<string, unknown>;
|
196 |
+
|
197 |
+
return (
|
198 |
+
Array.isArray(candidate.functionCalls) &&
|
199 |
+
candidate.functionCalls.every((call) => isLiveFunctionCall(call))
|
200 |
+
);
|
201 |
+
}
|
202 |
+
|
203 |
+
export function isToolResponse(value: unknown): value is ToolResponse {
|
204 |
+
if (!value || typeof value !== "object") return false;
|
205 |
+
|
206 |
+
const candidate = value as Record<string, unknown>;
|
207 |
+
|
208 |
+
return (
|
209 |
+
Array.isArray(candidate.functionResponses) &&
|
210 |
+
candidate.functionResponses.every((resp) => isLiveFunctionResponse(resp))
|
211 |
+
);
|
212 |
+
}
|
213 |
+
|
214 |
+
export function isLiveFunctionCall(value: unknown): value is LiveFunctionCall {
|
215 |
+
if (!value || typeof value !== "object") return false;
|
216 |
+
|
217 |
+
const candidate = value as Record<string, unknown>;
|
218 |
+
|
219 |
+
return (
|
220 |
+
typeof candidate.name === "string" &&
|
221 |
+
typeof candidate.id === "string" &&
|
222 |
+
typeof candidate.args === "object" &&
|
223 |
+
candidate.args !== null
|
224 |
+
);
|
225 |
+
}
|
226 |
+
|
227 |
+
export function isLiveFunctionResponse(
|
228 |
+
value: unknown,
|
229 |
+
): value is LiveFunctionResponse {
|
230 |
+
if (!value || typeof value !== "object") return false;
|
231 |
+
|
232 |
+
const candidate = value as Record<string, unknown>;
|
233 |
+
|
234 |
+
return (
|
235 |
+
typeof candidate.response === "object" && typeof candidate.id === "string"
|
236 |
+
);
|
237 |
+
}
|
238 |
+
|
239 |
+
export const isToolCallCancellation = (
|
240 |
+
a: unknown,
|
241 |
+
): a is ToolCallCancellationMessage["toolCallCancellation"] =>
|
242 |
+
typeof a === "object" && Array.isArray((a as any).ids);
|
src/react-app-env.d.ts
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
/// <reference types="react-scripts" />
|
src/reportWebVitals.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2024 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
import { ReportHandler } from 'web-vitals';
|
18 |
+
|
19 |
+
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
20 |
+
if (onPerfEntry && onPerfEntry instanceof Function) {
|
21 |
+
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
22 |
+
getCLS(onPerfEntry);
|
23 |
+
getFID(onPerfEntry);
|
24 |
+
getFCP(onPerfEntry);
|
25 |
+
getLCP(onPerfEntry);
|
26 |
+
getTTFB(onPerfEntry);
|
27 |
+
});
|
28 |
+
}
|
29 |
+
};
|
30 |
+
|
31 |
+
export default reportWebVitals;
|