@alia.onl/sdk 7.2.9 → 10.0.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 +142 -6
- package/README.md +242 -0
- package/package.json +8 -12
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- package/src/components/ResearchProgressCard.tsx +3 -0
- package/src/components/ui/prompt-input/mic-button.tsx +2 -3
- package/src/components/ui/prompt-input/prompt-input.tsx +1 -4
- package/src/components/voice/VoiceControls.tsx +48 -63
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +24 -10
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +196 -147
- package/src/hooks/useTTS.ts +33 -38
- package/src/hooks/useVoiceRoom.ts +581 -416
- package/src/index.ts +6 -19
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +14 -2
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +76 -0
- package/src/lib/speech-recognition-types.ts +108 -0
- package/src/lib/speech-recognition.native.ts +226 -0
- package/src/lib/speech-recognition.ts +288 -0
- package/src/lib/speech-synthesis.ts +122 -0
- package/src/lib/voice-levels.ts +60 -0
- package/src/lib/voice-text.ts +192 -0
- package/src/lib/voice-turn.ts +86 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +11 -3
- package/src/lib/config.ts +0 -36
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { IdentityMark } from './components/IdentityMark';
|
|
|
13
13
|
export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
|
|
14
14
|
export { ThinkingIndicator } from './components/ThinkingIndicator';
|
|
15
15
|
export { AliaMarkdown } from './components/Markdown';
|
|
16
|
-
export type { AliaMarkdownProps } from './components/Markdown';
|
|
16
|
+
export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
|
|
17
17
|
export { Reasoning, ReasoningTrigger, ReasoningContent } from './components/Reasoning';
|
|
18
18
|
export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
|
|
19
19
|
export { ResearchProgressCard } from './components/ResearchProgressCard';
|
|
@@ -27,11 +27,13 @@ export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat'
|
|
|
27
27
|
export { useTTS } from './hooks/useTTS';
|
|
28
28
|
export type { UseTTSOptions } from './hooks/useTTS';
|
|
29
29
|
export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
|
|
30
|
+
export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
|
|
31
|
+
export type { VoiceErrorCode } from './lib/speech-messages';
|
|
30
32
|
export type { UseSTTOptions } from './hooks/useSpeechToText';
|
|
31
33
|
|
|
32
34
|
// ── Model catalogue (GET /catalogue) ──
|
|
33
|
-
// Exported so a consumer building its own picker reads the
|
|
34
|
-
//
|
|
35
|
+
// Exported so a consumer building its own picker reads the real models the
|
|
36
|
+
// server offers; the SDK itself ships no model identifier.
|
|
35
37
|
export {
|
|
36
38
|
fetchCatalogue,
|
|
37
39
|
parseCatalogue,
|
|
@@ -39,8 +41,7 @@ export {
|
|
|
39
41
|
resolveModelId,
|
|
40
42
|
clearCatalogueCache,
|
|
41
43
|
} from './lib/catalogue';
|
|
42
|
-
export type {
|
|
43
|
-
export { PREFERRED_CHAT_MODEL_ID, PREFERRED_VOICE_MODEL_ID } from './lib/config';
|
|
44
|
+
export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from './lib/catalogue';
|
|
44
45
|
|
|
45
46
|
// ── Types ──
|
|
46
47
|
export type {
|
|
@@ -64,24 +65,10 @@ export type {
|
|
|
64
65
|
export { Button, buttonVariants, buttonTextVariants } from './components/ui/button';
|
|
65
66
|
export type { ButtonProps } from './components/ui/button';
|
|
66
67
|
export { Text, TextClassContext } from './components/ui/text';
|
|
67
|
-
export { ChatTextInput } from './components/ui/chat-text-input';
|
|
68
68
|
export { cn, formatFileSize } from './lib/utils';
|
|
69
69
|
export { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from './lib/tool-registry';
|
|
70
70
|
export { getTextFromContent, getImagesFromContent } from './lib/content-utils';
|
|
71
71
|
|
|
72
|
-
// ── PromptInput ──
|
|
73
|
-
export { PromptInput } from './components/ui/prompt-input/prompt-input';
|
|
74
|
-
export type { PromptInputProps } from './components/ui/prompt-input/prompt-input';
|
|
75
|
-
export { usePromptInput, useIsFullscreen, PromptInputContext } from './components/ui/prompt-input/context';
|
|
76
|
-
export type { Attachment, Completion, PromptInputContextType } from './components/ui/prompt-input/context';
|
|
77
|
-
export { PromptInputTextarea } from './components/ui/prompt-input/textarea';
|
|
78
|
-
export { PromptInputActions } from './components/ui/prompt-input/actions';
|
|
79
|
-
export { PromptInputSubmitButton } from './components/ui/prompt-input/submit-button';
|
|
80
|
-
export { PromptInputMicButton } from './components/ui/prompt-input/mic-button';
|
|
81
|
-
export { PromptInputAddMenu } from './components/ui/prompt-input/add-menu';
|
|
82
|
-
export { PromptInputAttachments } from './components/ui/prompt-input/attachments';
|
|
83
|
-
export { PromptInputAutocomplete } from './components/ui/prompt-input/autocomplete';
|
|
84
|
-
|
|
85
72
|
// ── Picker hooks ──
|
|
86
73
|
export { useImagePicker } from './hooks/useImagePicker';
|
|
87
74
|
export type { ImagePickerAsset } from './hooks/useImagePicker';
|
package/src/lib/audio-level.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* How audio becomes the ambient field's 0..1 level — one curve, one pair of
|
|
3
3
|
* time constants, for every source that drives it.
|
|
4
4
|
*
|
|
5
|
-
* Dictation got here first and is the reference: the
|
|
6
|
-
*
|
|
5
|
+
* Dictation got here first and is the reference: the microphone's level is
|
|
6
|
+
* taken as dBFS (on web, from an analyser beside the recognizer —
|
|
7
|
+
* `speech-recognition.ts`), normalised against a -60 floor, and `useAmbientWave`
|
|
7
8
|
* smooths it with a fast attack and a slow decay before it reaches
|
|
8
9
|
* `AmbientField`. That is the behaviour people like, so it is the behaviour
|
|
9
10
|
* everything else adopts rather than re-invents — including "read aloud", which
|
package/src/lib/catalogue.ts
CHANGED
|
@@ -1,58 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The model catalogue, as the SDK consumes it (`GET /catalogue
|
|
3
|
-
* workstream 5).
|
|
2
|
+
* The model catalogue, as the SDK consumes it (`GET /catalogue`).
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* offers instead.
|
|
4
|
+
* Alia has no models of its own: the catalogue lists the real models Oxy serves
|
|
5
|
+
* (ids `publisher/model`), and the server decides which one a request with no
|
|
6
|
+
* `model` gets (`defaultModelId`) and which ones a picker shows first
|
|
7
|
+
* (`featuredIds`). This package therefore ships NO model identifier: a caller
|
|
8
|
+
* that names no model sends no `model`, and the server answers with its default.
|
|
11
9
|
*
|
|
12
10
|
* ## Deliberately not React, and deliberately not react-query
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* anywhere above it. Introducing one here would turn "the model default came
|
|
19
|
-
* from the server" into a breaking change for every consumer, which is a bad
|
|
20
|
-
* trade for a fallback.
|
|
12
|
+
* The SDK imports `@tanstack/react-query` in no module, so a consumer mounting
|
|
13
|
+
* `<AliaChatSheet>` is not required to have a `QueryClientProvider`. Resolution
|
|
14
|
+
* happens inside `send`, at the moment a request is about to name a model, and a
|
|
15
|
+
* module-level cache keeps that from becoming a fetch per message.
|
|
21
16
|
*
|
|
22
|
-
*
|
|
23
|
-
* request is about to name a model — rather than at render. A module-level cache
|
|
24
|
-
* keeps that from becoming a fetch per message.
|
|
17
|
+
* ## Parsing does not invent
|
|
25
18
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - An entry whose `object` is neither known value is DROPPED. Defaulting it to
|
|
32
|
-
* either kind would break ADR 0003 invariant 1 in one direction or the other.
|
|
33
|
-
* - A response whose entries all fail to parse THROWS rather than reading as an
|
|
34
|
-
* empty catalogue, because "no models" and "we could not read the models"
|
|
35
|
-
* look identical to every caller below and one of them means "offer nothing".
|
|
36
|
-
*
|
|
37
|
-
* This file is a second implementation of that contract rather than a shared
|
|
38
|
-
* one, and that is forced rather than chosen: `@alia.onl/sdk` ships as raw
|
|
39
|
-
* source, so it cannot depend on an unpublished workspace package — a consumer's
|
|
40
|
-
* Metro would fail to resolve it. The three Node-side clients (Codea, Cowork,
|
|
41
|
-
* the CLI) share one instead.
|
|
19
|
+
* An entry without an id or a name is dropped, and a response whose entries all
|
|
20
|
+
* fail to parse THROWS rather than reading as an empty catalogue: "no models"
|
|
21
|
+
* and "we could not read the models" look identical to every caller below, and
|
|
22
|
+
* one of them means "offer nothing".
|
|
42
23
|
*/
|
|
43
24
|
|
|
44
|
-
/** A
|
|
45
|
-
export type
|
|
25
|
+
/** A reasoning effort a model accepts. */
|
|
26
|
+
export type ReasoningEffort = 'low' | 'medium' | 'high';
|
|
27
|
+
|
|
28
|
+
const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high'];
|
|
46
29
|
|
|
47
30
|
export interface CatalogueEntry {
|
|
31
|
+
/** `publisher/model`. */
|
|
48
32
|
readonly id: string;
|
|
49
|
-
readonly
|
|
50
|
-
readonly
|
|
51
|
-
readonly description: string;
|
|
52
|
-
|
|
53
|
-
readonly
|
|
54
|
-
|
|
55
|
-
readonly
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly publisher: { readonly id: string; readonly name: string };
|
|
35
|
+
readonly description: string | null;
|
|
36
|
+
readonly contextWindow: number | null;
|
|
37
|
+
readonly maxOutput: number | null;
|
|
38
|
+
readonly inputModalities: readonly string[];
|
|
39
|
+
readonly outputModalities: readonly string[];
|
|
40
|
+
readonly tools: boolean;
|
|
41
|
+
/** The efforts the model accepts, cheapest first. Empty: send none. */
|
|
42
|
+
readonly reasoningEfforts: readonly ReasoningEffort[];
|
|
43
|
+
readonly pricing: { readonly inputPerMTok: string; readonly outputPerMTok: string } | null;
|
|
44
|
+
readonly releasedAt: string | null;
|
|
45
|
+
readonly featured: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Catalogue {
|
|
49
|
+
readonly entries: readonly CatalogueEntry[];
|
|
50
|
+
/** What a request without `model` is answered with, or `null` if the server did not say. */
|
|
51
|
+
readonly defaultModelId: string | null;
|
|
52
|
+
/** The ids a picker shows first, in order. */
|
|
53
|
+
readonly featuredIds: readonly string[];
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
type JsonObject = Record<string, unknown>;
|
|
@@ -64,7 +62,15 @@ function asObject(value: unknown): JsonObject | null {
|
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
function asText(value: unknown): string | null {
|
|
67
|
-
return typeof value === 'string' ? value : null;
|
|
65
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function asCount(value: unknown): number | null {
|
|
69
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asTexts(value: unknown): string[] {
|
|
73
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
function parseEntry(value: unknown): CatalogueEntry | null {
|
|
@@ -72,27 +78,41 @@ function parseEntry(value: unknown): CatalogueEntry | null {
|
|
|
72
78
|
if (raw === null) return null;
|
|
73
79
|
|
|
74
80
|
const id = asText(raw.id);
|
|
75
|
-
const
|
|
76
|
-
if (id === null ||
|
|
81
|
+
const name = asText(raw.name);
|
|
82
|
+
if (id === null || name === null) return null;
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
const
|
|
84
|
+
const publisher = asObject(raw.publisher);
|
|
85
|
+
const publisherId = asText(publisher?.id) ?? id.split('/')[0] ?? id;
|
|
86
|
+
const pricing = asObject(raw.pricing);
|
|
87
|
+
const input = asText(pricing?.inputPerMTok);
|
|
88
|
+
const output = asText(pricing?.outputPerMTok);
|
|
80
89
|
|
|
81
90
|
return {
|
|
82
91
|
id,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
description: asText(raw.description)
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
name,
|
|
93
|
+
publisher: { id: publisherId, name: asText(publisher?.name) ?? publisherId },
|
|
94
|
+
description: asText(raw.description),
|
|
95
|
+
contextWindow: asCount(raw.contextWindow),
|
|
96
|
+
maxOutput: asCount(raw.maxOutput),
|
|
97
|
+
inputModalities: asTexts(raw.inputModalities),
|
|
98
|
+
outputModalities: asTexts(raw.outputModalities),
|
|
99
|
+
tools: raw.tools === true,
|
|
100
|
+
reasoningEfforts: REASONING_EFFORTS.filter((effort) =>
|
|
101
|
+
asTexts(raw.reasoningEfforts).includes(effort),
|
|
102
|
+
),
|
|
103
|
+
pricing: input !== null && output !== null ? { inputPerMTok: input, outputPerMTok: output } : null,
|
|
104
|
+
releasedAt: asText(raw.releasedAt),
|
|
105
|
+
featured: raw.featured === true,
|
|
88
106
|
};
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
/** Turn a catalogue response into
|
|
92
|
-
export function parseCatalogue(payload: unknown):
|
|
109
|
+
/** Turn a catalogue response into a {@link Catalogue}, or throw. */
|
|
110
|
+
export function parseCatalogue(payload: unknown): Catalogue {
|
|
93
111
|
const body = asObject(payload);
|
|
94
112
|
const data = body === null ? null : body.data;
|
|
95
|
-
if (!Array.isArray(data))
|
|
113
|
+
if (body === null || !Array.isArray(data)) {
|
|
114
|
+
throw new Error('The model catalogue response could not be read.');
|
|
115
|
+
}
|
|
96
116
|
|
|
97
117
|
const entries: CatalogueEntry[] = [];
|
|
98
118
|
for (const value of data) {
|
|
@@ -102,57 +122,53 @@ export function parseCatalogue(payload: unknown): CatalogueEntry[] {
|
|
|
102
122
|
if (data.length > 0 && entries.length === 0) {
|
|
103
123
|
throw new Error('The model catalogue response could not be read.');
|
|
104
124
|
}
|
|
105
|
-
return
|
|
125
|
+
return {
|
|
126
|
+
entries,
|
|
127
|
+
defaultModelId: asText(body.defaultModelId),
|
|
128
|
+
featuredIds: asTexts(body.featuredIds),
|
|
129
|
+
};
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
export interface ModelSelection {
|
|
109
|
-
/** What the caller asked for
|
|
110
|
-
readonly requestedId: string;
|
|
111
|
-
/** What a request should carry
|
|
112
|
-
readonly effectiveId: string;
|
|
133
|
+
/** What the caller asked for; `undefined` is "the server's default". */
|
|
134
|
+
readonly requestedId: string | undefined;
|
|
135
|
+
/** What a request should carry as `model`; `undefined` means omit it. */
|
|
136
|
+
readonly effectiveId: string | undefined;
|
|
137
|
+
/** The entry the request will be answered by, when the catalogue says. */
|
|
138
|
+
readonly entry: CatalogueEntry | null;
|
|
113
139
|
/** `replaced` when the requested identifier is not one the catalogue offers. */
|
|
114
|
-
readonly source: 'requested' | 'replaced';
|
|
140
|
+
readonly source: 'requested' | 'default' | 'replaced';
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
/**
|
|
118
144
|
* Resolve a requested identifier against the catalogue.
|
|
119
145
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
146
|
+
* - **Nothing requested** → omit `model`; the server's default answers.
|
|
122
147
|
* - **No catalogue — not loaded, or the request failed — leaves the choice
|
|
123
|
-
* alone.** Replacing on missing data
|
|
124
|
-
* slow cold start
|
|
125
|
-
* - **An
|
|
126
|
-
*
|
|
127
|
-
* among them; picking a different one is a product decision nobody made.
|
|
128
|
-
* - **`preferredId` is never trusted.** It is a build-time value, so it is
|
|
129
|
-
* checked against the catalogue like any other and falls through when the
|
|
130
|
-
* catalogue does not offer it.
|
|
148
|
+
* alone.** Replacing on missing data would change the model under the user
|
|
149
|
+
* on any slow cold start.
|
|
150
|
+
* - **An identifier the catalogue no longer lists** is not sent (it would be
|
|
151
|
+
* refused); the request omits `model` and the server's default answers.
|
|
131
152
|
*/
|
|
132
153
|
export function resolveSelection(
|
|
133
|
-
requestedId: string,
|
|
134
|
-
|
|
135
|
-
preferredId?: string,
|
|
154
|
+
requestedId: string | null | undefined,
|
|
155
|
+
catalogue: Catalogue | undefined,
|
|
136
156
|
): ModelSelection {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
157
|
+
const requested = requestedId === null || requestedId === '' ? undefined : requestedId;
|
|
158
|
+
const defaultEntry = (): CatalogueEntry | null =>
|
|
159
|
+
catalogue?.entries.find((entry) => entry.id === catalogue.defaultModelId) ?? null;
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return { requestedId, effectiveId: requestedId, source: 'requested' };
|
|
161
|
+
if (requested === undefined) {
|
|
162
|
+
return { requestedId: undefined, effectiveId: undefined, entry: defaultEntry(), source: 'default' };
|
|
144
163
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
offered[0];
|
|
152
|
-
if (replacement === undefined) {
|
|
153
|
-
return { requestedId, effectiveId: requestedId, source: 'requested' };
|
|
164
|
+
if (catalogue === undefined) {
|
|
165
|
+
return { requestedId: requested, effectiveId: requested, entry: null, source: 'requested' };
|
|
166
|
+
}
|
|
167
|
+
const entry = catalogue.entries.find((candidate) => candidate.id === requested);
|
|
168
|
+
if (entry !== undefined) {
|
|
169
|
+
return { requestedId: requested, effectiveId: requested, entry, source: 'requested' };
|
|
154
170
|
}
|
|
155
|
-
return { requestedId, effectiveId:
|
|
171
|
+
return { requestedId: requested, effectiveId: undefined, entry: defaultEntry(), source: 'replaced' };
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
/**
|
|
@@ -162,9 +178,9 @@ export function resolveSelection(
|
|
|
162
178
|
* request. A REJECTED promise is evicted, because caching a failure would make a
|
|
163
179
|
* single cold-start network blip permanent for the life of the process.
|
|
164
180
|
*/
|
|
165
|
-
const inFlight = new Map<string, Promise<
|
|
181
|
+
const inFlight = new Map<string, Promise<Catalogue>>();
|
|
166
182
|
|
|
167
|
-
export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<
|
|
183
|
+
export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<Catalogue> {
|
|
168
184
|
const cached = inFlight.get(apiUrl);
|
|
169
185
|
if (cached !== undefined) return cached;
|
|
170
186
|
|
|
@@ -187,23 +203,20 @@ export function clearCatalogueCache(): void {
|
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
/**
|
|
190
|
-
* The
|
|
206
|
+
* The `model` a request should carry, given what the caller asked for, or
|
|
207
|
+
* `undefined` to omit it and let the server's default answer.
|
|
191
208
|
*
|
|
192
209
|
* Never throws: a catalogue that cannot be read leaves the requested identifier
|
|
193
|
-
* alone, which is the same answer as "the catalogue has not loaded yet".
|
|
194
|
-
* consumer that wants to KNOW the catalogue is unreadable calls
|
|
195
|
-
* {@link fetchCatalogue} directly.
|
|
210
|
+
* alone, which is the same answer as "the catalogue has not loaded yet".
|
|
196
211
|
*/
|
|
197
212
|
export async function resolveModelId(
|
|
198
213
|
apiUrl: string,
|
|
199
|
-
requestedId: string,
|
|
214
|
+
requestedId: string | undefined,
|
|
200
215
|
accessToken?: string,
|
|
201
|
-
|
|
202
|
-
)
|
|
203
|
-
if (requestedId.startsWith('mode:')) return requestedId;
|
|
216
|
+
): Promise<string | undefined> {
|
|
217
|
+
if (requestedId === undefined || requestedId === '') return undefined;
|
|
204
218
|
try {
|
|
205
|
-
|
|
206
|
-
return resolveSelection(requestedId, entries, preferredId).effectiveId;
|
|
219
|
+
return resolveSelection(requestedId, await fetchCatalogue(apiUrl, accessToken)).effectiveId;
|
|
207
220
|
} catch {
|
|
208
221
|
return requestedId;
|
|
209
222
|
}
|
package/src/lib/chat-stream.ts
CHANGED
|
@@ -177,11 +177,6 @@ function validateIgnoredNamedEvent(eventName: string, payload: JsonObject): void
|
|
|
177
177
|
requireEventVersion(payload, eventName);
|
|
178
178
|
|
|
179
179
|
switch (eventName) {
|
|
180
|
-
case 'alia.model_switch':
|
|
181
|
-
assertOnlyKeys(payload, ['eventVersion', 'model', 'modelName'], eventName);
|
|
182
|
-
asString(payload.model, `${eventName} model`);
|
|
183
|
-
asString(payload.modelName, `${eventName} modelName`);
|
|
184
|
-
return;
|
|
185
180
|
case 'alia.suggest_new_conversation':
|
|
186
181
|
assertOnlyKeys(payload, ['eventVersion', 'reason'], eventName);
|
|
187
182
|
asString(payload.reason, `${eventName} reason`);
|
|
@@ -16,8 +16,18 @@ export interface AuthenticatedResponseClient {
|
|
|
16
16
|
|
|
17
17
|
export interface AliaChatRequest {
|
|
18
18
|
readonly url: string;
|
|
19
|
-
|
|
19
|
+
/** `publisher/model`; omitted, the server's default model answers. */
|
|
20
|
+
readonly model?: string;
|
|
20
21
|
readonly messages: ReadonlyArray<{ readonly role: string; readonly content: string }>;
|
|
22
|
+
/**
|
|
23
|
+
* `'voice'` asks for an answer meant to be heard: short, conversational, no
|
|
24
|
+
* formatting (the API layers its voice response profile over the turn's
|
|
25
|
+
* prompt). Omitted for ordinary text chat, which sends exactly what it
|
|
26
|
+
* always sent.
|
|
27
|
+
*/
|
|
28
|
+
readonly responseMode?: 'voice';
|
|
29
|
+
/** The agent whose thread this turn belongs to, when there is one. */
|
|
30
|
+
readonly agentId?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/**
|
|
@@ -39,9 +49,11 @@ export async function streamAliaChat(
|
|
|
39
49
|
'Content-Type': 'application/json',
|
|
40
50
|
},
|
|
41
51
|
body: JSON.stringify({
|
|
42
|
-
model: request.model,
|
|
52
|
+
...(request.model === undefined ? {} : { model: request.model }),
|
|
43
53
|
messages: request.messages,
|
|
44
54
|
stream: true,
|
|
55
|
+
...(request.responseMode === undefined ? {} : { responseMode: request.responseMode }),
|
|
56
|
+
...(request.agentId === undefined ? {} : { agentId: request.agentId }),
|
|
45
57
|
}),
|
|
46
58
|
signal,
|
|
47
59
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language to recognize when the caller names none.
|
|
3
|
+
*
|
|
4
|
+
* The browser's own language on web; the runtime's resolved locale on native
|
|
5
|
+
* (Hermes implements `Intl`); `en-US` when neither answers. An app should pass
|
|
6
|
+
* its UI locale instead — the Alia app does — because a person who chose
|
|
7
|
+
* Spanish in the app expects to be understood in Spanish whatever the device
|
|
8
|
+
* is set to.
|
|
9
|
+
*/
|
|
10
|
+
export function defaultSpeechLanguage(): string {
|
|
11
|
+
const browser = globalThis.navigator?.language;
|
|
12
|
+
if (typeof browser === 'string' && browser !== '') return browser;
|
|
13
|
+
try {
|
|
14
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
15
|
+
if (resolved) return resolved;
|
|
16
|
+
} catch {
|
|
17
|
+
// No Intl on this runtime.
|
|
18
|
+
}
|
|
19
|
+
return 'en-US';
|
|
20
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { SpeechRecognitionFailure } from './speech-recognition-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every way dictation or a voice call can fail that a person is told about,
|
|
5
|
+
* as a stable code.
|
|
6
|
+
*
|
|
7
|
+
* The hooks expose the code beside the English sentence (`errorCode` /
|
|
8
|
+
* `turnErrorCode` next to `error` / `turnError`) so an app can say it in its
|
|
9
|
+
* own language; the sentence stays for a consumer that has no translations.
|
|
10
|
+
*/
|
|
11
|
+
export type VoiceErrorCode =
|
|
12
|
+
| 'speech-unsupported'
|
|
13
|
+
| 'microphone-denied'
|
|
14
|
+
| 'microphone-busy'
|
|
15
|
+
| 'microphone-missing'
|
|
16
|
+
| 'speech-network'
|
|
17
|
+
| 'speech-language'
|
|
18
|
+
| 'speech-failed'
|
|
19
|
+
| 'voice-unavailable'
|
|
20
|
+
| 'not-authenticated'
|
|
21
|
+
| 'turn-failed'
|
|
22
|
+
| 'no-answer'
|
|
23
|
+
| 'not-played';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The English copy for each code — the same sentences dictation and voice mode
|
|
27
|
+
* used when they recorded through `expo-audio` and LiveKit, so moving
|
|
28
|
+
* recognition onto the device did not change what an error says.
|
|
29
|
+
*/
|
|
30
|
+
export const VOICE_ERROR_MESSAGES: Readonly<Record<VoiceErrorCode, string>> = {
|
|
31
|
+
'speech-unsupported': 'Speech recognition is not available on this device or browser',
|
|
32
|
+
'microphone-denied': 'Microphone permission required',
|
|
33
|
+
'microphone-busy': 'Microphone is in use by another application',
|
|
34
|
+
'microphone-missing': 'No microphone found — check your input devices',
|
|
35
|
+
'speech-network': 'Speech recognition needs a network connection',
|
|
36
|
+
'speech-language': 'Speech recognition does not support this language',
|
|
37
|
+
'speech-failed': 'Speech recognition failed',
|
|
38
|
+
'voice-unavailable': 'Voice is not available on this device',
|
|
39
|
+
'not-authenticated': 'Not authenticated',
|
|
40
|
+
'turn-failed': 'Voice request failed',
|
|
41
|
+
'no-answer': 'No answer came back — try saying it again',
|
|
42
|
+
'not-played': 'The answer could not be played aloud',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The code for a recognition failure. `null` for the failures that are not
|
|
47
|
+
* errors: `aborted` is a stop the caller asked for, and `no-speech` is a
|
|
48
|
+
* silence the caller recovers from.
|
|
49
|
+
*/
|
|
50
|
+
export function speechFailureCode(failure: SpeechRecognitionFailure): VoiceErrorCode | null {
|
|
51
|
+
switch (failure.code) {
|
|
52
|
+
case 'aborted':
|
|
53
|
+
case 'no-speech':
|
|
54
|
+
return null;
|
|
55
|
+
case 'unsupported':
|
|
56
|
+
return 'speech-unsupported';
|
|
57
|
+
case 'not-allowed':
|
|
58
|
+
return 'microphone-denied';
|
|
59
|
+
case 'audio-capture':
|
|
60
|
+
return failure.detail === 'NotReadableError' || failure.detail === 'TrackStartError'
|
|
61
|
+
? 'microphone-busy'
|
|
62
|
+
: 'microphone-missing';
|
|
63
|
+
case 'network':
|
|
64
|
+
return 'speech-network';
|
|
65
|
+
case 'language-not-supported':
|
|
66
|
+
return 'speech-language';
|
|
67
|
+
case 'other':
|
|
68
|
+
return 'speech-failed';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The English copy a person sees when listening fails; `null` as for `speechFailureCode`. */
|
|
73
|
+
export function speechFailureMessage(failure: SpeechRecognitionFailure): string | null {
|
|
74
|
+
const code = speechFailureCode(failure);
|
|
75
|
+
return code === null ? null : VOICE_ERROR_MESSAGES[code];
|
|
76
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shape on-device speech recognition has in this package.
|
|
3
|
+
*
|
|
4
|
+
* Two engines sit behind it and neither leaks through: the browser's Web
|
|
5
|
+
* Speech API (`speech-recognition.ts`, picked by web bundlers and by tests) and
|
|
6
|
+
* `expo-speech-recognition` on iOS and Android (`speech-recognition.native.ts`,
|
|
7
|
+
* picked by Metro's platform resolution). Dictation and the voice loop are
|
|
8
|
+
* written against this file only, so the audio never leaves the device through
|
|
9
|
+
* Alia — there is no transcription endpoint, and nothing here posts audio
|
|
10
|
+
* anywhere. Whether the ENGINE uses a network service is the platform's
|
|
11
|
+
* business and is stated in `docs/voice.mdx`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Why a recognition session failed, in the vocabulary both engines share. */
|
|
15
|
+
export type SpeechRecognitionFailureCode =
|
|
16
|
+
/** Microphone or speech-recognition permission was refused. */
|
|
17
|
+
| 'not-allowed'
|
|
18
|
+
/** No microphone, or it could not be opened. */
|
|
19
|
+
| 'audio-capture'
|
|
20
|
+
/** The engine needed the network and could not reach it. */
|
|
21
|
+
| 'network'
|
|
22
|
+
/** The engine does not recognize this language. */
|
|
23
|
+
| 'language-not-supported'
|
|
24
|
+
/** Nothing was said before the engine gave up. Recoverable: start again. */
|
|
25
|
+
| 'no-speech'
|
|
26
|
+
/** The session was aborted on purpose. Never an error to report. */
|
|
27
|
+
| 'aborted'
|
|
28
|
+
/** Recognition is not available on this device or in this browser. */
|
|
29
|
+
| 'unsupported'
|
|
30
|
+
/** Anything else — the engine was busy, interrupted, or said nothing useful. */
|
|
31
|
+
| 'other';
|
|
32
|
+
|
|
33
|
+
export interface SpeechRecognitionFailure {
|
|
34
|
+
readonly code: SpeechRecognitionFailureCode;
|
|
35
|
+
/** The engine's own words, for logs. Never shown as-is. */
|
|
36
|
+
readonly detail?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SpeechRecognitionHandlers {
|
|
40
|
+
/**
|
|
41
|
+
* The whole of what this session has heard so far, and whether the engine
|
|
42
|
+
* considers it settled. It is the FULL transcript each time, never a delta:
|
|
43
|
+
* both engines revise earlier words as more audio arrives.
|
|
44
|
+
*/
|
|
45
|
+
onResult(result: { transcript: string; isFinal: boolean }): void;
|
|
46
|
+
/** Input level, 0..1, on the same scale as dictation's metering. */
|
|
47
|
+
onLevel?(level: number): void;
|
|
48
|
+
onError(failure: SpeechRecognitionFailure): void;
|
|
49
|
+
/** The session is over, whichever way it ended. Always the last call. */
|
|
50
|
+
onEnd(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SpeechRecognitionOptions {
|
|
54
|
+
/** BCP-47 tag, e.g. `es-ES`. */
|
|
55
|
+
readonly lang: string;
|
|
56
|
+
/**
|
|
57
|
+
* Android: the recognition service to use, by package, instead of the
|
|
58
|
+
* system's default. `chooseSpeechRecognizer` picks it.
|
|
59
|
+
*/
|
|
60
|
+
readonly service?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Remove the device's own playback from what the microphone hears, where the
|
|
63
|
+
* platform can (iOS voice processing). Voice mode listens while it speaks,
|
|
64
|
+
* so without this it can hear itself.
|
|
65
|
+
*/
|
|
66
|
+
readonly echoCancellation?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SpeechRecognitionSession {
|
|
70
|
+
/** Stop listening and deliver a final result, then `onEnd`. */
|
|
71
|
+
stop(): void;
|
|
72
|
+
/** Stop listening and discard; `onEnd` still follows. */
|
|
73
|
+
abort(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Map a raw 0..1-ish reading to the field's level, clamped. */
|
|
77
|
+
export function clampLevel(level: number): number {
|
|
78
|
+
if (!Number.isFinite(level)) return 0;
|
|
79
|
+
return Math.min(1, Math.max(0, level));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What to start a session with, decided before the first one opens.
|
|
84
|
+
*
|
|
85
|
+
* `silent` is set when the device has recognition services and every one of
|
|
86
|
+
* them, asked, named no language at all. A `language-not-supported` from such
|
|
87
|
+
* a device is not about the language: nothing on it recognizes speech (a Pixel
|
|
88
|
+
* without Google's speech services, whose remaining service has no language
|
|
89
|
+
* pack). Where nobody could be asked — the web, iOS — it is `false` and a
|
|
90
|
+
* language error stays one.
|
|
91
|
+
*/
|
|
92
|
+
export type SpeechRecognizerChoice =
|
|
93
|
+
| { readonly lang: string; readonly service?: string; readonly silent: boolean }
|
|
94
|
+
| { readonly failure: SpeechRecognitionFailure };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The tag in `offered` that recognizes `lang`: the same tag (`en-US`, however
|
|
98
|
+
* the service spells it — `en_US`, `en-us`), else another region of the same
|
|
99
|
+
* language (`es-US` for `es-ES`), else `null`.
|
|
100
|
+
*/
|
|
101
|
+
export function matchSpeechLocale(lang: string, offered: readonly string[]): string | null {
|
|
102
|
+
const norm = (tag: string) => tag.replace(/_/g, '-').toLowerCase();
|
|
103
|
+
const wanted = norm(lang);
|
|
104
|
+
const exact = offered.find((tag) => norm(tag) === wanted);
|
|
105
|
+
if (exact !== undefined) return exact;
|
|
106
|
+
const language = wanted.split('-')[0];
|
|
107
|
+
return offered.find((tag) => norm(tag).split('-')[0] === language) ?? null;
|
|
108
|
+
}
|