@alia.onl/sdk 13.1.0 → 13.2.0
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/CHANGELOG.md +61 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/src/components/AliaChatContent.tsx +53 -100
- package/src/components/AliaChatScreen.tsx +27 -24
- package/src/components/AliaChatSheet.tsx +15 -4
- package/src/components/AliaComposer.tsx +336 -0
- package/src/components/ComposerDropZone.tsx +235 -0
- package/src/hooks/useAttachmentIntake.ts +401 -0
- package/src/hooks/useFocusWhenPresented.ts +41 -0
- package/src/hooks/usePendingQuestions.ts +19 -0
- package/src/hooks/useVoiceRoom.ts +81 -16
- package/src/index.ts +10 -0
- package/src/lib/attachment-intake.ts +154 -0
- package/src/lib/attachment-tiles.ts +74 -0
- package/src/lib/labels.tsx +47 -1
- package/src/lib/speech-messages.ts +34 -2
- package/src/lib/speech-synthesis.ts +99 -8
- package/src/tools.ts +1 -0
- package/src/types.ts +14 -0
- package/src/voice.ts +2 -0
package/src/index.ts
CHANGED
|
@@ -26,7 +26,14 @@ export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
|
|
|
26
26
|
export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
|
|
27
27
|
export { PlanPreviewCard } from './components/PlanPreviewCard';
|
|
28
28
|
export { AskedQuestions, QuestionCard } from './components/QuestionCard';
|
|
29
|
+
export { AliaComposer } from './components/AliaComposer';
|
|
30
|
+
export type {
|
|
31
|
+
AliaComposerProps,
|
|
32
|
+
AliaComposerQuestions,
|
|
33
|
+
AliaComposerSuggestions,
|
|
34
|
+
} from './components/AliaComposer';
|
|
29
35
|
export { ComposerSuggestions } from './components/ComposerSuggestions';
|
|
36
|
+
export { usePendingQuestions } from './hooks/usePendingQuestions';
|
|
30
37
|
export { useComposerSuggestions, useSuggestionQuery } from './hooks/useComposerSuggestions';
|
|
31
38
|
export { composerCompletions } from './lib/suggestions';
|
|
32
39
|
export type { Completion } from './lib/suggestions';
|
|
@@ -50,6 +57,8 @@ export type { UseTTSOptions } from './hooks/useTTS';
|
|
|
50
57
|
export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
|
|
51
58
|
export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
|
|
52
59
|
export type { VoiceErrorCode } from './lib/speech-messages';
|
|
60
|
+
export { SpeechClipError } from './lib/speech-synthesis';
|
|
61
|
+
export type { SpeechClipErrorCode } from './lib/speech-synthesis';
|
|
53
62
|
export type { UseSTTOptions } from './hooks/useSpeechToText';
|
|
54
63
|
|
|
55
64
|
// ── Model catalogue (GET /catalogue) ──
|
|
@@ -68,6 +77,7 @@ export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from
|
|
|
68
77
|
export type {
|
|
69
78
|
ChatMessage,
|
|
70
79
|
ToolInvocation,
|
|
80
|
+
AliaAttachment,
|
|
71
81
|
AliaSuggestion,
|
|
72
82
|
ResearchProgress,
|
|
73
83
|
ResearchSource,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the composer will take in, and how far a file has actually got.
|
|
3
|
+
*
|
|
4
|
+
* Alia had no answer to either question. A file reached the attachment strip
|
|
5
|
+
* by exactly one route — a picker that hands back a `uri` and asks nothing —
|
|
6
|
+
* so there was no size it would refuse, no kind it would separate, and, once
|
|
7
|
+
* drag-and-drop existed, no shared place to state those rules for a second
|
|
8
|
+
* route. Worse, a drag is a stateful gesture: the browser fires `dragenter`
|
|
9
|
+
* and `dragleave` for every element the pointer crosses, so a drop affordance
|
|
10
|
+
* driven by a boolean flickers off the instant the pointer moves from the bar
|
|
11
|
+
* onto the textarea INSIDE it. The counter that fixes that is four lines of
|
|
12
|
+
* arithmetic guarding a visible effect, which is precisely the kind of thing
|
|
13
|
+
* that is easy to get subtly wrong and almost impossible to watch going wrong.
|
|
14
|
+
*
|
|
15
|
+
* So it lives here, beside `turn-selection.ts`, under the same rule: nothing
|
|
16
|
+
* in this file may import react-native, read a store, or touch the DOM. The
|
|
17
|
+
* reader that moves the bytes is a platform adapter and lives with the
|
|
18
|
+
* composer; what can be decided by arithmetic is decided here, where it can be
|
|
19
|
+
* checked without mounting a composer or staging a drag.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The largest file the composer will take.
|
|
24
|
+
*
|
|
25
|
+
* An attached image is not uploaded anywhere — the app's `attachment-utils.ts`
|
|
26
|
+
* inlines it into the request as a `data:` URL — so its bytes are carried in
|
|
27
|
+
* memory as a base64 string roughly a third larger than the file, twice over
|
|
28
|
+
* while the request is built. 20 MiB is the point past which that stops being
|
|
29
|
+
* a message and starts being a reason the tab dies.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
export type IntakeKind = 'image' | 'document';
|
|
34
|
+
|
|
35
|
+
/** Why a file was turned away. Each one has its own sentence to the user. */
|
|
36
|
+
export type IntakeRefusal = 'empty' | 'too-large' | 'unsupported';
|
|
37
|
+
|
|
38
|
+
export type IntakeVerdict =
|
|
39
|
+
| { accepted: true; kind: IntakeKind }
|
|
40
|
+
| { accepted: false; refusal: IntakeRefusal };
|
|
41
|
+
|
|
42
|
+
export interface IntakeCandidate {
|
|
43
|
+
name: string;
|
|
44
|
+
mimeType: string;
|
|
45
|
+
size: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extensions that name a picture when the browser will not.
|
|
50
|
+
*
|
|
51
|
+
* A dragged file's `type` is filled in from the OS, and it comes through empty
|
|
52
|
+
* often enough to matter — from an archive manager, from a file manager with a
|
|
53
|
+
* thin mime database, from anything that drops a path rather than a handle.
|
|
54
|
+
* Falling back to the name is not a nicety: without it a dropped `.png` is
|
|
55
|
+
* filed as a document, and a document is never inlined into the request at all.
|
|
56
|
+
*/
|
|
57
|
+
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'heic', 'heif'];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a candidate is a picture, by what the browser says first and by its
|
|
61
|
+
* name only when the browser said nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function intakeKind(candidate: IntakeCandidate): IntakeKind {
|
|
64
|
+
if (candidate.mimeType.startsWith('image/')) return 'image';
|
|
65
|
+
const extension = candidate.name.split('.').pop()?.toLowerCase() ?? '';
|
|
66
|
+
return IMAGE_EXTENSIONS.includes(extension) ? 'image' : 'document';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The one gate both web routes pass through — pasted files and dropped ones —
|
|
71
|
+
* so a rule cannot hold for one and not the other.
|
|
72
|
+
*
|
|
73
|
+
* The zero-byte refusal is not pedantry about empty files. Dropping a FOLDER
|
|
74
|
+
* on web yields a `File` with size 0 and no type, in every browser; without
|
|
75
|
+
* this the composer accepts a tile named after a directory whose contents it
|
|
76
|
+
* will never have, and whose read completes with nothing in it.
|
|
77
|
+
*/
|
|
78
|
+
export function classifyIntake(candidate: IntakeCandidate): IntakeVerdict {
|
|
79
|
+
if (candidate.size <= 0) return { accepted: false, refusal: 'empty' };
|
|
80
|
+
if (candidate.size > MAX_ATTACHMENT_BYTES) return { accepted: false, refusal: 'too-large' };
|
|
81
|
+
return { accepted: true, kind: intakeKind(candidate) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The four things that can happen to the count of drags over the composer. */
|
|
85
|
+
export type DragStep = 'enter' | 'leave' | 'drop' | 'reset';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How many dragged-over elements deep the pointer is.
|
|
89
|
+
*
|
|
90
|
+
* `dragenter` and `dragleave` fire per ELEMENT, not per target, and they fire
|
|
91
|
+
* in that order: moving from the bar onto the textarea inside it produces
|
|
92
|
+
* `dragenter` (textarea) then `dragleave` (bar). A boolean set by enter and
|
|
93
|
+
* cleared by leave therefore ends that move switched OFF while the pointer is
|
|
94
|
+
* still over the composer, and the affordance strobes as the pointer crosses
|
|
95
|
+
* each child on the way in. Counting elements instead is the standard answer,
|
|
96
|
+
* and the depth only reaches zero when the pointer has genuinely left
|
|
97
|
+
* everything.
|
|
98
|
+
*
|
|
99
|
+
* `drop` resets rather than decrements: the browser does not send the matching
|
|
100
|
+
* `dragleave` for the element the file landed on, so a decrement leaves the
|
|
101
|
+
* count stuck at one and the overlay pinned over the composer for good.
|
|
102
|
+
*/
|
|
103
|
+
export function nextDragDepth(depth: number, step: DragStep): number {
|
|
104
|
+
switch (step) {
|
|
105
|
+
case 'enter':
|
|
106
|
+
return depth + 1;
|
|
107
|
+
case 'leave':
|
|
108
|
+
// Clamped, because a `dragleave` can arrive without its `dragenter` — a
|
|
109
|
+
// drag that began before the listener was attached, or one whose
|
|
110
|
+
// `dragenter` was swallowed by an element that stopped propagation. A
|
|
111
|
+
// negative depth would then need two full entries before the overlay
|
|
112
|
+
// came back.
|
|
113
|
+
return Math.max(0, depth - 1);
|
|
114
|
+
case 'drop':
|
|
115
|
+
case 'reset':
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether this drag is carrying files at all.
|
|
122
|
+
*
|
|
123
|
+
* `DataTransfer.types` contains the literal `"Files"` when it is; dragging
|
|
124
|
+
* selected text or a link gives `"text/plain"` and `"text/uri-list"` instead.
|
|
125
|
+
* Without the question the composer lights up when the user drags a word
|
|
126
|
+
* across it, and then swallows the drop.
|
|
127
|
+
*/
|
|
128
|
+
export function dragCarriesFiles(types: readonly string[] | undefined): boolean {
|
|
129
|
+
return types !== undefined && types.includes('Files');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How far a read has got, as a fraction — or `null` when nothing measurable
|
|
134
|
+
* was reported.
|
|
135
|
+
*
|
|
136
|
+
* `null` is the important return, and it is why this is a function rather than
|
|
137
|
+
* a division at the call site. #608 §7: "Cuando no hay avance medible, mostrar
|
|
138
|
+
* actividad indeterminada. No inventar porcentajes." A `ProgressEvent` whose
|
|
139
|
+
* `lengthComputable` is false carries a `total` of 0, and dividing by it gives
|
|
140
|
+
* `NaN` or `Infinity` — both of which render as SOMETHING once handed to a
|
|
141
|
+
* bar, and that something is invented. Bloom's `use-attachment-queue.ts`
|
|
142
|
+
* invents its number outright, from a `Math.random()` ramp on a 50ms tick;
|
|
143
|
+
* nothing in this file may grow a timer.
|
|
144
|
+
*/
|
|
145
|
+
export function readFraction(progress: {
|
|
146
|
+
lengthComputable: boolean;
|
|
147
|
+
loaded: number;
|
|
148
|
+
total: number;
|
|
149
|
+
}): number | null {
|
|
150
|
+
if (!progress.lengthComputable) return null;
|
|
151
|
+
if (!Number.isFinite(progress.total) || progress.total <= 0) return null;
|
|
152
|
+
if (!Number.isFinite(progress.loaded) || progress.loaded < 0) return null;
|
|
153
|
+
return Math.min(1, progress.loaded / progress.total);
|
|
154
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ComposerPanelAttachment } from '@oxy.so/bloom/composer-panel';
|
|
2
|
+
import type { IntakeItem } from '../hooks/useAttachmentIntake';
|
|
3
|
+
import type { AliaAttachment } from '../types';
|
|
4
|
+
import { MAX_ATTACHMENT_BYTES } from './attachment-intake';
|
|
5
|
+
import type { AliaChatLabels } from './labels';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The composer's tiles: landed files, then the ones the intake still holds.
|
|
9
|
+
*
|
|
10
|
+
* A file whose read failed, or that was refused, stays in the strip with the
|
|
11
|
+
* sentence that says why — a drop of six where one did not make it is a
|
|
12
|
+
* sentence the user needs beside the five that did, not a toast that slides
|
|
13
|
+
* away. `retryable` marks the ones a second read could fix: a failed read,
|
|
14
|
+
* whose `File` the intake kept. A refusal is not: the same 30 MB file reaches
|
|
15
|
+
* the same verdict — and since Bloom's retry button is the panel's, drawn on
|
|
16
|
+
* every error tile once `onAttachmentRetry` is set, the composer shows a
|
|
17
|
+
* refusal as a toast rather than as a tile it would offer to retry.
|
|
18
|
+
*/
|
|
19
|
+
export interface ComposerTile extends ComposerPanelAttachment {
|
|
20
|
+
error?: string;
|
|
21
|
+
retryable?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The words a tile and a refusal use. */
|
|
25
|
+
type TileLabels = Pick<
|
|
26
|
+
AliaChatLabels,
|
|
27
|
+
'composerUntitled' | 'fileReadFailed' | 'fileTooLarge' | 'fileEmpty' | 'fileUnsupported'
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
/** Why an intake item is not an attachment, or `undefined` while it is being read. */
|
|
31
|
+
export function intakeError(item: IntakeItem, labels: TileLabels): string | undefined {
|
|
32
|
+
const name = item.name || labels.composerUntitled;
|
|
33
|
+
switch (item.status) {
|
|
34
|
+
case 'reading':
|
|
35
|
+
return undefined;
|
|
36
|
+
case 'failed':
|
|
37
|
+
return labels.fileReadFailed(name);
|
|
38
|
+
case 'refused':
|
|
39
|
+
if (item.refusal === 'unsupported') return labels.fileUnsupported(name);
|
|
40
|
+
return item.refusal === 'too-large'
|
|
41
|
+
? labels.fileTooLarge(name, `${Math.round(MAX_ATTACHMENT_BYTES / (1024 * 1024))} MB`)
|
|
42
|
+
: labels.fileEmpty(name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function composerTiles(
|
|
47
|
+
attachments: readonly AliaAttachment[],
|
|
48
|
+
items: readonly IntakeItem[],
|
|
49
|
+
labels: TileLabels,
|
|
50
|
+
): ComposerTile[] {
|
|
51
|
+
return [
|
|
52
|
+
...attachments.map((a) => ({
|
|
53
|
+
id: a.id,
|
|
54
|
+
name: a.name,
|
|
55
|
+
kind: a.type === 'image' ? ('image' as const) : ('document' as const),
|
|
56
|
+
src: a.type === 'image' ? a.uri : undefined,
|
|
57
|
+
})),
|
|
58
|
+
...items.map((item): ComposerTile => {
|
|
59
|
+
const tile: ComposerTile = {
|
|
60
|
+
id: item.id,
|
|
61
|
+
name: item.name || labels.composerUntitled,
|
|
62
|
+
kind: item.kind === 'image' ? 'image' : 'document',
|
|
63
|
+
};
|
|
64
|
+
if (item.status === 'reading') {
|
|
65
|
+
// Real progress only; an unmeasured read is an empty ring, not a guess.
|
|
66
|
+
tile.progress = item.fraction === null ? 0 : Math.round(item.fraction * 100);
|
|
67
|
+
return tile;
|
|
68
|
+
}
|
|
69
|
+
tile.error = intakeError(item, labels);
|
|
70
|
+
tile.retryable = item.status === 'failed';
|
|
71
|
+
return tile;
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
package/src/lib/labels.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
2
|
+
import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from './speech-messages';
|
|
2
3
|
import { englishWorkLog, type Translate } from './work-log-copy';
|
|
3
4
|
|
|
4
5
|
/** Every string the chat UI draws itself. Bloom's own copy follows `BloomProvider locale`. */
|
|
@@ -15,6 +16,32 @@ export interface AliaChatLabels {
|
|
|
15
16
|
addPhotos: string;
|
|
16
17
|
startVoice: string;
|
|
17
18
|
voiceFailed: string;
|
|
19
|
+
/** The field's accessible name. */
|
|
20
|
+
composerMessage: string;
|
|
21
|
+
/** The add button's accessible name. */
|
|
22
|
+
composerAddMenu: string;
|
|
23
|
+
/** The mode selector's accessible name (the host's power levels). */
|
|
24
|
+
composerModes: string;
|
|
25
|
+
/** The dictation button's accessible name. */
|
|
26
|
+
composerVoice: string;
|
|
27
|
+
composerSend: string;
|
|
28
|
+
composerStop: string;
|
|
29
|
+
/** A tile's remove button. */
|
|
30
|
+
composerRemove: string;
|
|
31
|
+
/** Bloom names a retry button "<this> <file name>". */
|
|
32
|
+
composerRetry: string;
|
|
33
|
+
/** What a tile without a file name is called. */
|
|
34
|
+
composerUntitled: string;
|
|
35
|
+
fileReadFailed: (name: string) => string;
|
|
36
|
+
fileTooLarge: (name: string, limit: string) => string;
|
|
37
|
+
fileEmpty: (name: string) => string;
|
|
38
|
+
/** A file of a kind the chat cannot send (a document, where only photos go). */
|
|
39
|
+
fileUnsupported: (name: string) => string;
|
|
40
|
+
/** Over the composer while a file is dragged onto it. */
|
|
41
|
+
dropHint: string;
|
|
42
|
+
dropUnavailable: string;
|
|
43
|
+
/** Why dictation failed, in the host's language. */
|
|
44
|
+
voiceError: (code: VoiceErrorCode) => string;
|
|
18
45
|
// Welcome
|
|
19
46
|
/** The empty chat's greeting for a signed-out visitor. */
|
|
20
47
|
greeting: string;
|
|
@@ -56,6 +83,22 @@ export const DEFAULT_LABELS: AliaChatLabels = {
|
|
|
56
83
|
addPhotos: 'Photos',
|
|
57
84
|
startVoice: 'Start voice',
|
|
58
85
|
voiceFailed: "Couldn't start the voice call",
|
|
86
|
+
composerMessage: 'Message',
|
|
87
|
+
composerAddMenu: 'Add to chat',
|
|
88
|
+
composerModes: 'Power level',
|
|
89
|
+
composerVoice: 'Dictate',
|
|
90
|
+
composerSend: 'Send message',
|
|
91
|
+
composerStop: 'Stop generating',
|
|
92
|
+
composerRemove: 'Remove',
|
|
93
|
+
composerRetry: 'Retry',
|
|
94
|
+
composerUntitled: 'untitled',
|
|
95
|
+
fileReadFailed: (name) => `${name} could not be read — select to try again`,
|
|
96
|
+
fileTooLarge: (name, limit) => `${name} is larger than ${limit} and was not attached`,
|
|
97
|
+
fileEmpty: (name) => `${name} is empty — folders can't be attached`,
|
|
98
|
+
fileUnsupported: (name) => `${name} can't be sent here — only photos can`,
|
|
99
|
+
dropHint: 'Drop files here to attach them',
|
|
100
|
+
dropUnavailable: "Files can't be attached right now",
|
|
101
|
+
voiceError: (code) => VOICE_ERROR_MESSAGES[code],
|
|
59
102
|
greeting: "Hi, what's on your mind?",
|
|
60
103
|
greetingNamed: (name) => `Hi ${name}, what's on your mind?`,
|
|
61
104
|
copy: 'Copy',
|
|
@@ -87,7 +130,10 @@ export function AliaChatLabelsProvider({
|
|
|
87
130
|
labels?: Partial<AliaChatLabels>;
|
|
88
131
|
children: ReactNode;
|
|
89
132
|
}) {
|
|
90
|
-
|
|
133
|
+
// Over whatever labels are already in reach, so a part that takes its own `labels` (the
|
|
134
|
+
// composer) refines the chat's copy instead of resetting it to English.
|
|
135
|
+
const inherited = useContext(LabelsContext);
|
|
136
|
+
const value = useMemo(() => ({ ...inherited, ...labels }), [inherited, labels]);
|
|
91
137
|
return <LabelsContext.Provider value={value}>{children}</LabelsContext.Provider>;
|
|
92
138
|
}
|
|
93
139
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SpeechRecognitionFailure } from './speech-recognition-types';
|
|
2
|
+
import type { SpeechClipError } from './speech-synthesis';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Every way dictation or a voice call can fail that a person is told about,
|
|
@@ -20,7 +21,11 @@ export type VoiceErrorCode =
|
|
|
20
21
|
| 'not-authenticated'
|
|
21
22
|
| 'turn-failed'
|
|
22
23
|
| 'no-answer'
|
|
23
|
-
| 'not-played'
|
|
24
|
+
| 'not-played'
|
|
25
|
+
| 'synthesis-unavailable'
|
|
26
|
+
| 'synthesis-limited'
|
|
27
|
+
| 'synthesis-rejected'
|
|
28
|
+
| 'synthesis-failed';
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* The English copy for each code — the same sentences dictation and voice mode
|
|
@@ -39,9 +44,36 @@ export const VOICE_ERROR_MESSAGES: Readonly<Record<VoiceErrorCode, string>> = {
|
|
|
39
44
|
'not-authenticated': 'Not authenticated',
|
|
40
45
|
'turn-failed': 'Voice request failed',
|
|
41
46
|
'no-answer': 'No answer came back — try saying it again',
|
|
42
|
-
'not-played': 'The answer could not be played aloud',
|
|
47
|
+
'not-played': 'The answer could not be played aloud — it is in the chat',
|
|
48
|
+
'synthesis-unavailable': "Alia's voice is not available right now — the answer is in the chat",
|
|
49
|
+
'synthesis-limited': "Alia's voice has reached its limit for now — the answer is in the chat",
|
|
50
|
+
'synthesis-rejected': "Alia's voice could not read this answer — it is in the chat",
|
|
51
|
+
'synthesis-failed': "Alia's voice failed — the answer is in the chat",
|
|
43
52
|
};
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The code for a clip `/v1/audio/speech` could not make, from the typed code
|
|
56
|
+
* the route answered with. A failure the route did not name (a network
|
|
57
|
+
* failure, an unknown code) is `synthesis-failed`.
|
|
58
|
+
*/
|
|
59
|
+
export function synthesisFailureCode(failure: SpeechClipError): VoiceErrorCode {
|
|
60
|
+
switch (failure.code) {
|
|
61
|
+
case 'NOT_AUTHENTICATED':
|
|
62
|
+
return 'not-authenticated';
|
|
63
|
+
case 'SPEECH_UNAVAILABLE':
|
|
64
|
+
return 'synthesis-unavailable';
|
|
65
|
+
case 'SPEECH_LIMIT_REACHED':
|
|
66
|
+
return 'synthesis-limited';
|
|
67
|
+
case 'SPEECH_REJECTED':
|
|
68
|
+
case 'INVALID_REQUEST':
|
|
69
|
+
return 'synthesis-rejected';
|
|
70
|
+
case 'SPEECH_PROVIDER_FAILED':
|
|
71
|
+
case 'SPEECH_GENERATION_FAILED':
|
|
72
|
+
case null:
|
|
73
|
+
return 'synthesis-failed';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
/**
|
|
46
78
|
* The code for a recognition failure. `null` for the failures that are not
|
|
47
79
|
* errors: `aborted` is a stop the caller asked for, and `no-speech` is a
|
|
@@ -26,8 +26,102 @@ export interface SpeechClipRequest {
|
|
|
26
26
|
readonly signal: AbortSignal;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The codes `/v1/audio/speech` answers a failure with, plus the two this side
|
|
31
|
+
* knows on its own: `NOT_AUTHENTICATED` (no session token, or the route said
|
|
32
|
+
* 401) and `INVALID_REQUEST` (the route refused the body).
|
|
33
|
+
*/
|
|
34
|
+
export type SpeechClipErrorCode =
|
|
35
|
+
| 'SPEECH_UNAVAILABLE'
|
|
36
|
+
| 'SPEECH_REJECTED'
|
|
37
|
+
| 'SPEECH_LIMIT_REACHED'
|
|
38
|
+
| 'SPEECH_PROVIDER_FAILED'
|
|
39
|
+
| 'SPEECH_GENERATION_FAILED'
|
|
40
|
+
| 'INVALID_REQUEST'
|
|
41
|
+
| 'NOT_AUTHENTICATED';
|
|
42
|
+
|
|
43
|
+
const SERVER_CODES: ReadonlySet<string> = new Set<SpeechClipErrorCode>([
|
|
44
|
+
'SPEECH_UNAVAILABLE',
|
|
45
|
+
'SPEECH_REJECTED',
|
|
46
|
+
'SPEECH_LIMIT_REACHED',
|
|
47
|
+
'SPEECH_PROVIDER_FAILED',
|
|
48
|
+
'SPEECH_GENERATION_FAILED',
|
|
49
|
+
'INVALID_REQUEST',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A clip that could not be made, with what the route said about it: the HTTP
|
|
54
|
+
* status (`null` when no request was answered) and its typed `code` (`null`
|
|
55
|
+
* when the route named none this SDK knows — a network failure, an older or
|
|
56
|
+
* newer server).
|
|
57
|
+
*/
|
|
58
|
+
export class SpeechClipError extends Error {
|
|
59
|
+
readonly status: number | null;
|
|
60
|
+
readonly code: SpeechClipErrorCode | null;
|
|
61
|
+
readonly retryable: boolean;
|
|
62
|
+
/** Oxy's request id, for support, when the route gave one. */
|
|
63
|
+
readonly requestId: string | null;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
message: string,
|
|
67
|
+
details: {
|
|
68
|
+
status: number | null;
|
|
69
|
+
code: SpeechClipErrorCode | null;
|
|
70
|
+
retryable?: boolean;
|
|
71
|
+
requestId?: string | null;
|
|
72
|
+
},
|
|
73
|
+
) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'SpeechClipError';
|
|
76
|
+
this.status = details.status;
|
|
77
|
+
this.code = details.code;
|
|
78
|
+
this.retryable = details.retryable ?? false;
|
|
79
|
+
this.requestId = details.requestId ?? null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clipErrorFrom(status: number, body: unknown): SpeechClipError {
|
|
84
|
+
const envelope =
|
|
85
|
+
body !== null && typeof body === 'object' ? (body as { error?: unknown }).error : undefined;
|
|
86
|
+
const error =
|
|
87
|
+
envelope !== null && typeof envelope === 'object'
|
|
88
|
+
? (envelope as {
|
|
89
|
+
code?: unknown;
|
|
90
|
+
message?: unknown;
|
|
91
|
+
retryable?: unknown;
|
|
92
|
+
requestId?: unknown;
|
|
93
|
+
})
|
|
94
|
+
: {};
|
|
95
|
+
const named =
|
|
96
|
+
typeof error.code === 'string' && SERVER_CODES.has(error.code)
|
|
97
|
+
? (error.code as SpeechClipErrorCode)
|
|
98
|
+
: null;
|
|
99
|
+
const code = named ?? (status === 401 ? 'NOT_AUTHENTICATED' : null);
|
|
100
|
+
const message =
|
|
101
|
+
status === 504
|
|
102
|
+
? 'Request timed out — please try again'
|
|
103
|
+
: typeof error.message === 'string' && error.message !== ''
|
|
104
|
+
? error.message
|
|
105
|
+
: typeof envelope === 'string' && envelope !== ''
|
|
106
|
+
? envelope
|
|
107
|
+
: 'TTS failed';
|
|
108
|
+
return new SpeechClipError(message, {
|
|
109
|
+
status,
|
|
110
|
+
code,
|
|
111
|
+
retryable: error.retryable === true,
|
|
112
|
+
requestId: typeof error.requestId === 'string' ? error.requestId : null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Synthesize `input` and return the private link the route answers with.
|
|
118
|
+
*
|
|
119
|
+
* @throws {SpeechClipError} when the route refuses or answers no link; a
|
|
120
|
+
* network failure or an abort is thrown as `fetch` threw it.
|
|
121
|
+
*/
|
|
30
122
|
export async function requestSpeechClip(request: SpeechClipRequest): Promise<string> {
|
|
123
|
+
if (request.token === '')
|
|
124
|
+
throw new SpeechClipError('Not authenticated', { status: null, code: 'NOT_AUTHENTICATED' });
|
|
31
125
|
const response = await fetch(`${request.apiUrl}/v1/audio/speech`, {
|
|
32
126
|
method: 'POST',
|
|
33
127
|
signal: request.signal,
|
|
@@ -46,16 +140,13 @@ export async function requestSpeechClip(request: SpeechClipRequest): Promise<str
|
|
|
46
140
|
});
|
|
47
141
|
|
|
48
142
|
if (!response.ok) {
|
|
49
|
-
|
|
50
|
-
throw new Error('Request timed out — please try again');
|
|
51
|
-
}
|
|
52
|
-
const errData = await response.json().catch(() => ({}));
|
|
53
|
-
throw new Error(errData.error?.message || errData.error || 'TTS failed');
|
|
143
|
+
throw clipErrorFrom(response.status, await response.json().catch(() => null));
|
|
54
144
|
}
|
|
55
145
|
|
|
56
|
-
const data = (await response.json()) as { audioUrl?: unknown };
|
|
146
|
+
const data = (await response.json().catch(() => ({}))) as { audioUrl?: unknown };
|
|
57
147
|
request.signal.throwIfAborted();
|
|
58
|
-
if (typeof data.audioUrl !== 'string' || data.audioUrl === '')
|
|
148
|
+
if (typeof data.audioUrl !== 'string' || data.audioUrl === '')
|
|
149
|
+
throw new SpeechClipError('TTS failed', { status: response.status, code: null });
|
|
59
150
|
return data.audioUrl;
|
|
60
151
|
}
|
|
61
152
|
|
package/src/tools.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
pendingQuestions,
|
|
20
20
|
} from './lib/questions';
|
|
21
21
|
export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
|
|
22
|
+
export { classifyIntake, MAX_ATTACHMENT_BYTES } from './lib/attachment-intake';
|
|
22
23
|
export { toolCallStatus } from './lib/tool-call-status';
|
|
23
24
|
export type { ToolCallStatus } from './lib/tool-call-status';
|
|
24
25
|
export { englishWorkLog } from './lib/work-log-copy';
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,20 @@ export interface AliaSuggestion {
|
|
|
9
9
|
text: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// ── Composer Types ──
|
|
13
|
+
|
|
14
|
+
/** A file in the composer's draft, on its way into a turn. */
|
|
15
|
+
export interface AliaAttachment {
|
|
16
|
+
id: string;
|
|
17
|
+
/** A `data:` URL once read; a picker's `blob:`/`file:` URI for a photo not yet read. */
|
|
18
|
+
uri: string;
|
|
19
|
+
type: 'image' | 'document';
|
|
20
|
+
name: string;
|
|
21
|
+
size: number;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
isLoading?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
// ── Chat Message Types ──
|
|
13
27
|
|
|
14
28
|
export interface ResearchSource {
|
package/src/voice.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type {
|
|
|
22
22
|
export type { VoiceLevelSource } from './lib/voice-levels';
|
|
23
23
|
export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
|
|
24
24
|
export type { VoiceErrorCode } from './lib/speech-messages';
|
|
25
|
+
export { SpeechClipError } from './lib/speech-synthesis';
|
|
26
|
+
export type { SpeechClipErrorCode } from './lib/speech-synthesis';
|
|
25
27
|
export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
|
|
26
28
|
export { useAudioLevels } from './hooks/useAudioLevels';
|
|
27
29
|
export { useAmbientWave } from './hooks/useAmbientWave';
|