@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/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: 'ash' },
28
- des: { portrait: '/agents/des.png', ground: '#74569F', voice: 'echo' },
29
- vi: { portrait: '/agents/vi.png', ground: '#41649F', voice: 'sage' },
30
- antje: { portrait: '/agents/antje.jpg', voice: 'nova' },
31
- zach: { portrait: '/agents/zach.jpg', voice: 'onyx' },
32
- feynman: { portrait: '/agents/feynman.png', ground: '#9C7C4F', voice: 'fable' },
33
- einstein: { portrait: '/agents/einstein.png', ground: '#7D6450', voice: 'verse' },
34
- nora: { portrait: '/agents/nora.png', voice: 'shimmer' },
35
- creative: { portrait: '/agents/creative.png', voice: 'ballad' },
36
- maya: { portrait: '/agents/maya.png', voice: 'coral' },
37
- leo: { portrait: '/agents/leo.png', voice: 'alloy' },
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: 'ash' },
41
- 'hanzo researcher': { portrait: '/agents/feynman.png', voice: 'fable' },
42
- 'hanzo designer': { portrait: '/agents/des.png', voice: 'echo' },
43
- 'hanzo devops': { voice: 'sage' },
44
- 'hanzo quant': { voice: 'onyx' },
45
- 'hanzo copywriter': { portrait: '/agents/creative.png', voice: 'ballad' },
46
- 'hanzo legal & compliance': { portrait: '/agents/einstein.png', voice: 'verse' },
47
- 'hanzo executive assistant': { portrait: '/agents/maya.png', voice: 'coral' },
48
- 'hanzo architect': { voice: 'verse' },
49
- 'hanzo vision': { voice: 'ballad' },
50
- 'hanzo support': { portrait: '/agents/nora.png', voice: 'shimmer' },
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
- 'ash',
137
- 'echo',
138
- 'sage',
139
- 'nova',
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 VOICES[0];
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 v = voiceOf(name);
175
- return VOICE_PROFILES[v] || VOICE_PROFILES.ash;
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: [_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" })] }) }), ordered(agents).map((one) => {
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
- * (app/globals.css): a corner, an edge, the lens behind it, and the second
6
- * paper rung's lit edge and drop. The fill is the caller's rung: `--pane-fill`
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-2)';
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-strong), transparent), var(--pane-ground)';
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
- * (app/globals.css): a corner, an edge, the lens behind it, and the second
6
- * paper rung's lit edge and drop. The fill is the caller's rung: `--pane-fill`
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-2)',
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-strong), transparent), var(--pane-ground)',
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
+ }
@@ -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. */
@@ -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 & Voice Synthesis
3
+ // Agent Speech
4
4
  //
5
5
  // Every agent in the Hanzo ecosystem has an assigned voice (`voiceOf(name)` from
6
- // `cast.tsx`). This module provides speech synthesis for agents across Chat,
7
- // Talk channels, and Meet video calls:
8
- // - Direct browser SpeechSynthesis with agent-tailored pitch, rate, and voice matching
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
- /** Currently active speech synthesis utterance tracker. */
45
- let activeUtterance = null;
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
- if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
49
- try {
50
- window.speechSynthesis.cancel();
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 utterance = new SpeechSynthesisUtterance(clean);
76
- utterance.pitch = profile.pitch;
77
- utterance.rate = profile.rate;
78
- // Attempt to select best matching system voice based on gender / voice tone
79
- try {
80
- const voices = window.speechSynthesis.getVoices();
81
- if (voices.length > 0) {
82
- const isFemale = profile.gender === 'female';
83
- const candidate = voices.find((v) => {
84
- const name = v.name.toLowerCase();
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
- for (const notify of activeListeners) {
111
- notify();
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 (activeUtterance === utterance) {
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})`, "aria-pressed": speaking, title: speaking ? 'Stop speaking' : `Listen in ${agentName || 'agent'}'s voice (${voice})`, 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'] })) }));
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", overflow: "scroll", borderBottomWidth: 1, borderColor: "$borderColor", children: [tabs.map((one) => {
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,