@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/RightPane.js CHANGED
@@ -14,10 +14,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
14
14
  // OUTPUTS AND SOURCES ARE DERIVED FROM THE TURNS, never from a second list kept
15
15
  // in step with them. `LLM.md` already states this rule for `Work` and it is the
16
16
  // same rule: the conversation is the record, so a fenced block in an answer IS
17
- // an output and an image in a question IS a source. There is nothing to sync,
18
- // nothing to invalidate, and no count that can disagree with the transcript —
19
- // and switching channels costs no request, because the turns are already in
20
- // hand. A conversation that has produced nothing says so.
17
+ // an output, and an image in a question or a block in it that names a file IS a
18
+ // source. There is nothing to sync, nothing to invalidate, and no count that
19
+ // can disagree with the transcript — and switching channels costs no request,
20
+ // because the turns are already in hand. The one thing listed that is not yet
21
+ // a turn is what is held for the next message (`hold` in `pane.ts`), which a
22
+ // send spends into one. A conversation that has produced nothing says so.
21
23
  //
22
24
  // THE COLUMN IS STACKED, NOT SPLIT. The reference this follows puts a ~300px
23
25
  // summary beside a wide browser, which needs about 900px of column; the frame
@@ -33,7 +35,7 @@ import { ExternalLink, FileCode, Image as ImageMark, Paperclip, PanelRightClose,
33
35
  import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, View, XStack, YStack, } from '@hanzo/ui';
34
36
  import { Card, Quiet, Row } from './card.js';
35
37
  import { useOpen } from './open.js';
36
- import { address, channel, hold, name as titleOf, openTab, pickTab, pin, shutTab, usePane, } from './pane.js';
38
+ import { address, channel, name as titleOf, openTab, pickTab, pin, shutTab, take, usePane, } from './pane.js';
37
39
  import { useProviders, useSkills } from './tools.js';
38
40
  import { activate, startDevice } from './connect.js';
39
41
  /** How many rows a card shows before it offers the rest. */
@@ -115,7 +117,11 @@ function outputs(said) {
115
117
  });
116
118
  return out;
117
119
  }
118
- /** What went into this conversation: pictures asked about, and files brought in. */
120
+ /**
121
+ * What went into this conversation: pictures asked about, files sent fenced
122
+ * under their names (`compose` in `lib/attach`), and files held for the next
123
+ * message.
124
+ */
119
125
  function sources(said, held) {
120
126
  const out = [];
121
127
  said.forEach((m, turn) => {
@@ -124,15 +130,17 @@ function sources(said, held) {
124
130
  pictures(m).forEach((href, i) => {
125
131
  out.push({ id: `s${turn}.${i}`, name: 'Attached image', kind: 'image', href });
126
132
  });
133
+ let n = 0;
134
+ for (const [, info, body] of words(m).matchAll(FENCE)) {
135
+ n += 1;
136
+ const named = info.trim();
137
+ if (!suffix(named))
138
+ continue;
139
+ out.push({ id: `sf${turn}.${n}`, name: named, kind: 'file', body, mime: MIME[suffix(named)] ?? 'text/plain' });
140
+ }
127
141
  });
128
142
  for (const h of held) {
129
- out.push({
130
- id: h.id,
131
- name: h.name,
132
- kind: h.type.startsWith('image/') ? 'image' : 'file',
133
- href: h.href,
134
- mime: h.type,
135
- });
143
+ out.push({ id: h.id, name: h.name, kind: h.kind === 'image' ? 'image' : 'file', href: h.href, mime: h.type });
136
144
  }
137
145
  return out;
138
146
  }
@@ -238,23 +246,6 @@ const Note = ({ children }) => (_jsx(DropdownMenuLabel, { children: _jsx(Text, {
238
246
  function Add({ at }) {
239
247
  return (_jsxs(DropdownMenu, { placement: "bottom-end", maxHeight: 420, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Box, { render: "button", "aria-label": "Add to this conversation", p: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16, "aria-hidden": true }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsx(DropdownMenuItem, { onClick: () => take(at), children: _jsx(Word, { children: "Attach files" }) }), _jsx(DropdownMenuSeparator, {}), _jsx(Skills, {}), _jsx(DropdownMenuSeparator, {}), _jsx(Services, {})] })] }));
240
248
  }
241
- /**
242
- * Asks for files and takes what is given.
243
- *
244
- * The picker is the browser's, minted and dropped per press rather than kept as
245
- * a hidden input in the markup: a menu row is not a form control, and an
246
- * `<input type=file>` parked in the tree is one more thing for a screen reader
247
- * to walk past.
248
- */
249
- function take(at) {
250
- if (typeof document === 'undefined')
251
- return;
252
- const ask = document.createElement('input');
253
- ask.type = 'file';
254
- ask.multiple = true;
255
- ask.onchange = () => hold(at, Array.from(ask.files ?? []));
256
- ask.click();
257
- }
258
249
  /**
259
250
  * What this org's agents can reach for, as a section of this menu.
260
251
  *
package/dist/cast.d.ts CHANGED
@@ -24,18 +24,17 @@ interface Role {
24
24
  */
25
25
  ground?: string;
26
26
  /**
27
- * The voice the platform reads this character's replies in, by
28
- * /v1/audio/speech name.
27
+ * The voice the platform reads this character's replies in: a speech-service
28
+ * voice id, sent to /v1/audio/speech as it is. The first letter is the accent
29
+ * (a American, b British), the second the register (f female, m male).
29
30
  *
30
- * ONE VOICE PER CHARACTER, and never two who would be in the same room. The
31
- * service serves eleven and there are fourteen of them, so three must double
32
- * up — the question is which three, and the answer is people who work apart:
33
- * the five the demo puts in one call are all distinct. `echo` was reading for
34
- * three at once, which in a call is one person doing three parts.
31
+ * ONE VOICE PER CHARACTER, and never two who would be in the same room. A
32
+ * house part shares the voice of the character whose face it wears, so Hanzo
33
+ * Coder sounds like Dev; no two characters share one.
35
34
  *
36
35
  * CAST, NOT COPIED. These are the speech service's own voices, chosen for fit
37
- * — a warm animated reader for the physicist, something low and dry for the
38
- * founder. None of them is anybody's actual voice and none is trying to be:
36
+ * — a playful reader for the physicist, something low and measured for the
37
+ * quant. None of them is anybody's actual voice and none is trying to be:
39
38
  * synthesising a real person saying words they never said is a different act
40
39
  * from casting a part, and this is the second one.
41
40
  */
@@ -88,18 +87,22 @@ export declare function speakers(text: string, names: string[]): {
88
87
  who: string | null;
89
88
  text: string;
90
89
  }[];
90
+ /**
91
+ * Every American and British voice the speech service reads in, which is the
92
+ * pool an agent nobody cast is given a voice from. `am_santa` is left out: it
93
+ * is a character voice, not a reader.
94
+ */
91
95
  export declare const VOICES: string[];
96
+ /** A voice for a name nobody cast: the same name always gets the same one. */
92
97
  export declare function fallbackVoice(name: string | undefined): string;
93
98
  /** The voice to read a character's replies in. Always returns a valid voice. */
94
99
  export declare const voiceOf: (name: string | undefined) => string;
100
+ /** Who a voice sounds like, read off its id — what a browser voice standing in is matched on. */
95
101
  export interface VoiceProfile {
96
102
  voice: string;
97
- pitch: number;
98
- rate: number;
99
- description: string;
100
- gender: 'male' | 'female' | 'neutral';
103
+ gender: 'male' | 'female';
104
+ accent: 'american' | 'british';
101
105
  }
102
- export declare const VOICE_PROFILES: Record<string, VoiceProfile>;
103
106
  export declare function voiceProfileOf(name: string | undefined): VoiceProfile;
104
107
  export declare function fallbackMemoji(name: string | undefined): string;
105
108
  /**
package/dist/cast.js CHANGED
@@ -24,30 +24,30 @@ export const CAST = {
24
24
  //
25
25
  // Dev wears its own memoji (black ground baked in, so it fills the disc and
26
26
  // needs no rim colour); hanzo coder is that same face under the house name.
27
- dev: { portrait: '/agents/dev.png', voice: '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. */
@@ -5,34 +5,39 @@
5
5
  * model reads, and nothing else rides that wire, so a file goes one of two
6
6
  * ways: an image as a `data:` URI beside the words, a text file as its own
7
7
  * words after the message, fenced under its name. Any other kind is refused by
8
- * name, before anything is sent.
8
+ * name, before anything is held.
9
+ *
10
+ * This module judges and reads a file; `pane.ts` holds what it admits, per
11
+ * conversation, for the composer's chips and the column's Sources alike.
9
12
  */
10
13
  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;
14
+ /** What a file contributes to a message once admitted. */
15
+ export interface Admitted {
15
16
  kind: 'image' | 'text';
16
17
  /** A `data:` URI for an image, the words for a text file. */
17
18
  data: string;
18
- size: number;
19
19
  }
20
+ /** A file as it goes with a message. */
21
+ export type Going = Admitted & {
22
+ name: string;
23
+ };
20
24
  export declare const IMAGE_MAX: number;
21
25
  export declare const TEXT_MAX: number;
22
26
  /** Which way a file goes with a message, or null when it cannot go. */
23
27
  export declare function kindOf(file: {
24
28
  name: string;
25
29
  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>;
30
+ }): Admitted['kind'] | null;
31
+ /** A file read for a message, or the sentence that says why it cannot go. */
32
+ export declare function admit(file: File): Promise<Admitted | string>;
29
33
  /**
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.
34
+ * The message as sent: the words, then each text file fenced with its name as
35
+ * the info string — the shape a model writes a file in, and the one the
36
+ * column's Sources reads back out of the turn. With no words and no text file,
37
+ * the images' names, so the turn says what it carries.
33
38
  */
34
- export declare function compose(text: string, files: readonly Held[]): string;
39
+ export declare function compose(text: string, files: readonly Going[]): string;
35
40
  /** The images of a message, as the URIs @hanzo/ai's `send` takes. */
36
- export declare const images: (files: readonly Held[]) => string[];
41
+ export declare const images: (files: readonly Going[]) => string[];
37
42
  /** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
38
43
  export declare function partsOf(content: readonly unknown[]): MessagePart[];
@@ -5,7 +5,10 @@
5
5
  * model reads, and nothing else rides that wire, so a file goes one of two
6
6
  * ways: an image as a `data:` URI beside the words, a text file as its own
7
7
  * words after the message, fenced under its name. Any other kind is refused by
8
- * name, before anything is sent.
8
+ * name, before anything is held.
9
+ *
10
+ * This module judges and reads a file; `pane.ts` holds what it admits, per
11
+ * conversation, for the composer's chips and the column's Sources alike.
9
12
  */
10
13
  export const IMAGE_MAX = 8 * 1024 * 1024;
11
14
  export const TEXT_MAX = 256 * 1024;
@@ -30,8 +33,8 @@ function read(file, as) {
30
33
  reader.readAsText(file);
31
34
  });
32
35
  }
33
- /** A file read and held, or the sentence that says why it cannot go. */
34
- export async function hold(file) {
36
+ /** A file read for a message, or the sentence that says why it cannot go. */
37
+ export async function admit(file) {
35
38
  const kind = kindOf(file);
36
39
  if (!kind)
37
40
  return `${file.name}: only images and text files go with a message.`;
@@ -39,8 +42,7 @@ export async function hold(file) {
39
42
  if (file.size > max)
40
43
  return `${file.name} is larger than ${kind === 'image' ? '8 MB' : '256 KB'}.`;
41
44
  try {
42
- const data = await read(file, kind === 'image' ? 'url' : 'text');
43
- return { id: crypto.randomUUID(), name: file.name, kind, data, size: file.size };
45
+ return { kind, data: await read(file, kind === 'image' ? 'url' : 'text') };
44
46
  }
45
47
  catch {
46
48
  return `${file.name} could not be read.`;
@@ -52,16 +54,17 @@ function fence(words) {
52
54
  return '`'.repeat(Math.max(3, longest + 1));
53
55
  }
54
56
  /**
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.
57
+ * The message as sent: the words, then each text file fenced with its name as
58
+ * the info string — the shape a model writes a file in, and the one the
59
+ * column's Sources reads back out of the turn. With no words and no text file,
60
+ * the images' names, so the turn says what it carries.
58
61
  */
59
62
  export function compose(text, files) {
60
63
  const blocks = files
61
64
  .filter((f) => f.kind === 'text')
62
65
  .map((f) => {
63
66
  const f3 = fence(f.data);
64
- return `${f.name}\n${f3}\n${f.data.replace(/\n$/, '')}\n${f3}`;
67
+ return `${f3}${f.name}\n${f.data.replace(/\n$/, '')}\n${f3}`;
65
68
  });
66
69
  const out = [text.trim(), ...blocks].filter(Boolean).join('\n\n');
67
70
  return out || files.map((f) => f.name).join(', ');
@@ -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/pane.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Admitted } from './lib/attach.js';
1
2
  /** One open tab in the pane's browser. */
2
3
  export interface Leaf {
3
4
  id: string;
@@ -7,13 +8,14 @@ export interface Leaf {
7
8
  title: string;
8
9
  }
9
10
  /**
10
- * A file the reader put into this conversation from their own disk.
11
+ * A file the reader put into this conversation from their own disk, read and
12
+ * waiting to go with the next message.
11
13
  *
12
14
  * `href` is an object URL and is the reason this is never written down — see
13
15
  * the note at the top. Everything else is read off the `File` itself, so a name
14
16
  * and a size here are the file system's, never a guess.
15
17
  */
16
- export interface Held {
18
+ export interface Held extends Admitted {
17
19
  id: string;
18
20
  name: string;
19
21
  size: number;
@@ -29,6 +31,8 @@ export interface Pane {
29
31
  /** Whether the summary column stands beside the browser. */
30
32
  pinned: boolean;
31
33
  held: Held[];
34
+ /** Why the last files offered were refused, in the reader's words, or null. */
35
+ refused: string | null;
32
36
  }
33
37
  /**
34
38
  * The conversation a pane belongs to.
@@ -83,8 +87,31 @@ export declare function pickTab(key: string, id: string): void;
83
87
  export declare function shutTab(key: string, id: string): void;
84
88
  /** Shows or hides the summary column beside this conversation's browser. */
85
89
  export declare function pin(key: string, on: boolean): void;
86
- /** Takes files from the reader's disk into this conversation. */
87
- export declare function hold(key: string, files: File[]): void;
90
+ /**
91
+ * Takes files from the reader's disk into this conversation.
92
+ *
93
+ * Each is judged and read by `admit`; what is refused is said by name in
94
+ * `refused` and nothing of it is held, so a chip never stands for a file that
95
+ * cannot go.
96
+ */
97
+ export declare function hold(key: string, files: readonly File[]): Promise<void>;
98
+ /**
99
+ * Asks for files and holds what is given.
100
+ *
101
+ * The picker is the browser's, minted and dropped per press rather than kept as
102
+ * a hidden input in the markup: a menu row is not a form control, and an
103
+ * `<input type=file>` parked in the tree is one more thing for a screen reader
104
+ * to walk past.
105
+ */
106
+ export declare function take(key: string): void;
107
+ /**
108
+ * Hands over everything held to go with a message, and lets go of it.
109
+ *
110
+ * The turn carries each file from here — an image as its `data:` URI, a text
111
+ * file as its words. The object URLs are left standing, so a tab already open
112
+ * on one keeps showing it.
113
+ */
114
+ export declare function spend(key: string): Held[];
88
115
  /** Lets go of a file, and of the handle the browser minted for it. */
89
116
  export declare function drop(key: string, id: string): void;
90
117
  /** This conversation's pane, and it alone — a change to another redraws nothing
package/dist/pane.js CHANGED
@@ -20,10 +20,17 @@
20
20
  // with the document that minted it, so a persisted row would come back as a
21
21
  // name pointing at nothing. A row that looks like a file and opens nothing is
22
22
  // worse than no row, so they are held for the session and no longer.
23
+ //
24
+ // ONE WAY IN FOR A FILE. The composer's paperclip, a drop on the room, a paste
25
+ // into the field and the column's `+` all call `hold`, so a file is judged by
26
+ // one rule (`lib/attach`), shows as a chip over the field and a row under
27
+ // Sources at once, and goes with the next message. Sending spends what is
28
+ // held; from then on the turn itself is the record Sources reads.
23
29
  import { useSyncExternalStore } from 'react';
30
+ import { admit } from './lib/attach.js';
24
31
  /** A pane nobody has touched. Shared, and never mutated — see `EMPTY` in
25
32
  * `open.ts` for why a fresh object per read is an infinite render. */
26
- const BARE = { tabs: [], at: null, pinned: true, held: [] };
33
+ const BARE = { tabs: [], at: null, pinned: true, held: [], refused: null };
27
34
  const where = (key) => `hanzo.pane.${key}`;
28
35
  /**
29
36
  * The conversation a pane belongs to.
@@ -90,6 +97,7 @@ function read(key) {
90
97
  at: tabs.some((t) => t.id === kept.at) ? kept.at : (tabs[0]?.id ?? null),
91
98
  pinned: kept.pinned !== false,
92
99
  held: [],
100
+ refused: null,
93
101
  };
94
102
  }
95
103
  }
@@ -201,18 +209,58 @@ export function shutTab(key, id) {
201
209
  export function pin(key, on) {
202
210
  edit(key, (was) => (was.pinned === on ? was : { ...was, pinned: on }));
203
211
  }
204
- /** Takes files from the reader's disk into this conversation. */
205
- export function hold(key, files) {
212
+ /**
213
+ * Takes files from the reader's disk into this conversation.
214
+ *
215
+ * Each is judged and read by `admit`; what is refused is said by name in
216
+ * `refused` and nothing of it is held, so a chip never stands for a file that
217
+ * cannot go.
218
+ */
219
+ export async function hold(key, files) {
206
220
  if (!files.length)
207
221
  return;
208
- const taken = files.map((f) => ({
209
- id: mint('h'),
210
- name: f.name,
211
- size: f.size,
212
- type: f.type,
213
- href: URL.createObjectURL(f),
214
- }));
215
- edit(key, (was) => ({ ...was, held: [...was.held, ...taken] }));
222
+ const judged = await Promise.all(files.map(admit));
223
+ const taken = [];
224
+ const refused = [];
225
+ judged.forEach((one, i) => {
226
+ const file = files[i];
227
+ if (typeof one === 'string')
228
+ refused.push(one);
229
+ else
230
+ taken.push({ id: mint('h'), name: file.name, size: file.size, type: file.type, href: URL.createObjectURL(file), ...one });
231
+ });
232
+ edit(key, (was) => ({ ...was, held: [...was.held, ...taken], refused: refused.length ? refused.join(' ') : null }));
233
+ }
234
+ /**
235
+ * Asks for files and holds what is given.
236
+ *
237
+ * The picker is the browser's, minted and dropped per press rather than kept as
238
+ * a hidden input in the markup: a menu row is not a form control, and an
239
+ * `<input type=file>` parked in the tree is one more thing for a screen reader
240
+ * to walk past.
241
+ */
242
+ export function take(key) {
243
+ if (typeof document === 'undefined')
244
+ return;
245
+ const ask = document.createElement('input');
246
+ ask.type = 'file';
247
+ ask.multiple = true;
248
+ ask.onchange = () => void hold(key, Array.from(ask.files ?? []));
249
+ ask.click();
250
+ }
251
+ /**
252
+ * Hands over everything held to go with a message, and lets go of it.
253
+ *
254
+ * The turn carries each file from here — an image as its `data:` URI, a text
255
+ * file as its words. The object URLs are left standing, so a tab already open
256
+ * on one keeps showing it.
257
+ */
258
+ export function spend(key) {
259
+ const was = read(key);
260
+ if (!was.held.length && !was.refused)
261
+ return [];
262
+ write(key, { ...was, held: [], refused: null });
263
+ return was.held;
216
264
  }
217
265
  /** Lets go of a file, and of the handle the browser minted for it. */
218
266
  export function drop(key, id) {
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;