@hanzo/rooms 0.1.3 → 0.1.5
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 +57 -53
- package/dist/Meet.js +91 -148
- package/dist/RightPane.js +21 -30
- package/dist/cast.d.ts +17 -14
- package/dist/cast.js +39 -49
- package/dist/lib/attach.d.ts +19 -14
- package/dist/lib/attach.js +12 -9
- package/dist/lib/limits.d.ts +9 -0
- package/dist/lib/limits.js +6 -0
- package/dist/pane.d.ts +31 -4
- package/dist/pane.js +59 -11
- package/dist/speech.d.ts +23 -1
- package/dist/speech.js +70 -79
- package/package.json +6 -6
package/dist/Chat.js
CHANGED
|
@@ -33,22 +33,23 @@ import { Prose } from "./Prose.js";
|
|
|
33
33
|
import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
|
|
34
34
|
import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
|
|
35
35
|
import { ASK, Chat as ChatSurface, Code, Failure, Parts, words } from "@hanzo/ui/chat";
|
|
36
|
-
import { compose,
|
|
36
|
+
import { compose, images, partsOf } from "./lib/attach.js";
|
|
37
|
+
import { channel, drop, hold, spend, take, usePane } from "./pane.js";
|
|
37
38
|
import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
|
|
38
|
-
import { SpeakButton,
|
|
39
|
+
import { SpeakButton, cleanForSpeech } from "./speech.js";
|
|
39
40
|
import { Crew, HOUSE, useCrew } from "./crew.js";
|
|
40
41
|
import { called } from "./team.js";
|
|
41
|
-
import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
|
|
42
|
+
import { Anchor, Box, Button, Text, Tooltip, TooltipContent, TooltipTrigger, View, XStack, YStack } from "@hanzo/ui";
|
|
42
43
|
import { Control } from "@hanzo/composer";
|
|
43
44
|
import { useIam, useIamToken } from "@hanzo/iam/react";
|
|
44
45
|
import { hasSession, org } from "./lib/session.js";
|
|
45
|
-
import { clock, sku, useLimits } from "./lib/limits.js";
|
|
46
|
+
import { chats, clock, sku, useLimits } from "./lib/limits.js";
|
|
46
47
|
import { Meters } from "./meters.js";
|
|
47
48
|
import { useHydrated } from "./lib/hydrated.js";
|
|
48
49
|
import { base, served } from "./lib/ai.js";
|
|
49
50
|
import { onServed } from "./lib/served.js";
|
|
50
51
|
import { speech, useVoice, Voice } from "@hanzo/voice";
|
|
51
|
-
import { ArrowUp, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
52
|
+
import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
52
53
|
import { ENSO, FREE } from "./lib/ai.js";
|
|
53
54
|
import { openThread, useOpen } from "./open.js";
|
|
54
55
|
import { Beside, Framed } from "./Shell.js";
|
|
@@ -331,7 +332,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
331
332
|
const ai = useAi();
|
|
332
333
|
// In the app's frame the composer floats on the pane as a pane of its own.
|
|
333
334
|
const framed = useContext(Framed);
|
|
334
|
-
const
|
|
335
|
+
const opened = useOpen();
|
|
336
|
+
const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = opened;
|
|
337
|
+
// The conversation's key in `pane.ts`, the one the column beside it reads.
|
|
338
|
+
const at = channel(opened);
|
|
335
339
|
// The first of them: what a surface that addresses one person reads.
|
|
336
340
|
const withWhom = room[0] ?? null;
|
|
337
341
|
// Whether the column beside this conversation is showing its summary, and the
|
|
@@ -431,7 +435,11 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
431
435
|
// bare "not found", after it has already recorded the question. `served`
|
|
432
436
|
// keeps the house name where it is served and otherwise falls to the first
|
|
433
437
|
// model that is, so a room on any catalogue can be spoken to.
|
|
434
|
-
|
|
438
|
+
// A SPEECH MODEL NEVER ANSWERS A CHAT TURN. The catalog lists the
|
|
439
|
+
// transcriber and the voice beside the chat models; one that names its
|
|
440
|
+
// outputs and names no text (`chats`) is neither offered nor fallen back to.
|
|
441
|
+
const { models: catalog } = useModels();
|
|
442
|
+
const models = useMemo(() => catalog.filter(chats), [catalog]);
|
|
435
443
|
// HANZO MODELS ALONE are offered here, to everyone: an Enso or Zen id
|
|
436
444
|
// (`sku`). A preference kept from before for any other model is not one this
|
|
437
445
|
// room offers, so the house router is asked for instead, and `served` keeps
|
|
@@ -597,60 +605,50 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
597
605
|
// passing value/onChange/onSend takes that ownership over rather than fighting
|
|
598
606
|
// it.
|
|
599
607
|
const [draft, setDraft] = useState("");
|
|
600
|
-
// FILES WITH THE NEXT MESSAGE
|
|
601
|
-
// anywhere on the room
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
608
|
+
// FILES WITH THE NEXT MESSAGE, held by `pane.ts` for this conversation: the
|
|
609
|
+
// paperclip, a drop anywhere on the room, a paste into the field and the
|
|
610
|
+
// column's `+` all go through its one `hold`, so the chips here and the rows
|
|
611
|
+
// under Sources are one list. An image rides as a picture the model reads, a
|
|
612
|
+
// text file as its words (lib/attach); anything else is refused by name.
|
|
613
|
+
const { held: files, refused: turnedAway } = usePane(at);
|
|
605
614
|
const [dragging, setDragging] = useState(false);
|
|
606
|
-
const picker = useRef(null);
|
|
607
615
|
const outgoing = useRef([]);
|
|
608
616
|
const depth = useRef(0);
|
|
609
|
-
const take = useCallback(async (list) => {
|
|
610
|
-
if (!list?.length)
|
|
611
|
-
return;
|
|
612
|
-
const out = await Promise.all(Array.from(list).map(hold));
|
|
613
|
-
setFiles((now) => [...now, ...out.filter((one) => typeof one !== "string")]);
|
|
614
|
-
const refused = out.filter((one) => typeof one === "string");
|
|
615
|
-
setFileSaid(refused.length ? refused.join(" ") : null);
|
|
616
|
-
}, []);
|
|
617
617
|
useSeed(initial, setDraft);
|
|
618
|
-
// DICTATION
|
|
619
|
-
// to
|
|
618
|
+
// DICTATION through Hanzo's own ear: the mic records and each pause is
|
|
619
|
+
// posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
|
|
620
|
+
// the Hanzo gateway and `base()` is the one place a host is read from, so
|
|
620
621
|
// pointing a local cloud at one points both. The token is the reader's own —
|
|
621
|
-
// the package never invents a credential — and
|
|
622
|
-
//
|
|
622
|
+
// the package never invents a credential — and a refusal is worn by
|
|
623
|
+
// `<Voice/>`, never passed off as a transcript.
|
|
623
624
|
const { token } = useIamToken();
|
|
624
|
-
const
|
|
625
|
-
const ear = useMemo(() => speech({
|
|
626
|
-
...base(),
|
|
627
|
-
...(token ? { token } : {}),
|
|
628
|
-
voice: { name: agentVoice },
|
|
629
|
-
}), [token, agentVoice]);
|
|
625
|
+
const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
|
|
630
626
|
const voice = useVoice({
|
|
631
627
|
speech: ear,
|
|
632
628
|
onPartial: setDraft,
|
|
633
629
|
onUtterance: setDraft,
|
|
634
630
|
});
|
|
635
|
-
//
|
|
631
|
+
// WHILE THE MIC IS OPEN, A FINISHED REPLY IS READ ALOUD by the platform's
|
|
632
|
+
// voice, through the conversation's own mouth so speaking over it stops it.
|
|
633
|
+
// A room's reply is read part by part, each in its speaker's cast voice.
|
|
636
634
|
const wasStreaming = useRef(false);
|
|
637
635
|
useEffect(() => {
|
|
638
636
|
if (wasStreaming.current && !chat.streaming && voice.open) {
|
|
639
637
|
const msgs = chat.messages;
|
|
640
638
|
const lastMsg = msgs[msgs.length - 1];
|
|
641
639
|
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
640
|
const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
641
|
+
void (async () => {
|
|
642
|
+
for (const part of parts) {
|
|
643
|
+
const said = cleanForSpeech(part.text);
|
|
644
|
+
if (said)
|
|
645
|
+
await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined));
|
|
646
|
+
}
|
|
647
|
+
})();
|
|
650
648
|
}
|
|
651
649
|
}
|
|
652
650
|
wasStreaming.current = chat.streaming;
|
|
653
|
-
}, [chat.streaming, voice.open, chat.messages, withWhom, room]);
|
|
651
|
+
}, [chat.streaming, voice.open, voice.say, chat.messages, withWhom, room]);
|
|
654
652
|
// A SEND THAT WAITS ONE RENDER. `send` closes over the turns it was rendered
|
|
655
653
|
// with, so a send that first tidies the thread is held here and made from the
|
|
656
654
|
// next render, where the tidied turns are the ones it sends.
|
|
@@ -796,11 +794,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
796
794
|
const text = draft.trim();
|
|
797
795
|
if (!text && !files.length)
|
|
798
796
|
return;
|
|
799
|
-
|
|
797
|
+
const going = spend(at);
|
|
798
|
+
outgoing.current = images(going);
|
|
800
799
|
setDraft("");
|
|
801
|
-
|
|
802
|
-
setFileSaid(null);
|
|
803
|
-
guarded(compose(text, files));
|
|
800
|
+
guarded(compose(text, going));
|
|
804
801
|
};
|
|
805
802
|
/*
|
|
806
803
|
WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
|
|
@@ -910,8 +907,8 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
910
907
|
if (!e.dataTransfer.files?.length)
|
|
911
908
|
return;
|
|
912
909
|
e.preventDefault();
|
|
913
|
-
void
|
|
914
|
-
}, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop to attach" }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
|
|
910
|
+
void hold(at, Array.from(e.dataTransfer.files));
|
|
911
|
+
}, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
|
|
915
912
|
if (aside === null) {
|
|
916
913
|
showAside("tasks");
|
|
917
914
|
if (!pinned)
|
|
@@ -1034,15 +1031,22 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
1034
1031
|
// package's button and `streaming` still has to become stop; it keeps
|
|
1035
1032
|
// `data-slot="composer-send"` so a driver finds it, and it is the same
|
|
1036
1033
|
// `Control` circle as the mic, filled.
|
|
1037
|
-
// THE PAPERCLIP
|
|
1038
|
-
// the
|
|
1039
|
-
children: (_jsxs(
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1034
|
+
// THE PAPERCLIP is the column's "Attach files", one press closer: the
|
|
1035
|
+
// same `take`, into the same `hold`.
|
|
1036
|
+
children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
|
|
1037
|
+
// A file or an image pasted into the field is held like a picked one;
|
|
1038
|
+
// words paste as words.
|
|
1039
|
+
field: {
|
|
1040
|
+
onPaste: (e) => {
|
|
1041
|
+
if (!e.clipboardData?.files?.length)
|
|
1042
|
+
return;
|
|
1043
|
+
e.preventDefault();
|
|
1044
|
+
void hold(at, Array.from(e.clipboardData.files));
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1043
1047
|
// WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
|
|
1044
1048
|
// with its own remove, and the reason any file was refused.
|
|
1045
|
-
head: files.length ||
|
|
1049
|
+
head: files.length || turnedAway ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [one.kind === "image" ? (_jsx(ImageMark, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })) : (_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => drop(at, one.id), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null] })) : undefined,
|
|
1046
1050
|
send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
|
|
1047
1051
|
// WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
|
|
1048
1052
|
// plan holder only: Free keeps the free lane's own notice.
|
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);
|