@hanzo/rooms 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Chat.js +24 -22
- package/dist/Meet.js +91 -148
- package/dist/cast.d.ts +17 -14
- package/dist/cast.js +39 -49
- package/dist/lib/limits.d.ts +9 -0
- package/dist/lib/limits.js +6 -0
- package/dist/speech.d.ts +23 -1
- package/dist/speech.js +70 -79
- package/package.json +6 -6
package/dist/Chat.js
CHANGED
|
@@ -35,14 +35,14 @@ import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
|
|
|
35
35
|
import { ASK, Chat as ChatSurface, Code, Failure, Parts, words } from "@hanzo/ui/chat";
|
|
36
36
|
import { compose, hold, images, partsOf } from "./lib/attach.js";
|
|
37
37
|
import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
|
|
38
|
-
import { SpeakButton,
|
|
38
|
+
import { SpeakButton, cleanForSpeech } from "./speech.js";
|
|
39
39
|
import { Crew, HOUSE, useCrew } from "./crew.js";
|
|
40
40
|
import { called } from "./team.js";
|
|
41
41
|
import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
|
|
42
42
|
import { Control } from "@hanzo/composer";
|
|
43
43
|
import { useIam, useIamToken } from "@hanzo/iam/react";
|
|
44
44
|
import { hasSession, org } from "./lib/session.js";
|
|
45
|
-
import { clock, sku, useLimits } from "./lib/limits.js";
|
|
45
|
+
import { chats, clock, sku, useLimits } from "./lib/limits.js";
|
|
46
46
|
import { Meters } from "./meters.js";
|
|
47
47
|
import { useHydrated } from "./lib/hydrated.js";
|
|
48
48
|
import { base, served } from "./lib/ai.js";
|
|
@@ -431,7 +431,11 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
431
431
|
// bare "not found", after it has already recorded the question. `served`
|
|
432
432
|
// keeps the house name where it is served and otherwise falls to the first
|
|
433
433
|
// model that is, so a room on any catalogue can be spoken to.
|
|
434
|
-
|
|
434
|
+
// A SPEECH MODEL NEVER ANSWERS A CHAT TURN. The catalog lists the
|
|
435
|
+
// transcriber and the voice beside the chat models; one that names its
|
|
436
|
+
// outputs and names no text (`chats`) is neither offered nor fallen back to.
|
|
437
|
+
const { models: catalog } = useModels();
|
|
438
|
+
const models = useMemo(() => catalog.filter(chats), [catalog]);
|
|
435
439
|
// HANZO MODELS ALONE are offered here, to everyone: an Enso or Zen id
|
|
436
440
|
// (`sku`). A preference kept from before for any other model is not one this
|
|
437
441
|
// room offers, so the house router is asked for instead, and `served` keeps
|
|
@@ -615,42 +619,40 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
615
619
|
setFileSaid(refused.length ? refused.join(" ") : null);
|
|
616
620
|
}, []);
|
|
617
621
|
useSeed(initial, setDraft);
|
|
618
|
-
// DICTATION
|
|
619
|
-
// to
|
|
622
|
+
// DICTATION through Hanzo's own ear: the mic records and each pause is
|
|
623
|
+
// posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
|
|
624
|
+
// the Hanzo gateway and `base()` is the one place a host is read from, so
|
|
620
625
|
// pointing a local cloud at one points both. The token is the reader's own —
|
|
621
|
-
// the package never invents a credential — and
|
|
622
|
-
//
|
|
626
|
+
// the package never invents a credential — and a refusal is worn by
|
|
627
|
+
// `<Voice/>`, never passed off as a transcript.
|
|
623
628
|
const { token } = useIamToken();
|
|
624
|
-
const
|
|
625
|
-
const ear = useMemo(() => speech({
|
|
626
|
-
...base(),
|
|
627
|
-
...(token ? { token } : {}),
|
|
628
|
-
voice: { name: agentVoice },
|
|
629
|
-
}), [token, agentVoice]);
|
|
629
|
+
const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
|
|
630
630
|
const voice = useVoice({
|
|
631
631
|
speech: ear,
|
|
632
632
|
onPartial: setDraft,
|
|
633
633
|
onUtterance: setDraft,
|
|
634
634
|
});
|
|
635
|
-
//
|
|
635
|
+
// WHILE THE MIC IS OPEN, A FINISHED REPLY IS READ ALOUD by the platform's
|
|
636
|
+
// voice, through the conversation's own mouth so speaking over it stops it.
|
|
637
|
+
// A room's reply is read part by part, each in its speaker's cast voice.
|
|
636
638
|
const wasStreaming = useRef(false);
|
|
637
639
|
useEffect(() => {
|
|
638
640
|
if (wasStreaming.current && !chat.streaming && voice.open) {
|
|
639
641
|
const msgs = chat.messages;
|
|
640
642
|
const lastMsg = msgs[msgs.length - 1];
|
|
641
643
|
if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
|
|
642
|
-
// A room's reply is read part by part, each in its speaker's voice.
|
|
643
644
|
const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
645
|
+
void (async () => {
|
|
646
|
+
for (const part of parts) {
|
|
647
|
+
const said = cleanForSpeech(part.text);
|
|
648
|
+
if (said)
|
|
649
|
+
await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined));
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
650
652
|
}
|
|
651
653
|
}
|
|
652
654
|
wasStreaming.current = chat.streaming;
|
|
653
|
-
}, [chat.streaming, voice.open, chat.messages, withWhom, room]);
|
|
655
|
+
}, [chat.streaming, voice.open, voice.say, chat.messages, withWhom, room]);
|
|
654
656
|
// A SEND THAT WAITS ONE RENDER. `send` closes over the turns it was rendered
|
|
655
657
|
// with, so a send that first tidies the thread is held here and made from the
|
|
656
658
|
// next render, where the tidied turns are the ones it sends.
|
package/dist/Meet.js
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
4
|
import { Video, VideoOff, Mic, MicOff, PhoneOff, Copy, Check, Plus, Calendar, ExternalLink, Bot, Volume2, Square, Send, UserPlus, Tv, LayoutGrid, ArrowRight, } from 'lucide-react';
|
|
5
5
|
import { Text, View, XStack, YStack } from '@hanzo/gui';
|
|
6
6
|
import { Button } from '@hanzo/ui';
|
|
7
|
+
import { useIamToken } from '@hanzo/iam/react';
|
|
8
|
+
import { REFUSED, speech, useVoice } from '@hanzo/voice';
|
|
7
9
|
import { Face, voiceOf } from './cast.js';
|
|
8
10
|
import { member } from './team.js';
|
|
9
|
-
import { speakAgent, stopAgentSpeech, AudioWave, useAgentSpeech } from './speech.js';
|
|
11
|
+
import { cleanForSpeech, speakAgent, stopAgentSpeech, AudioWave, useAgentSpeech } from './speech.js';
|
|
10
12
|
import { GOOD } from './lib/mix.js';
|
|
13
|
+
import { api } from './lib/api.js';
|
|
14
|
+
import { ENSO, base } from './lib/ai.js';
|
|
11
15
|
/** A core member as a meeting seats them: the team's name and part, the cast's voice. */
|
|
12
16
|
const seated = (id) => {
|
|
13
17
|
const one = member(id);
|
|
14
18
|
return { id, name: one.name, role: one.role, voice: voiceOf(id) };
|
|
15
19
|
};
|
|
16
20
|
export const AVAILABLE_MEETING_AGENTS = [
|
|
17
|
-
{ id: 'hanzo-coder', name: 'Hanzo Coder', role: 'Engineering Lead & Coder', voice: '
|
|
18
|
-
{ id: 'hanzo-researcher', name: 'Hanzo Researcher', role: 'Research & DeSci Scientist', voice: '
|
|
21
|
+
{ id: 'hanzo-coder', name: 'Hanzo Coder', role: 'Engineering Lead & Coder', voice: voiceOf('Hanzo Coder') },
|
|
22
|
+
{ id: 'hanzo-researcher', name: 'Hanzo Researcher', role: 'Research & DeSci Scientist', voice: voiceOf('Hanzo Researcher') },
|
|
19
23
|
seated('maya'),
|
|
20
24
|
seated('des'),
|
|
21
25
|
seated('vi'),
|
|
22
26
|
seated('nora'),
|
|
23
27
|
seated('einstein'),
|
|
24
|
-
{ id: 'zach', name: 'Zach', role: 'Quantitative Analyst', voice: '
|
|
28
|
+
{ id: 'zach', name: 'Zach', role: 'Quantitative Analyst', voice: voiceOf('zach') },
|
|
25
29
|
];
|
|
26
30
|
const DEFAULT_MEETINGS = [
|
|
27
31
|
{
|
|
@@ -64,65 +68,37 @@ const DEFAULT_MEETINGS = [
|
|
|
64
68
|
url: 'https://meet.hanzo.ai/security-sync',
|
|
65
69
|
},
|
|
66
70
|
];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The co-pilot's answer to one thing said in the call, in a sentence or two a
|
|
73
|
+
* voice can read. Asked of the gateway as the reader (`token`, from
|
|
74
|
+
* `useIamToken`) through the one API host; a refusal is thrown with what the
|
|
75
|
+
* gateway said, never answered with a line nobody wrote.
|
|
76
|
+
*/
|
|
77
|
+
async function generateAgentMeetingResponse(prompt, agentName, token) {
|
|
78
|
+
if (!token)
|
|
79
|
+
throw new Error('Sign in to ask the co-pilot.');
|
|
80
|
+
const res = await fetch(`${api()}/v1/chat/completions`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
model: ENSO,
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
role: 'system',
|
|
88
|
+
content: `You are ${agentName}, participating in a live Hanzo Meet video call. Respond succinctly, conversationally, and informatively in 1 to 2 sentences suitable for voice synthesis.`,
|
|
76
89
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
max_tokens: 150,
|
|
90
|
-
}),
|
|
91
|
-
});
|
|
92
|
-
if (res.ok) {
|
|
93
|
-
const data = await res.json();
|
|
94
|
-
const text = data.choices?.[0]?.message?.content?.trim();
|
|
95
|
-
if (text)
|
|
96
|
-
return text;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch { }
|
|
100
|
-
}
|
|
101
|
-
// Domain-specific intelligent persona responses
|
|
102
|
-
const lower = prompt.toLowerCase();
|
|
103
|
-
const agent = agentName.toLowerCase();
|
|
104
|
-
if (agent.includes('coder')) {
|
|
105
|
-
if (lower.includes('audio') || lower.includes('webrtc') || lower.includes('media')) {
|
|
106
|
-
return 'The WebRTC audio track is streaming over LiveKit SFU at 24kHz with PCM capture and real-time turn-taking.';
|
|
107
|
-
}
|
|
108
|
-
if (lower.includes('deploy') || lower.includes('build') || lower.includes('status')) {
|
|
109
|
-
return 'All services and container builds are verified green across Kubernetes pods with zero regressions.';
|
|
110
|
-
}
|
|
111
|
-
return `As Hanzo Coder, I am tracking that: our engineering pipeline is synchronized and ready for production deployment.`;
|
|
112
|
-
}
|
|
113
|
-
if (agent.includes('researcher')) {
|
|
114
|
-
return `From our DeSci research benchmarks, model convergence and validation loss show optimal scaling across the cluster.`;
|
|
115
|
-
}
|
|
116
|
-
if (agent.includes('maya')) {
|
|
117
|
-
return `I have recorded this decision and scheduled the follow-up milestone in our calendar.`;
|
|
118
|
-
}
|
|
119
|
-
if (agent.includes('des')) {
|
|
120
|
-
return `The UI hierarchy, contrast ratios, and layout flow are optimized for clarity across both desktop and mobile.`;
|
|
121
|
-
}
|
|
122
|
-
if (agent.includes('vi')) {
|
|
123
|
-
return `Cluster health, P99 response latencies, and auto-scaling ingress controllers are reporting 100% operational.`;
|
|
124
|
-
}
|
|
125
|
-
return `Understood. I have logged that item for our meeting sync and will monitor the execution.`;
|
|
90
|
+
{ role: 'user', content: prompt },
|
|
91
|
+
],
|
|
92
|
+
max_tokens: 150,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
throw new Error(`The co-pilot is unavailable (${res.status}).`);
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
99
|
+
if (!text)
|
|
100
|
+
throw new Error('The co-pilot answered with nothing.');
|
|
101
|
+
return text;
|
|
126
102
|
}
|
|
127
103
|
/** design's smallest rung (`--text-floor`, 10px), below gui's `$1`; gui's font size takes a rung or a number. */
|
|
128
104
|
const FLOOR = 10;
|
|
@@ -143,16 +119,11 @@ export function Meet() {
|
|
|
143
119
|
const [meetingView, setMeetingView] = useState('iframe');
|
|
144
120
|
const [agentQuestion, setAgentQuestion] = useState('');
|
|
145
121
|
// Agent Speech in Call
|
|
122
|
+
const { token } = useIamToken();
|
|
146
123
|
const { speaking, speak, toggle } = useAgentSpeech(activeCoPilotAgent);
|
|
147
124
|
const [audioTesting, setAudioTesting] = useState(false);
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
const [liveNotes, setLiveNotes] = useState([
|
|
151
|
-
'18:02: Meeting started with participants. AI Bot transcription active.',
|
|
152
|
-
'18:04: Discussed latency metrics on local S3 drive server (port 9005). Average response: 1.2ms.',
|
|
153
|
-
'18:08: Agreed on channel connector architecture: channels connect via bot instances exclusively.',
|
|
154
|
-
'18:12: Action Item assigned to Hanzo Coder: Integrate Cal.com scheduling engine across bot triggers.',
|
|
155
|
-
]);
|
|
125
|
+
// The call's notes: what was said and answered here, and nothing before it.
|
|
126
|
+
const [liveNotes, setLiveNotes] = useState([]);
|
|
156
127
|
const videoRef = useRef(null);
|
|
157
128
|
const localStreamRef = useRef(null);
|
|
158
129
|
useEffect(() => {
|
|
@@ -230,80 +201,37 @@ export function Meet() {
|
|
|
230
201
|
return;
|
|
231
202
|
}
|
|
232
203
|
setAudioTesting(true);
|
|
233
|
-
speakAgent(`Welcome to Hanzo Meet.
|
|
204
|
+
speakAgent(`Welcome to Hanzo Meet. This is ${activeCoPilotAgent}'s voice. If you can hear this, your speakers are working.`, {
|
|
234
205
|
agent: activeCoPilotAgent,
|
|
206
|
+
token,
|
|
235
207
|
onEnd: () => setAudioTesting(false),
|
|
236
208
|
onError: () => setAudioTesting(false),
|
|
237
209
|
});
|
|
238
210
|
};
|
|
211
|
+
// HANDS-FREE is one spoken conversation with the co-pilot, on @hanzo/voice:
|
|
212
|
+
// the mic records, each pause goes to Hanzo's /v1/audio/transcriptions, the
|
|
213
|
+
// utterance is answered, and the answer is read in the co-pilot's cast voice
|
|
214
|
+
// by /v1/audio/speech. Speaking over the reply stops it (the machine's
|
|
215
|
+
// barge-in). Every browser that can record takes part, not only the ones with
|
|
216
|
+
// a working SpeechRecognition — Chromium on Linux and Firefox have none.
|
|
217
|
+
const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
|
|
218
|
+
const handsFree = useVoice({
|
|
219
|
+
speech: ear,
|
|
220
|
+
onUtterance: (transcript) => void handleSpokenInput(transcript),
|
|
221
|
+
});
|
|
222
|
+
const handsFreeSay = handsFree.say;
|
|
239
223
|
const handleSpokenInput = useCallback(async (transcript) => {
|
|
240
224
|
const time = new Date().toLocaleTimeString().slice(0, 5);
|
|
241
|
-
|
|
242
|
-
setLiveNotes((prev) => [...prev, userNote]);
|
|
243
|
-
const reply = await generateAgentMeetingResponse(transcript, activeCoPilotAgent);
|
|
244
|
-
const agentNote = `${time}: ${activeCoPilotAgent} (AI): ${reply}`;
|
|
245
|
-
setLiveNotes((prev) => [...prev, agentNote]);
|
|
246
|
-
speak(reply, activeCoPilotAgent);
|
|
247
|
-
}, [activeCoPilotAgent, speak]);
|
|
248
|
-
// Real-time hands-free speech recognition & barge-in turn-taking
|
|
249
|
-
useEffect(() => {
|
|
250
|
-
if (!handsFree || !activeMeetingRoom) {
|
|
251
|
-
if (recognitionRef.current) {
|
|
252
|
-
try {
|
|
253
|
-
recognitionRef.current.stop();
|
|
254
|
-
}
|
|
255
|
-
catch { }
|
|
256
|
-
recognitionRef.current = null;
|
|
257
|
-
}
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (typeof window === 'undefined')
|
|
261
|
-
return;
|
|
262
|
-
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
263
|
-
if (!SpeechRec)
|
|
264
|
-
return;
|
|
265
|
-
const recognition = new SpeechRec();
|
|
266
|
-
recognition.continuous = true;
|
|
267
|
-
recognition.interimResults = true;
|
|
268
|
-
recognition.lang = 'en-US';
|
|
269
|
-
recognition.onresult = (event) => {
|
|
270
|
-
// Barge-in: if human starts speaking while agent is talking, halt speech immediately!
|
|
271
|
-
if (speaking) {
|
|
272
|
-
stopAgentSpeech();
|
|
273
|
-
}
|
|
274
|
-
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
275
|
-
if (event.results[i].isFinal) {
|
|
276
|
-
const transcript = event.results[i][0]?.transcript?.trim();
|
|
277
|
-
if (transcript) {
|
|
278
|
-
handleSpokenInput(transcript);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
recognition.onerror = () => { };
|
|
284
|
-
recognition.onend = () => {
|
|
285
|
-
if (handsFree && activeMeetingRoom) {
|
|
286
|
-
try {
|
|
287
|
-
recognition.start();
|
|
288
|
-
}
|
|
289
|
-
catch { }
|
|
290
|
-
}
|
|
291
|
-
};
|
|
225
|
+
setLiveNotes((prev) => [...prev, `${time}: You (voice): ${transcript}`]);
|
|
292
226
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
227
|
+
const reply = await generateAgentMeetingResponse(transcript, activeCoPilotAgent, token);
|
|
228
|
+
setLiveNotes((prev) => [...prev, `${time}: ${activeCoPilotAgent} (AI): ${reply}`]);
|
|
229
|
+
void handsFreeSay(cleanForSpeech(reply), voiceOf(activeCoPilotAgent));
|
|
295
230
|
}
|
|
296
|
-
catch {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
recognitionRef.current.stop();
|
|
301
|
-
}
|
|
302
|
-
catch { }
|
|
303
|
-
recognitionRef.current = null;
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
}, [handsFree, activeMeetingRoom, speaking, handleSpokenInput]);
|
|
231
|
+
catch (err) {
|
|
232
|
+
setLiveNotes((prev) => [...prev, `${time}: ${activeCoPilotAgent} did not answer: ${err.message}`]);
|
|
233
|
+
}
|
|
234
|
+
}, [activeCoPilotAgent, token, handsFreeSay]);
|
|
307
235
|
const handleAskAgent = async (e) => {
|
|
308
236
|
e.preventDefault();
|
|
309
237
|
const q = agentQuestion.trim();
|
|
@@ -311,26 +239,37 @@ export function Meet() {
|
|
|
311
239
|
return;
|
|
312
240
|
setAgentQuestion('');
|
|
313
241
|
const time = new Date().toLocaleTimeString().slice(0, 5);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
242
|
+
setLiveNotes((prev) => [...prev, `${time}: You: ${q}`]);
|
|
243
|
+
try {
|
|
244
|
+
const reply = await generateAgentMeetingResponse(q, activeCoPilotAgent, token);
|
|
245
|
+
setLiveNotes((prev) => [...prev, `${time}: ${activeCoPilotAgent} (AI): ${reply}`]);
|
|
246
|
+
speak(reply, activeCoPilotAgent);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
setLiveNotes((prev) => [...prev, `${time}: ${activeCoPilotAgent} did not answer: ${err.message}`]);
|
|
250
|
+
}
|
|
320
251
|
};
|
|
252
|
+
// The briefing is the call's own notes, read aloud; with none there is nothing to brief.
|
|
321
253
|
const handleReadBriefing = () => {
|
|
322
|
-
|
|
323
|
-
|
|
254
|
+
if (!liveNotes.length)
|
|
255
|
+
return;
|
|
256
|
+
toggle(`Briefing for ${activeMeetingRoom || 'this call'}. ${liveNotes.join('. ')}`, activeCoPilotAgent);
|
|
324
257
|
};
|
|
258
|
+
// What the hands-free control says: why it cannot run, or that Hanzo's speech
|
|
259
|
+
// refused and the browser stood in (or could not) — never silence.
|
|
260
|
+
const handsFreeNote = handsFree.reason ?? (handsFree.refusal ? REFUSED[handsFree.refusal.covered ? 'covered' : 'lost'] : null);
|
|
325
261
|
return (_jsxs(YStack, { width: "100%", height: "100%", bg: "$background", overflowX: "auto", overflowY: "auto", children: [_jsxs(XStack, { minH: 56, px: 20, py: 8, gap: 10, flexWrap: "wrap", bg: "$panel", borderBottomWidth: 1, borderBottomColor: "$borderColor", items: "center", justify: "space-between", children: [_jsxs(XStack, { items: "center", gap: 12, shrink: 1, minW: "auto", children: [_jsx(XStack, { width: 32, height: 32, rounded: 8, bg: "$raised", items: "center", justify: "center", shrink: 1, minW: "auto", children: _jsx(Video, { size: 18, color: "var(--foreground)" }) }), _jsxs(YStack, { shrink: 1, minW: "auto", children: [_jsxs(XStack, { items: "center", gap: 8, children: [_jsx(Text, { fontSize: "$4", fontWeight: "700", color: "$ink", children: "Hanzo Meet" }), _jsx(Text, { fontSize: FLOOR, fontWeight: "700", bg: "$hover", color: "$soft", borderWidth: 1, borderColor: "$borderColor", px: 6, py: 1, rounded: 4, children: "LIVE \u00B7 meet.hanzo.ai" })] }), _jsx(Text, { fontSize: "$1", color: "$soft", children: "Native WebRTC video conferencing with AI meeting bot transcription & voice" })] })] }), activeMeetingRoom ? (_jsxs(XStack, { items: "center", gap: 8, shrink: 1, minW: "auto", flexWrap: "wrap", children: [_jsx(XStack, { bg: "$hover", rounded: 8, p: 2, gap: 2, borderWidth: 1, borderColor: "$borderColor", shrink: 1, minW: "auto", children: [
|
|
326
262
|
['iframe', 'SFU Stage', Tv],
|
|
327
263
|
['grid', 'Agent Grid', LayoutGrid],
|
|
328
264
|
].map(([view, label, Icon]) => (_jsxs(Button, { size: "sm", variant: meetingView === view ? 'primary' : 'ghost', ...(meetingView === view ? null : { color: '$soft' }), "aria-pressed": meetingView === view, onClick: () => setMeetingView(view), children: [_jsx(Icon, { size: 12 }), label] }, view))) }), botAssisting ? (_jsx(View, { display: "none", "$max-md": { display: 'flex' }, children: _jsxs(Button, { size: "sm", variant: copilot ? 'secondary' : 'default', onClick: () => setCopilot((v) => !v), "aria-label": "Co-pilot", "aria-expanded": copilot, "aria-controls": "meet-copilot", children: [_jsx(Bot, { size: 13 }), "Co-pilot"] }) })) : null, _jsxs(Button, { size: "sm", onClick: () => handleCopyLink(activeMeetingRoom), children: [copied ? _jsx(Check, { size: 13 }) : _jsx(Copy, { size: 13 }), copied ? 'Link Copied' : 'Copy Room Link'] }), _jsxs(Button, { size: "sm", variant: "destructive", onClick: () => {
|
|
329
265
|
stopAgentSpeech();
|
|
266
|
+
// Leaving the call hangs up the hands-free conversation with it.
|
|
267
|
+
if (handsFree.open)
|
|
268
|
+
handsFree.toggle();
|
|
330
269
|
setActiveMeetingRoom(null);
|
|
331
270
|
}, children: [_jsx(PhoneOff, { size: 14 }), "Leave Call"] })] })) : (_jsx(XStack, { items: "center", gap: 8, shrink: 1, minW: "auto", children: _jsx(Button, { asChild: true, variant: "linkMuted", size: "sm", px: 0, children: _jsxs("a", { href: "https://meet.hanzo.ai", target: "_blank", rel: "noopener noreferrer", children: ["meet.hanzo.ai", _jsx(ExternalLink, { size: 12 })] }) }) }))] }), activeMeetingRoom ? (_jsxs(XStack, { flex: 1, overflow: "hidden", position: "relative", children: [_jsx(XStack, { flex: 1, minW: "auto", position: "relative", bg: "$sunken", children: meetingView === 'iframe' ? (_jsx(View, { render: _jsx("iframe", { src: `https://meet.hanzo.ai/${activeMeetingRoom}#config.prejoinPageEnabled=false&config.startWithAudioMuted=${!micOn}&config.startWithVideoMuted=${!cameraOn}`, allow: "camera; microphone; display-capture; autoplay; clipboard-write; speaker-selection; screen-wake-lock", title: `Hanzo Meet Room: ${activeMeetingRoom}` }), width: "100%", height: "100%", borderWidth: 0, bg: "$sunken" }, "stage")) : (_jsxs(View, { flex: 1, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 12, p: 16, overflowY: "auto", bg: "$background", children: [_jsx(YStack, { bg: "$panel", rounded: 12, borderWidth: 1, borderColor: "$borderColor", position: "relative", overflow: "hidden", items: "center", justify: "center", minH: 220, children: _jsxs(YStack, { items: "center", children: [_jsx(YStack, { width: 72, height: 72, rounded: 9999, bg: "$raised", items: "center", justify: "center", mb: 8, children: _jsx(Text, { fontSize: "$8", fontWeight: "700", color: "$ink", children: "You" }) }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: "Alex (Host)" }), _jsx(Text, { fontSize: "$1", color: micOn ? GOOD : '$soft', children: micOn ? 'Mic Active' : 'Muted' })] }) }), invitedAgents.map((agentName) => {
|
|
332
271
|
const voice = voiceOf(agentName);
|
|
333
|
-
const isThisSpeaking = speaking && activeCoPilotAgent.toLowerCase() === agentName.toLowerCase();
|
|
272
|
+
const isThisSpeaking = (speaking || handsFree.state === 'speaking') && activeCoPilotAgent.toLowerCase() === agentName.toLowerCase();
|
|
334
273
|
return (_jsxs(YStack, { bg: "$panel", rounded: 12, borderWidth: isThisSpeaking ? 2 : 1, borderColor: isThisSpeaking ? 'var(--state-success)' : '$borderColor', position: "relative", overflow: "hidden", items: "center", justify: "center", p: 20, minH: 220, transition: "200ms", children: [_jsxs(View, { position: "relative", mb: 10, children: [_jsx(Face, { name: agentName, size: 72 }), isThisSpeaking ? (_jsx(View, { position: "absolute", b: -4, r: -4, bg: "var(--popover)", borderWidth: 1, borderColor: "var(--state-success)", rounded: 9999, p: 4, children: _jsx(Volume2, { size: 12, color: GOOD }) })) : null] }), _jsx(Text, { fontSize: "$3", fontWeight: "700", color: "$ink", mb: 2, children: agentName }), _jsxs(Text, { fontSize: "$1", color: "$soft", mb: 8, children: ["Voice: ", voice] }), _jsx(XStack, { items: "center", gap: 6, bg: "$hover", px: 10, py: 4, rounded: 12, children: isThisSpeaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: "var(--state-success)" }), _jsx(Text, { fontSize: "$1", fontWeight: "600", color: GOOD, children: "Speaking in Call" })] })) : (_jsxs(_Fragment, { children: [_jsx(Bot, { size: 11, color: "var(--muted-foreground)" }), _jsx(Text, { fontSize: "$1", fontWeight: "600", color: "$quiet", children: "AI Agent \u00B7 In Call" })] })) })] }, agentName));
|
|
335
274
|
})] }, "grid")) }), botAssisting ? (_jsxs(YStack, { id: "meet-copilot", width: 360, minW: 360, bg: "$panel", borderLeftWidth: 1, borderLeftColor: "$borderColor", p: 16, gap: 12, "$max-md": {
|
|
336
275
|
display: copilot ? 'flex' : 'none',
|
|
@@ -343,9 +282,13 @@ export function Meet() {
|
|
|
343
282
|
minW: 0,
|
|
344
283
|
z: 'var(--z-sheet)',
|
|
345
284
|
borderLeftWidth: 0,
|
|
346
|
-
}, children: [_jsxs(XStack, { items: "center", justify: "space-between", children: [_jsxs(XStack, { items: "center", gap: 8, children: [_jsx(Face, { name: activeCoPilotAgent, size: 28 }), _jsxs(YStack, { children: [_jsx(Text, { fontSize: "$2", fontWeight: "700", color: "$ink", children: activeCoPilotAgent }), _jsxs(Text, { fontSize: FLOOR, color: "$faint", children: ["AI Co-pilot \u00B7 Voice: ", voiceOf(activeCoPilotAgent)] })] })] }), _jsx(Text, { fontSize: FLOOR, fontWeight: "700", color: GOOD, bg: "$hover", px: 6, py: 2, rounded: 4, children: "Live in call" })] }), _jsx(XStack, { gap: 4, flexWrap: "wrap", children: invitedAgents.map((agent) => (_jsx(Button, { size: "sm", variant: activeCoPilotAgent === agent ? 'secondary' : 'ghost', color: activeCoPilotAgent === agent ? '$ink' : '$soft', "aria-pressed": activeCoPilotAgent === agent, onClick: () => setActiveCoPilotAgent(agent), children: agent }, agent))) }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, variant: speaking ? 'secondary' : 'default', onClick: handleReadBriefing, children: speaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: "var(--state-success)" }), "Stop Briefing", _jsx(Square, { size: 11, fill: "currentColor" })] })) : (_jsxs(_Fragment, { children: [_jsx(Volume2, { size: 14 }), "Briefing"] })) }), _jsxs(Button, { size: "sm", variant: handsFree ? 'secondary' : 'default', "data-testid": "hands-free-voice-toggle", "
|
|
347
|
-
|
|
348
|
-
|
|
285
|
+
}, children: [_jsxs(XStack, { items: "center", justify: "space-between", children: [_jsxs(XStack, { items: "center", gap: 8, children: [_jsx(Face, { name: activeCoPilotAgent, size: 28 }), _jsxs(YStack, { children: [_jsx(Text, { fontSize: "$2", fontWeight: "700", color: "$ink", children: activeCoPilotAgent }), _jsxs(Text, { fontSize: FLOOR, color: "$faint", children: ["AI Co-pilot \u00B7 Voice: ", voiceOf(activeCoPilotAgent)] })] })] }), _jsx(Text, { fontSize: FLOOR, fontWeight: "700", color: GOOD, bg: "$hover", px: 6, py: 2, rounded: 4, children: "Live in call" })] }), _jsx(XStack, { gap: 4, flexWrap: "wrap", children: invitedAgents.map((agent) => (_jsx(Button, { size: "sm", variant: activeCoPilotAgent === agent ? 'secondary' : 'ghost', color: activeCoPilotAgent === agent ? '$ink' : '$soft', "aria-pressed": activeCoPilotAgent === agent, onClick: () => setActiveCoPilotAgent(agent), children: agent }, agent))) }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, variant: speaking ? 'secondary' : 'default', disabled: !speaking && !liveNotes.length, onClick: handleReadBriefing, children: speaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: "var(--state-success)" }), "Stop Briefing", _jsx(Square, { size: 11, fill: "currentColor" })] })) : (_jsxs(_Fragment, { children: [_jsx(Volume2, { size: 14 }), "Briefing"] })) }), _jsxs(Button, { size: "sm", variant: handsFree.open ? 'secondary' : 'default', "data-testid": "hands-free-voice-toggle", "data-state": handsFree.state, "data-refusal": handsFree.refusal ? handsFree.refusal.service : undefined, "aria-pressed": handsFree.open, disabled: !!handsFree.blocked, title: handsFreeNote ??
|
|
286
|
+
(handsFree.open ? 'Hands-Free Voice Active (Speaks and interrupts on voice)' : 'Enable Hands-Free Voice Mode'), onClick: handsFree.toggle, children: [_jsx(Mic, { size: 14, color: handsFree.open ? 'var(--state-success)' : 'currentColor' }), handsFree.open ? 'Hands-Free On' : 'Hands-Free'] })] }), handsFreeNote ? (_jsx(Text, { fontSize: "$1", color: "$soft", role: "status", children: handsFreeNote })) : null, _jsx(YStack, { flex: 1, overflowY: "auto", bg: "$background", rounded: 8, borderWidth: 1, borderColor: "$borderColor", p: 12, gap: 8, children: liveNotes.map((note, i) => (_jsx(Text, { fontSize: "$1", color: "$quiet", lineHeight: 16.5, pb: 8, borderBottomWidth: 1, borderBottomColor: "$borderColor", children: note }, i))) }), _jsxs(XStack, { render: "form", onSubmit: handleAskAgent, gap: 6, children: [_jsx(Text, { render: _jsx("input", { type: "text", value: agentQuestion, onChange: (e) => setAgentQuestion(e.target.value), placeholder: `Ask ${activeCoPilotAgent} in meeting...` }), flex: 1, bg: "$background", borderWidth: 1, borderColor: "var(--border-control)", rounded: 6, px: 10, py: 8, color: "$ink", fontSize: "$1", outlineStyle: "none" }), _jsx(Button, { type: "submit", variant: "primary", size: "icon-sm", disabled: !agentQuestion.trim(), "aria-label": "Ask", children: _jsx(Send, { size: 12 }) })] }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, disabled: !agentQuestion.trim(), title: "Add what is typed above to the notes, without asking the co-pilot", onClick: () => {
|
|
287
|
+
const note = agentQuestion.trim();
|
|
288
|
+
if (!note)
|
|
289
|
+
return;
|
|
290
|
+
setAgentQuestion('');
|
|
291
|
+
setLiveNotes((prev) => [...prev, `${new Date().toLocaleTimeString().slice(0, 5)}: You (note): ${note}`]);
|
|
349
292
|
}, children: "+ Add Live Note" }), _jsx(Button, { variant: "primary", size: "sm", onClick: () => {
|
|
350
293
|
const text = liveNotes.join('\n');
|
|
351
294
|
navigator.clipboard.writeText(text);
|
package/dist/cast.d.ts
CHANGED
|
@@ -24,18 +24,17 @@ interface Role {
|
|
|
24
24
|
*/
|
|
25
25
|
ground?: string;
|
|
26
26
|
/**
|
|
27
|
-
* The voice the platform reads this character's replies in
|
|
28
|
-
* /v1/audio/speech
|
|
27
|
+
* The voice the platform reads this character's replies in: a speech-service
|
|
28
|
+
* voice id, sent to /v1/audio/speech as it is. The first letter is the accent
|
|
29
|
+
* (a American, b British), the second the register (f female, m male).
|
|
29
30
|
*
|
|
30
|
-
* ONE VOICE PER CHARACTER, and never two who would be in the same room.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the five the demo puts in one call are all distinct. `echo` was reading for
|
|
34
|
-
* three at once, which in a call is one person doing three parts.
|
|
31
|
+
* ONE VOICE PER CHARACTER, and never two who would be in the same room. A
|
|
32
|
+
* house part shares the voice of the character whose face it wears, so Hanzo
|
|
33
|
+
* Coder sounds like Dev; no two characters share one.
|
|
35
34
|
*
|
|
36
35
|
* CAST, NOT COPIED. These are the speech service's own voices, chosen for fit
|
|
37
|
-
* — a
|
|
38
|
-
*
|
|
36
|
+
* — a playful reader for the physicist, something low and measured for the
|
|
37
|
+
* quant. None of them is anybody's actual voice and none is trying to be:
|
|
39
38
|
* synthesising a real person saying words they never said is a different act
|
|
40
39
|
* from casting a part, and this is the second one.
|
|
41
40
|
*/
|
|
@@ -88,18 +87,22 @@ export declare function speakers(text: string, names: string[]): {
|
|
|
88
87
|
who: string | null;
|
|
89
88
|
text: string;
|
|
90
89
|
}[];
|
|
90
|
+
/**
|
|
91
|
+
* Every American and British voice the speech service reads in, which is the
|
|
92
|
+
* pool an agent nobody cast is given a voice from. `am_santa` is left out: it
|
|
93
|
+
* is a character voice, not a reader.
|
|
94
|
+
*/
|
|
91
95
|
export declare const VOICES: string[];
|
|
96
|
+
/** A voice for a name nobody cast: the same name always gets the same one. */
|
|
92
97
|
export declare function fallbackVoice(name: string | undefined): string;
|
|
93
98
|
/** The voice to read a character's replies in. Always returns a valid voice. */
|
|
94
99
|
export declare const voiceOf: (name: string | undefined) => string;
|
|
100
|
+
/** Who a voice sounds like, read off its id — what a browser voice standing in is matched on. */
|
|
95
101
|
export interface VoiceProfile {
|
|
96
102
|
voice: string;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
description: string;
|
|
100
|
-
gender: 'male' | 'female' | 'neutral';
|
|
103
|
+
gender: 'male' | 'female';
|
|
104
|
+
accent: 'american' | 'british';
|
|
101
105
|
}
|
|
102
|
-
export declare const VOICE_PROFILES: Record<string, VoiceProfile>;
|
|
103
106
|
export declare function voiceProfileOf(name: string | undefined): VoiceProfile;
|
|
104
107
|
export declare function fallbackMemoji(name: string | undefined): string;
|
|
105
108
|
/**
|
package/dist/cast.js
CHANGED
|
@@ -24,30 +24,30 @@ export const CAST = {
|
|
|
24
24
|
//
|
|
25
25
|
// Dev wears its own memoji (black ground baked in, so it fills the disc and
|
|
26
26
|
// needs no rim colour); hanzo coder is that same face under the house name.
|
|
27
|
-
dev: { portrait: '/agents/dev.png', voice: '
|
|
28
|
-
des: { portrait: '/agents/des.png', ground: '#74569F', voice: '
|
|
29
|
-
vi: { portrait: '/agents/vi.png', ground: '#41649F', voice: '
|
|
30
|
-
antje: { portrait: '/agents/antje.jpg', voice: '
|
|
31
|
-
zach: { portrait: '/agents/zach.jpg', voice: '
|
|
32
|
-
feynman: { portrait: '/agents/feynman.png', ground: '#9C7C4F', voice: '
|
|
33
|
-
einstein: { portrait: '/agents/einstein.png', ground: '#7D6450', voice: '
|
|
34
|
-
nora: { portrait: '/agents/nora.png', voice: '
|
|
35
|
-
creative: { portrait: '/agents/creative.png', voice: '
|
|
36
|
-
maya: { portrait: '/agents/maya.png', voice: '
|
|
37
|
-
leo: { portrait: '/agents/leo.png', voice: '
|
|
27
|
+
dev: { portrait: '/agents/dev.png', voice: 'am_michael' },
|
|
28
|
+
des: { portrait: '/agents/des.png', ground: '#74569F', voice: 'af_bella' },
|
|
29
|
+
vi: { portrait: '/agents/vi.png', ground: '#41649F', voice: 'am_fenrir' },
|
|
30
|
+
antje: { portrait: '/agents/antje.jpg', voice: 'af_nova' },
|
|
31
|
+
zach: { portrait: '/agents/zach.jpg', voice: 'am_onyx' },
|
|
32
|
+
feynman: { portrait: '/agents/feynman.png', ground: '#9C7C4F', voice: 'am_puck' },
|
|
33
|
+
einstein: { portrait: '/agents/einstein.png', ground: '#7D6450', voice: 'bm_george' },
|
|
34
|
+
nora: { portrait: '/agents/nora.png', voice: 'af_heart' },
|
|
35
|
+
creative: { portrait: '/agents/creative.png', voice: 'bm_fable' },
|
|
36
|
+
maya: { portrait: '/agents/maya.png', voice: 'bf_emma' },
|
|
37
|
+
leo: { portrait: '/agents/leo.png', voice: 'am_liam' },
|
|
38
38
|
// THE HOUSE AGENTS, UNDER THE NAMES A READER SEES. Every entry above is keyed
|
|
39
39
|
// by the character; these are keyed by the part.
|
|
40
|
-
'hanzo coder': { portrait: '/agents/dev.png', voice: '
|
|
41
|
-
'hanzo researcher': { portrait: '/agents/feynman.png', voice: '
|
|
42
|
-
'hanzo designer': { portrait: '/agents/des.png', voice: '
|
|
43
|
-
'hanzo devops': { voice: '
|
|
44
|
-
'hanzo quant': { voice: '
|
|
45
|
-
'hanzo copywriter': { portrait: '/agents/creative.png', voice: '
|
|
46
|
-
'hanzo legal & compliance': { portrait: '/agents/einstein.png', voice: '
|
|
47
|
-
'hanzo executive assistant': { portrait: '/agents/maya.png', voice: '
|
|
48
|
-
'hanzo architect': { voice: '
|
|
49
|
-
'hanzo vision': { voice: '
|
|
50
|
-
'hanzo support': { portrait: '/agents/nora.png', voice: '
|
|
40
|
+
'hanzo coder': { portrait: '/agents/dev.png', voice: 'am_michael' },
|
|
41
|
+
'hanzo researcher': { portrait: '/agents/feynman.png', voice: 'am_puck' },
|
|
42
|
+
'hanzo designer': { portrait: '/agents/des.png', voice: 'af_bella' },
|
|
43
|
+
'hanzo devops': { voice: 'am_fenrir' },
|
|
44
|
+
'hanzo quant': { voice: 'am_onyx' },
|
|
45
|
+
'hanzo copywriter': { portrait: '/agents/creative.png', voice: 'bm_fable' },
|
|
46
|
+
'hanzo legal & compliance': { portrait: '/agents/einstein.png', voice: 'bm_george' },
|
|
47
|
+
'hanzo executive assistant': { portrait: '/agents/maya.png', voice: 'bf_emma' },
|
|
48
|
+
'hanzo architect': { voice: 'bm_george' },
|
|
49
|
+
'hanzo vision': { voice: 'bm_fable' },
|
|
50
|
+
'hanzo support': { portrait: '/agents/nora.png', voice: 'af_heart' },
|
|
51
51
|
};
|
|
52
52
|
/** The role for an agent name, or undefined for one nobody cast. */
|
|
53
53
|
export const roleOf = (name) => CAST[name?.toLowerCase() ?? ''];
|
|
@@ -132,22 +132,21 @@ export function speakers(text, names) {
|
|
|
132
132
|
}
|
|
133
133
|
return out.map((one) => ({ ...one, text: one.text.trim() })).filter((one) => one.text || one.who);
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Every American and British voice the speech service reads in, which is the
|
|
137
|
+
* pool an agent nobody cast is given a voice from. `am_santa` is left out: it
|
|
138
|
+
* is a character voice, not a reader.
|
|
139
|
+
*/
|
|
135
140
|
export const VOICES = [
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
139
|
-
'
|
|
140
|
-
'onyx',
|
|
141
|
-
'fable',
|
|
142
|
-
'shimmer',
|
|
143
|
-
'alloy',
|
|
144
|
-
'verse',
|
|
145
|
-
'ballad',
|
|
146
|
-
'coral',
|
|
141
|
+
'af_alloy', 'af_aoede', 'af_bella', 'af_heart', 'af_jessica', 'af_kore', 'af_nicole', 'af_nova', 'af_river', 'af_sarah', 'af_sky',
|
|
142
|
+
'am_adam', 'am_echo', 'am_eric', 'am_fenrir', 'am_liam', 'am_michael', 'am_onyx', 'am_puck',
|
|
143
|
+
'bf_alice', 'bf_emma', 'bf_isabella', 'bf_lily',
|
|
144
|
+
'bm_daniel', 'bm_fable', 'bm_george', 'bm_lewis',
|
|
147
145
|
];
|
|
146
|
+
/** A voice for a name nobody cast: the same name always gets the same one. */
|
|
148
147
|
export function fallbackVoice(name) {
|
|
149
148
|
if (!name)
|
|
150
|
-
return
|
|
149
|
+
return 'af_heart';
|
|
151
150
|
let hash = 0;
|
|
152
151
|
for (let i = 0; i < name.length; i++) {
|
|
153
152
|
hash = (hash << 5) - hash + name.charCodeAt(i);
|
|
@@ -157,22 +156,13 @@ export function fallbackVoice(name) {
|
|
|
157
156
|
}
|
|
158
157
|
/** The voice to read a character's replies in. Always returns a valid voice. */
|
|
159
158
|
export const voiceOf = (name) => roleOf(name)?.voice || fallbackVoice(name);
|
|
160
|
-
export const VOICE_PROFILES = {
|
|
161
|
-
ash: { voice: 'ash', pitch: 1.0, rate: 1.0, description: 'Clear, direct, technical', gender: 'male' },
|
|
162
|
-
echo: { voice: 'echo', pitch: 1.1, rate: 1.05, description: 'Adaptive, creative, resonant', gender: 'female' },
|
|
163
|
-
sage: { voice: 'sage', pitch: 0.95, rate: 1.05, description: 'Authoritative, calm, systems-minded', gender: 'male' },
|
|
164
|
-
nova: { voice: 'nova', pitch: 1.1, rate: 1.0, description: 'Energetic, articulate, engaging', gender: 'female' },
|
|
165
|
-
onyx: { voice: 'onyx', pitch: 0.85, rate: 1.0, description: 'Deep, measured, quantitative', gender: 'male' },
|
|
166
|
-
fable: { voice: 'fable', pitch: 1.05, rate: 1.12, description: 'Warm, enthusiastic, pedagogical', gender: 'male' },
|
|
167
|
-
shimmer: { voice: 'shimmer', pitch: 1.1, rate: 0.98, description: 'Precise, professional, clear', gender: 'female' },
|
|
168
|
-
alloy: { voice: 'alloy', pitch: 0.95, rate: 1.05, description: 'Visionary, forward-leaning, focused', gender: 'male' },
|
|
169
|
-
verse: { voice: 'verse', pitch: 0.9, rate: 0.95, description: 'Intentional, iconic, design-first', gender: 'male' },
|
|
170
|
-
ballad: { voice: 'ballad', pitch: 0.92, rate: 1.0, description: 'Thoughtful, deep, expansive', gender: 'male' },
|
|
171
|
-
coral: { voice: 'coral', pitch: 1.15, rate: 1.05, description: 'Friendly, supportive, organized', gender: 'female' },
|
|
172
|
-
};
|
|
173
159
|
export function voiceProfileOf(name) {
|
|
174
|
-
const
|
|
175
|
-
return
|
|
160
|
+
const voice = voiceOf(name);
|
|
161
|
+
return {
|
|
162
|
+
voice,
|
|
163
|
+
gender: voice[1] === 'm' ? 'male' : 'female',
|
|
164
|
+
accent: voice[0] === 'b' ? 'british' : 'american',
|
|
165
|
+
};
|
|
176
166
|
}
|
|
177
167
|
/** Initials, for anyone with no likeness. Two at most: a disc this size holds
|
|
178
168
|
* two letters and turns three into a smudge. */
|
package/dist/lib/limits.d.ts
CHANGED
|
@@ -47,6 +47,15 @@ export declare const spendShown: ({ limits, answered }: {
|
|
|
47
47
|
export declare const left: (s: Span) => number;
|
|
48
48
|
/** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
|
|
49
49
|
export declare const sku: (id: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a model can answer a chat turn: it names no outputs, or names text
|
|
52
|
+
* among them. `zen-scribe` (a transcript) and `zen-voice-mini` (audio) are
|
|
53
|
+
* Zen ids the catalog lists, and neither answers a question.
|
|
54
|
+
*/
|
|
55
|
+
export declare const chats: (model: {
|
|
56
|
+
id: string;
|
|
57
|
+
outputs?: unknown;
|
|
58
|
+
}) => boolean;
|
|
50
59
|
/** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
|
|
51
60
|
export declare function clock(iso: string | null | undefined): string;
|
|
52
61
|
/** A period's end as a sentence states it — "Oct 30, 2026" — or '' for none. Periods end on UTC days. */
|
package/dist/lib/limits.js
CHANGED
|
@@ -102,6 +102,12 @@ export const spendShown = ({ limits, answered }) => answered && !limits?.plan;
|
|
|
102
102
|
export const left = (s) => 100 - s.percent;
|
|
103
103
|
/** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
|
|
104
104
|
export const sku = (id) => /^(hanzo\/)?(enso|zen)/i.test(id);
|
|
105
|
+
/**
|
|
106
|
+
* Whether a model can answer a chat turn: it names no outputs, or names text
|
|
107
|
+
* among them. `zen-scribe` (a transcript) and `zen-voice-mini` (audio) are
|
|
108
|
+
* Zen ids the catalog lists, and neither answers a question.
|
|
109
|
+
*/
|
|
110
|
+
export const chats = (model) => !Array.isArray(model.outputs) || model.outputs.includes('text');
|
|
105
111
|
/** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
|
|
106
112
|
export function clock(iso) {
|
|
107
113
|
const t = iso ? Date.parse(iso) : NaN;
|
package/dist/speech.d.ts
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { type Refusal } from '@hanzo/voice';
|
|
3
|
+
import { type VoiceProfile } from './cast.js';
|
|
2
4
|
/** Strips code blocks, links, headers, and markdown markup for natural audio speech. */
|
|
3
5
|
export declare function cleanForSpeech(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The browser voice that reads when the platform refuses: English, the cast
|
|
8
|
+
* voice's accent where this browser has it, its register where a name says so.
|
|
9
|
+
* Read per sentence, because the browser loads its voice list asynchronously.
|
|
10
|
+
*/
|
|
11
|
+
export declare function standIn({ gender, accent }: VoiceProfile, voices: {
|
|
12
|
+
name: string;
|
|
13
|
+
lang: string;
|
|
14
|
+
}[]): string | undefined;
|
|
4
15
|
export declare function stopAgentSpeech(): void;
|
|
5
16
|
export interface SpeakOptions {
|
|
6
17
|
agent?: string;
|
|
18
|
+
/** The reader's IAM bearer (`useIamToken`). Without one the platform refuses
|
|
19
|
+
* and the browser reads, which is reported through `onRefusal`. */
|
|
20
|
+
token?: string | null;
|
|
21
|
+
/** Speaking began. Fires at once: the request for the audio is part of it. */
|
|
7
22
|
onStart?: () => void;
|
|
23
|
+
/** Speaking stopped, for any reason: played out, stopped, or replaced. */
|
|
8
24
|
onEnd?: () => void;
|
|
9
25
|
onError?: (err: unknown) => void;
|
|
26
|
+
/** The platform refused, and whether the browser stood in. */
|
|
27
|
+
onRefusal?: (refusal: Refusal) => void;
|
|
10
28
|
}
|
|
11
29
|
/**
|
|
12
|
-
* Speak text in the assigned voice of an agent.
|
|
30
|
+
* Speak text in the assigned voice of an agent, through the platform.
|
|
13
31
|
* Returns a cancel function.
|
|
32
|
+
*
|
|
33
|
+
* Call it from the click that asks for it: the player is made inside that
|
|
34
|
+
* gesture, which is what lets Safari play the reply when it arrives.
|
|
14
35
|
*/
|
|
15
36
|
export declare function speakAgent(text: string, options?: SpeakOptions): () => void;
|
|
16
37
|
/**
|
|
@@ -19,6 +40,7 @@ export declare function speakAgent(text: string, options?: SpeakOptions): () =>
|
|
|
19
40
|
export declare function useAgentSpeech(defaultAgent?: string): {
|
|
20
41
|
speaking: boolean;
|
|
21
42
|
currentText: string;
|
|
43
|
+
refusal: Refusal;
|
|
22
44
|
speak: (text: string, agentName?: string) => void;
|
|
23
45
|
stop: () => void;
|
|
24
46
|
toggle: (text: string, agentName?: string) => void;
|
package/dist/speech.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// Agent Speech
|
|
3
|
+
// Agent Speech
|
|
4
4
|
//
|
|
5
5
|
// Every agent in the Hanzo ecosystem has an assigned voice (`voiceOf(name)` from
|
|
6
|
-
// `cast.tsx`)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// `cast.tsx`), and the platform reads it: `/v1/audio/speech` through
|
|
7
|
+
// @hanzo/voice's `speech()` transport and `mouth()`, in that voice. The
|
|
8
|
+
// browser's own speechSynthesis reads only when the platform refuses, in a
|
|
9
|
+
// voice of the same register and accent, and the refusal is reported rather
|
|
10
|
+
// than passed off as the platform's voice.
|
|
11
|
+
//
|
|
12
|
+
// - The Listen button on a message, the Meet speaker test and its briefing
|
|
9
13
|
// - Audio wave visualization during playback
|
|
10
14
|
// - Clean cancellation and state management
|
|
11
15
|
// - Text normalization (strips code fences, markdown syntax so speech sounds natural)
|
|
@@ -13,7 +17,10 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
13
17
|
import { Volume2, Square } from 'lucide-react';
|
|
14
18
|
import { View, XStack } from '@hanzo/gui';
|
|
15
19
|
import { Button } from '@hanzo/ui';
|
|
20
|
+
import { useIamToken } from '@hanzo/iam/react';
|
|
21
|
+
import { mouth, speech, REFUSED } from '@hanzo/voice';
|
|
16
22
|
import { GOOD } from './lib/mix.js';
|
|
23
|
+
import { base } from './lib/ai.js';
|
|
17
24
|
import { voiceOf, voiceProfileOf } from './cast.js';
|
|
18
25
|
/** Strips code blocks, links, headers, and markdown markup for natural audio speech. */
|
|
19
26
|
export function cleanForSpeech(raw) {
|
|
@@ -41,24 +48,41 @@ export function cleanForSpeech(raw) {
|
|
|
41
48
|
.replace(/\s+/g, ' ')
|
|
42
49
|
.trim();
|
|
43
50
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
51
|
+
/** Browser voice names by register, for the stand-in. Word-bounded: "Female" holds "male". */
|
|
52
|
+
const REGISTER = {
|
|
53
|
+
female: /\b(female|samantha|victoria|karen|zira|serena|moira|tessa)\b/i,
|
|
54
|
+
male: /\b(male|daniel|alex|fred|david|arthur|oliver)\b/i,
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The browser voice that reads when the platform refuses: English, the cast
|
|
58
|
+
* voice's accent where this browser has it, its register where a name says so.
|
|
59
|
+
* Read per sentence, because the browser loads its voice list asynchronously.
|
|
60
|
+
*/
|
|
61
|
+
export function standIn({ gender, accent }, voices) {
|
|
62
|
+
const english = voices.filter((v) => v.lang.toLowerCase().startsWith('en'));
|
|
63
|
+
const local = english.filter((v) => v.lang.toLowerCase().replace('_', '-') === (accent === 'british' ? 'en-gb' : 'en-us'));
|
|
64
|
+
const pick = (list) => list.find((v) => REGISTER[gender].test(v.name));
|
|
65
|
+
return (pick(local) ?? pick(english) ?? local[0] ?? english[0] ?? voices[0])?.name;
|
|
66
|
+
}
|
|
67
|
+
/** The reply being read aloud on this page. One at a time: a new one stops the last. */
|
|
68
|
+
let active = null;
|
|
46
69
|
const activeListeners = new Set();
|
|
70
|
+
const notify = () => {
|
|
71
|
+
for (const listener of activeListeners)
|
|
72
|
+
listener();
|
|
73
|
+
};
|
|
47
74
|
export function stopAgentSpeech() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
catch { }
|
|
53
|
-
}
|
|
54
|
-
activeUtterance = null;
|
|
55
|
-
for (const notify of activeListeners) {
|
|
56
|
-
notify();
|
|
57
|
-
}
|
|
75
|
+
const lips = active;
|
|
76
|
+
active = null;
|
|
77
|
+
lips?.hush();
|
|
78
|
+
notify();
|
|
58
79
|
}
|
|
59
80
|
/**
|
|
60
|
-
* Speak text in the assigned voice of an agent.
|
|
81
|
+
* Speak text in the assigned voice of an agent, through the platform.
|
|
61
82
|
* Returns a cancel function.
|
|
83
|
+
*
|
|
84
|
+
* Call it from the click that asks for it: the player is made inside that
|
|
85
|
+
* gesture, which is what lets Safari play the reply when it arrives.
|
|
62
86
|
*/
|
|
63
87
|
export function speakAgent(text, options = {}) {
|
|
64
88
|
if (typeof window === 'undefined')
|
|
@@ -67,77 +91,38 @@ export function speakAgent(text, options = {}) {
|
|
|
67
91
|
const clean = cleanForSpeech(text);
|
|
68
92
|
if (!clean)
|
|
69
93
|
return () => { };
|
|
70
|
-
if (!('speechSynthesis' in window)) {
|
|
71
|
-
options.onError?.(new Error('SpeechSynthesis not supported in this browser'));
|
|
72
|
-
return () => { };
|
|
73
|
-
}
|
|
74
94
|
const profile = voiceProfileOf(options.agent);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const lang = v.lang.toLowerCase();
|
|
86
|
-
if (!lang.startsWith('en'))
|
|
87
|
-
return false;
|
|
88
|
-
if (isFemale) {
|
|
89
|
-
return name.includes('female') || name.includes('samantha') || name.includes('victoria') || name.includes('karen') || name.includes('zira');
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
return name.includes('male') || name.includes('daniel') || name.includes('alex') || name.includes('fred') || name.includes('david');
|
|
93
|
-
}
|
|
94
|
-
}) || voices.find((v) => v.lang.toLowerCase().startsWith('en')) || voices[0];
|
|
95
|
-
if (candidate) {
|
|
96
|
-
utterance.voice = candidate;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
catch { }
|
|
101
|
-
utterance.onstart = () => {
|
|
102
|
-
activeUtterance = utterance;
|
|
103
|
-
options.onStart?.();
|
|
104
|
-
};
|
|
105
|
-
utterance.onend = () => {
|
|
106
|
-
if (activeUtterance === utterance) {
|
|
107
|
-
activeUtterance = null;
|
|
108
|
-
}
|
|
95
|
+
const lips = mouth({
|
|
96
|
+
speech: speech({ ...base(), ...(options.token ? { token: options.token } : {}) }),
|
|
97
|
+
voice: () => standIn(profile, window.speechSynthesis?.getVoices() ?? []),
|
|
98
|
+
refused: options.onRefusal,
|
|
99
|
+
});
|
|
100
|
+
active = lips;
|
|
101
|
+
options.onStart?.();
|
|
102
|
+
lips.say(clean, profile.voice).then(() => {
|
|
103
|
+
if (active === lips)
|
|
104
|
+
active = null;
|
|
109
105
|
options.onEnd?.();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
utterance.onerror = (e) => {
|
|
115
|
-
if (activeUtterance === utterance) {
|
|
116
|
-
activeUtterance = null;
|
|
117
|
-
}
|
|
118
|
-
options.onError?.(e);
|
|
119
|
-
for (const notify of activeListeners) {
|
|
120
|
-
notify();
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
try {
|
|
124
|
-
window.speechSynthesis.speak(utterance);
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
106
|
+
notify();
|
|
107
|
+
}, (err) => {
|
|
108
|
+
if (active === lips)
|
|
109
|
+
active = null;
|
|
127
110
|
options.onError?.(err);
|
|
128
|
-
|
|
111
|
+
notify();
|
|
112
|
+
});
|
|
129
113
|
return () => {
|
|
130
|
-
if (
|
|
114
|
+
if (active === lips)
|
|
131
115
|
stopAgentSpeech();
|
|
132
|
-
}
|
|
133
116
|
};
|
|
134
117
|
}
|
|
135
118
|
/**
|
|
136
119
|
* Hook for managing agent speech playback in UI components.
|
|
137
120
|
*/
|
|
138
121
|
export function useAgentSpeech(defaultAgent) {
|
|
122
|
+
const { token } = useIamToken();
|
|
139
123
|
const [speaking, setSpeaking] = useState(false);
|
|
140
124
|
const [currentText, setCurrentText] = useState(null);
|
|
125
|
+
const [refusal, setRefusal] = useState(null);
|
|
141
126
|
const cancelRef = useRef(null);
|
|
142
127
|
useEffect(() => {
|
|
143
128
|
const listener = () => {
|
|
@@ -160,8 +145,10 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
160
145
|
stop();
|
|
161
146
|
setCurrentText(text);
|
|
162
147
|
setSpeaking(true);
|
|
148
|
+
setRefusal(null);
|
|
163
149
|
cancelRef.current = speakAgent(text, {
|
|
164
150
|
agent: agentName || defaultAgent,
|
|
151
|
+
token,
|
|
165
152
|
onStart: () => setSpeaking(true),
|
|
166
153
|
onEnd: () => {
|
|
167
154
|
setSpeaking(false);
|
|
@@ -171,8 +158,9 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
171
158
|
setSpeaking(false);
|
|
172
159
|
setCurrentText(null);
|
|
173
160
|
},
|
|
161
|
+
onRefusal: setRefusal,
|
|
174
162
|
});
|
|
175
|
-
}, [defaultAgent, stop]);
|
|
163
|
+
}, [defaultAgent, stop, token]);
|
|
176
164
|
const toggle = useCallback((text, agentName) => {
|
|
177
165
|
if (speaking && currentText === text) {
|
|
178
166
|
stop();
|
|
@@ -181,7 +169,7 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
181
169
|
speak(text, agentName);
|
|
182
170
|
}
|
|
183
171
|
}, [speaking, currentText, speak, stop]);
|
|
184
|
-
return { speaking, currentText, speak, stop, toggle };
|
|
172
|
+
return { speaking, currentText, refusal, speak, stop, toggle };
|
|
185
173
|
}
|
|
186
174
|
/** The five bars' resting heights, as a share of the 12px the wave stands in. */
|
|
187
175
|
const BARS = [0.4, 0.9, 0.6, 1, 0.5];
|
|
@@ -219,11 +207,14 @@ export function AudioWave({ active = true, color = 'var(--state-success)' }) {
|
|
|
219
207
|
* Allows one-click audio playback in the agent's voice.
|
|
220
208
|
*/
|
|
221
209
|
export function SpeakButton({ text, agentName, compact = false, }) {
|
|
222
|
-
const { speaking, toggle } = useAgentSpeech(agentName);
|
|
210
|
+
const { speaking, refusal, toggle } = useAgentSpeech(agentName);
|
|
223
211
|
const voice = voiceOf(agentName);
|
|
212
|
+
// A browser voice standing in for a refused platform sounds like success;
|
|
213
|
+
// the label is the one place that says otherwise.
|
|
214
|
+
const stood = refusal ? ` ${REFUSED[refusal.covered ? 'covered' : 'lost']}` : '';
|
|
224
215
|
// An @hanzo/ui Button: ghost at rest, the quiet pressed ground while it
|
|
225
216
|
// speaks. The word for speaking is the one status here, so it alone takes
|
|
226
217
|
// the success ink (GOOD holds 4.5:1 on either theme); the icons follow the
|
|
227
218
|
// label through currentColor.
|
|
228
|
-
return (_jsx(Button, { variant: speaking ? 'secondary' : 'ghost', size: compact ? 'icon-sm' : 'sm', color: speaking ? GOOD : '$soft', onClick: () => toggle(text, agentName), "aria-label": speaking ? 'Stop speaking' : `Listen to ${agentName || 'agent'} (${voice})
|
|
219
|
+
return (_jsx(Button, { variant: speaking ? 'secondary' : 'ghost', size: compact ? 'icon-sm' : 'sm', color: speaking ? GOOD : '$soft', onClick: () => toggle(text, agentName), "aria-label": (speaking ? 'Stop speaking' : `Listen to ${agentName || 'agent'} (${voice})`) + stood, "aria-pressed": speaking, "data-refusal": refusal ? refusal.service : undefined, title: (speaking ? 'Stop speaking' : `Listen in ${agentName || 'agent'}'s voice (${voice})`) + stood, children: speaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: GOOD }), !compact && 'Speaking…', _jsx(Square, { size: 10, fill: "currentColor" })] })) : (_jsxs(_Fragment, { children: [_jsx(Volume2, { size: 13 }), !compact && 'Listen'] })) }));
|
|
229
220
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/rooms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@hanzo/plans": ">=1.8.9",
|
|
57
57
|
"@hanzo/ui": ">=8.27.51",
|
|
58
58
|
"@hanzo/usage": ">=0.1.17",
|
|
59
|
-
"@hanzo/voice": ">=0.1.
|
|
59
|
+
"@hanzo/voice": ">=0.1.10",
|
|
60
60
|
"@hanzogui/shell": "^8.3.20",
|
|
61
61
|
"lucide-react": ">=1.42.0",
|
|
62
62
|
"react": ">=19.2.8",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"@hanzo/appearance": "0.2.6",
|
|
68
68
|
"@hanzo/build": "0.2.34",
|
|
69
69
|
"@hanzo/composer": "0.2.0",
|
|
70
|
+
"@hanzo/design": "0.5.33",
|
|
70
71
|
"@hanzo/event": "0.3.56",
|
|
71
72
|
"@hanzo/gui": "8.3.5",
|
|
72
73
|
"@hanzo/iam": "0.21.28",
|
|
@@ -74,9 +75,10 @@
|
|
|
74
75
|
"@hanzo/plans": "1.8.9",
|
|
75
76
|
"@hanzo/ui": "8.27.51",
|
|
76
77
|
"@hanzo/usage": "0.1.17",
|
|
77
|
-
"@hanzo/voice": "0.1.
|
|
78
|
+
"@hanzo/voice": "0.1.10",
|
|
78
79
|
"@hanzogui/config": "8.3.5",
|
|
79
80
|
"@hanzogui/core": "8.3.5",
|
|
81
|
+
"@hanzogui/lucide-icons-2": "8.3.5",
|
|
80
82
|
"@hanzogui/shell": "8.3.20",
|
|
81
83
|
"@hanzogui/web": "8.3.5",
|
|
82
84
|
"@types/node": "26.5.0",
|
|
@@ -87,9 +89,7 @@
|
|
|
87
89
|
"react-dom": "19.2.8",
|
|
88
90
|
"react-native-web": "0.21.2",
|
|
89
91
|
"typescript": "7.0.2",
|
|
90
|
-
"vitest": "4.1.11"
|
|
91
|
-
"@hanzo/design": "0.5.33",
|
|
92
|
-
"@hanzogui/lucide-icons-2": "8.3.5"
|
|
92
|
+
"vitest": "4.1.11"
|
|
93
93
|
},
|
|
94
94
|
"repository": {
|
|
95
95
|
"type": "git",
|