@hanzo/rooms 0.1.2 → 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/Beluga.js +1 -1
- package/dist/Board.js +5 -5
- package/dist/Boxes.js +1 -1
- package/dist/Chat.js +101 -46
- package/dist/Contacts.js +1 -1
- package/dist/Directory.js +1 -1
- package/dist/Drive.js +1 -1
- package/dist/Home.js +1 -1
- package/dist/Inbox.js +2 -2
- package/dist/Market.js +1 -1
- package/dist/Me.js +1 -1
- package/dist/Meet.js +91 -148
- package/dist/Profile.js +1 -1
- package/dist/RightPane.js +4 -4
- package/dist/Settings.js +1 -1
- package/dist/Shell.js +6 -2
- package/dist/Talk.js +1 -1
- package/dist/Vibe.js +2 -2
- package/dist/Work.js +2 -2
- package/dist/cast.d.ts +17 -14
- package/dist/cast.js +39 -49
- package/dist/crew.js +2 -2
- package/dist/ground.d.ts +6 -4
- package/dist/ground.js +6 -4
- package/dist/lib/attach.d.ts +38 -0
- package/dist/lib/attach.js +82 -0
- 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/dist/tabs.js +1 -1
- package/package.json +8 -8
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/crew.js
CHANGED
|
@@ -98,11 +98,11 @@ export function Crew({ to } = {}) {
|
|
|
98
98
|
if (here !== to)
|
|
99
99
|
router.push(to);
|
|
100
100
|
};
|
|
101
|
-
return (_jsxs(_Fragment, { children: [_jsxs(XStack, { items: "flex-start", flexWrap: "nowrap", overflowX: "auto", gap: "$2.5", px: "$4", py: "$3", width: "100%", maxW: 820, mx: "auto", "data-slot": "crew", ...bare, role: "group", "aria-label": "Agents", children: [
|
|
101
|
+
return (_jsxs(_Fragment, { children: [_jsxs(XStack, { items: "flex-start", flexWrap: "nowrap", overflowX: "auto", overflowY: "hidden", gap: "$2.5", px: "$4", py: "$3", width: "100%", maxW: 820, mx: "auto", "data-slot": "crew", ...bare, role: "group", "aria-label": "Agents", children: [ordered(agents).map((one) => {
|
|
102
102
|
const name = called(one.name);
|
|
103
103
|
const on = room.some((there) => there.toLowerCase() === one.name.toLowerCase());
|
|
104
104
|
return (_jsx(Box, { render: "button", onClick: () => talk(name), "aria-pressed": on, "aria-label": `Chat with ${name}`, title: to ? `Chat with ${name}` : on ? `Remove ${name} from this chat` : `Add ${name} to this chat`, shrink: 0, children: _jsxs(YStack, { items: "center", gap: "$1.5", width: 68, children: [_jsxs(YStack, { position: "relative", rounded: DISC / 2 + 2, p: 2, borderWidth: 1.5, borderColor: on ? '$ink' : 'transparent', hoverStyle: { borderColor: on ? '$ink' : '$borderColor' }, children: [_jsx(Face, { src: one.avatar, emoji: one.emoji, name: one.name, size: DISC }), on ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", b: -2, r: -2, width: 18, height: 18, rounded: 9, bg: "$ink", borderWidth: 2, borderColor: "$background", items: "center", justify: "center", children: _jsx(Check, { size: 10, strokeWidth: 3, color: "var(--background)" }) })) : null] }), _jsx(Text, { fontSize: "$1", color: on ? '$ink' : '$soft', numberOfLines: 1, maxW: 68, children: name })] }) }, one.id || one.name));
|
|
105
|
-
})] }), hiring ? _jsx(Hire, { onClose: () => setHiring(false), onMade: talk }) : null] }));
|
|
105
|
+
}), _jsx(Box, { render: "button", onClick: () => setHiring(true), "aria-label": "New agent", "aria-haspopup": "dialog", shrink: 0, children: _jsxs(YStack, { items: "center", gap: "$1.5", width: 68, children: [_jsx(YStack, { width: DISC, height: DISC, rounded: DISC / 2, borderWidth: 1.5, borderStyle: "dashed", borderColor: "$soft", items: "center", justify: "center", hoverStyle: { borderColor: '$ink', bg: '$raised' }, children: _jsx(Plus, { size: 18, "aria-hidden": true }) }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: "New" })] }) })] }), hiring ? _jsx(Hire, { onClose: () => setHiring(false), onMade: talk }) : null] }));
|
|
106
106
|
}
|
|
107
107
|
/** The agents API's name rule (cloud apps/agents `nameRE`), checked before the request. */
|
|
108
108
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
package/dist/ground.d.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* The ground the workspace's cards stand on, and how each card is cut.
|
|
3
3
|
*
|
|
4
4
|
* A PANE is a card floating on the ground, cut by the `--pane-*` tokens
|
|
5
|
-
* (
|
|
6
|
-
* paper rung's lit edge and drop
|
|
5
|
+
* (@hanzo/design elevation.css): a corner, an edge, the lens behind it, and the
|
|
6
|
+
* first paper rung's lit edge and drop — the second's 15px drop and the floor's
|
|
7
|
+
* strong glass read as a gradient through a translucent pane, so both stay one
|
|
8
|
+
* rung down. The fill is the caller's rung: `--pane-fill`
|
|
7
9
|
* in the app's frame, `sheet(1)`/`sheet(2)` where a pane lies in a room.
|
|
8
10
|
*
|
|
9
11
|
* The drop is a prop and not `sheet(2)`'s `elevation-2` class, whose shadow is
|
|
@@ -16,7 +18,7 @@ export declare const pane: (fill?: string) => {
|
|
|
16
18
|
readonly borderWidth: 1;
|
|
17
19
|
readonly borderColor: 'var(--pane-edge)';
|
|
18
20
|
readonly backdropFilter: 'var(--pane-blur)';
|
|
19
|
-
readonly boxShadow: 'var(--shadow-sheet-
|
|
21
|
+
readonly boxShadow: 'var(--shadow-sheet-1)';
|
|
20
22
|
};
|
|
21
23
|
/**
|
|
22
24
|
* THE FLOOR under the app's frame (app/_web.tsx, components/settings/Frame.tsx):
|
|
@@ -26,7 +28,7 @@ export declare const pane: (fill?: string) => {
|
|
|
26
28
|
*/
|
|
27
29
|
export declare const floor: {
|
|
28
30
|
readonly backgroundColor: 'var(--pane-floor)';
|
|
29
|
-
readonly backgroundImage: 'linear-gradient(to bottom, var(--glass
|
|
31
|
+
readonly backgroundImage: 'linear-gradient(to bottom, var(--glass), transparent), var(--pane-ground)';
|
|
30
32
|
readonly backgroundSize: '100% 70%, auto';
|
|
31
33
|
readonly backgroundRepeat: 'no-repeat';
|
|
32
34
|
};
|
package/dist/ground.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* The ground the workspace's cards stand on, and how each card is cut.
|
|
3
3
|
*
|
|
4
4
|
* A PANE is a card floating on the ground, cut by the `--pane-*` tokens
|
|
5
|
-
* (
|
|
6
|
-
* paper rung's lit edge and drop
|
|
5
|
+
* (@hanzo/design elevation.css): a corner, an edge, the lens behind it, and the
|
|
6
|
+
* first paper rung's lit edge and drop — the second's 15px drop and the floor's
|
|
7
|
+
* strong glass read as a gradient through a translucent pane, so both stay one
|
|
8
|
+
* rung down. The fill is the caller's rung: `--pane-fill`
|
|
7
9
|
* in the app's frame, `sheet(1)`/`sheet(2)` where a pane lies in a room.
|
|
8
10
|
*
|
|
9
11
|
* The drop is a prop and not `sheet(2)`'s `elevation-2` class, whose shadow is
|
|
@@ -16,7 +18,7 @@ export const pane = (fill = 'var(--pane-fill)') => ({
|
|
|
16
18
|
borderWidth: 1,
|
|
17
19
|
borderColor: 'var(--pane-edge)',
|
|
18
20
|
backdropFilter: 'var(--pane-blur)',
|
|
19
|
-
boxShadow: 'var(--shadow-sheet-
|
|
21
|
+
boxShadow: 'var(--shadow-sheet-1)',
|
|
20
22
|
});
|
|
21
23
|
/**
|
|
22
24
|
* THE FLOOR under the app's frame (app/_web.tsx, components/settings/Frame.tsx):
|
|
@@ -26,7 +28,7 @@ export const pane = (fill = 'var(--pane-fill)') => ({
|
|
|
26
28
|
*/
|
|
27
29
|
export const floor = {
|
|
28
30
|
backgroundColor: 'var(--pane-floor)',
|
|
29
|
-
backgroundImage: 'linear-gradient(to bottom, var(--glass
|
|
31
|
+
backgroundImage: 'linear-gradient(to bottom, var(--glass), transparent), var(--pane-ground)',
|
|
30
32
|
backgroundSize: '100% 70%, auto',
|
|
31
33
|
backgroundRepeat: 'no-repeat',
|
|
32
34
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files that go with a chat message.
|
|
3
|
+
*
|
|
4
|
+
* @hanzo/ai's `send(text, images)` carries a picture as an `image_url` part the
|
|
5
|
+
* model reads, and nothing else rides that wire, so a file goes one of two
|
|
6
|
+
* ways: an image as a `data:` URI beside the words, a text file as its own
|
|
7
|
+
* words after the message, fenced under its name. Any other kind is refused by
|
|
8
|
+
* name, before anything is sent.
|
|
9
|
+
*/
|
|
10
|
+
import type { MessagePart } from '@hanzo/ui/chat';
|
|
11
|
+
/** A file held for the next message. */
|
|
12
|
+
export interface Held {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
kind: 'image' | 'text';
|
|
16
|
+
/** A `data:` URI for an image, the words for a text file. */
|
|
17
|
+
data: string;
|
|
18
|
+
size: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const IMAGE_MAX: number;
|
|
21
|
+
export declare const TEXT_MAX: number;
|
|
22
|
+
/** Which way a file goes with a message, or null when it cannot go. */
|
|
23
|
+
export declare function kindOf(file: {
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
}): Held['kind'] | null;
|
|
27
|
+
/** A file read and held, or the sentence that says why it cannot go. */
|
|
28
|
+
export declare function hold(file: File): Promise<Held | string>;
|
|
29
|
+
/**
|
|
30
|
+
* The message as sent: the words, then each text file fenced under its name.
|
|
31
|
+
* With no words and no text file, the images' names, so the turn says what it
|
|
32
|
+
* carries.
|
|
33
|
+
*/
|
|
34
|
+
export declare function compose(text: string, files: readonly Held[]): string;
|
|
35
|
+
/** The images of a message, as the URIs @hanzo/ai's `send` takes. */
|
|
36
|
+
export declare const images: (files: readonly Held[]) => string[];
|
|
37
|
+
/** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
|
|
38
|
+
export declare function partsOf(content: readonly unknown[]): MessagePart[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files that go with a chat message.
|
|
3
|
+
*
|
|
4
|
+
* @hanzo/ai's `send(text, images)` carries a picture as an `image_url` part the
|
|
5
|
+
* model reads, and nothing else rides that wire, so a file goes one of two
|
|
6
|
+
* ways: an image as a `data:` URI beside the words, a text file as its own
|
|
7
|
+
* words after the message, fenced under its name. Any other kind is refused by
|
|
8
|
+
* name, before anything is sent.
|
|
9
|
+
*/
|
|
10
|
+
export const IMAGE_MAX = 8 * 1024 * 1024;
|
|
11
|
+
export const TEXT_MAX = 256 * 1024;
|
|
12
|
+
const TEXT_TYPE = /^(text\/|application\/(json|ld\+json|xml|x-yaml|yaml|toml|javascript|typescript|x-sh|sql|x-tex))/;
|
|
13
|
+
const TEXT_NAME = /\.(md|mdx|txt|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|scss|[cm]?[jt]sx?|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|sql|ini|env|log|tex|proto|graphql)$/i;
|
|
14
|
+
/** Which way a file goes with a message, or null when it cannot go. */
|
|
15
|
+
export function kindOf(file) {
|
|
16
|
+
if (file.type.startsWith('image/'))
|
|
17
|
+
return 'image';
|
|
18
|
+
if (TEXT_TYPE.test(file.type) || TEXT_NAME.test(file.name))
|
|
19
|
+
return 'text';
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function read(file, as) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const reader = new FileReader();
|
|
25
|
+
reader.onload = () => resolve(String(reader.result ?? ''));
|
|
26
|
+
reader.onerror = () => reject(reader.error ?? new Error(`${file.name} could not be read.`));
|
|
27
|
+
if (as === 'url')
|
|
28
|
+
reader.readAsDataURL(file);
|
|
29
|
+
else
|
|
30
|
+
reader.readAsText(file);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** A file read and held, or the sentence that says why it cannot go. */
|
|
34
|
+
export async function hold(file) {
|
|
35
|
+
const kind = kindOf(file);
|
|
36
|
+
if (!kind)
|
|
37
|
+
return `${file.name}: only images and text files go with a message.`;
|
|
38
|
+
const max = kind === 'image' ? IMAGE_MAX : TEXT_MAX;
|
|
39
|
+
if (file.size > max)
|
|
40
|
+
return `${file.name} is larger than ${kind === 'image' ? '8 MB' : '256 KB'}.`;
|
|
41
|
+
try {
|
|
42
|
+
const data = await read(file, kind === 'image' ? 'url' : 'text');
|
|
43
|
+
return { id: crypto.randomUUID(), name: file.name, kind, data, size: file.size };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return `${file.name} could not be read.`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** A fence longer than any run of backticks inside the words, so they cannot close it. */
|
|
50
|
+
function fence(words) {
|
|
51
|
+
const longest = Math.max(0, ...(words.match(/`+/g) ?? []).map((run) => run.length));
|
|
52
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The message as sent: the words, then each text file fenced under its name.
|
|
56
|
+
* With no words and no text file, the images' names, so the turn says what it
|
|
57
|
+
* carries.
|
|
58
|
+
*/
|
|
59
|
+
export function compose(text, files) {
|
|
60
|
+
const blocks = files
|
|
61
|
+
.filter((f) => f.kind === 'text')
|
|
62
|
+
.map((f) => {
|
|
63
|
+
const f3 = fence(f.data);
|
|
64
|
+
return `${f.name}\n${f3}\n${f.data.replace(/\n$/, '')}\n${f3}`;
|
|
65
|
+
});
|
|
66
|
+
const out = [text.trim(), ...blocks].filter(Boolean).join('\n\n');
|
|
67
|
+
return out || files.map((f) => f.name).join(', ');
|
|
68
|
+
}
|
|
69
|
+
/** The images of a message, as the URIs @hanzo/ai's `send` takes. */
|
|
70
|
+
export const images = (files) => files.filter((f) => f.kind === 'image').map((f) => f.data);
|
|
71
|
+
/** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
|
|
72
|
+
export function partsOf(content) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const one of content) {
|
|
75
|
+
const part = one;
|
|
76
|
+
if (part?.type === 'text' && part.text)
|
|
77
|
+
out.push({ type: 'text', text: part.text });
|
|
78
|
+
else if (part?.type === 'image_url' && part.image_url?.url)
|
|
79
|
+
out.push({ type: 'image', url: part.image_url.url, alt: 'Attached image' });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
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/dist/tabs.js
CHANGED
|
@@ -13,7 +13,7 @@ import { Box, Text, XStack } from '@hanzo/ui';
|
|
|
13
13
|
* `after` is what sits past the last tab: the plus that would add one.
|
|
14
14
|
*/
|
|
15
15
|
export function Tabs({ tabs, chosen, onPick, after, }) {
|
|
16
|
-
return (_jsxs(XStack, { minH: 48, shrink: 0, items: "center", px: "$3", gap: "$3",
|
|
16
|
+
return (_jsxs(XStack, { minH: 48, shrink: 0, items: "center", px: "$3", gap: "$3", overflowX: "auto", overflowY: "hidden", borderBottomWidth: 1, borderColor: "$borderColor", children: [tabs.map((one) => {
|
|
17
17
|
const on = one.id === chosen;
|
|
18
18
|
const Icon = one.icon;
|
|
19
19
|
return (_jsx(Box, { ...(onPick ? { render: 'button', onClick: () => onPick(one.id) } : {}), "aria-current": on,
|