@alia.onl/sdk 22.6.0 → 22.8.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 +31 -0
- package/package.json +1 -1
- package/src/dictation.ts +24 -4
- package/src/lib/dictation-protocol.ts +142 -0
- package/src/lib/dictation-stream.ts +238 -0
- package/src/runtime/host-tools.ts +59 -4
- package/src/runtime/index.ts +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 22.8.0
|
|
4
|
+
|
|
5
|
+
- **Streaming dictation (ADR 0024).** `@alia.onl/sdk/dictation` gains
|
|
6
|
+
`openDictationStream({ apiUrl, getToken | token, language?, onEvent, signal?, openSocket?,
|
|
7
|
+
endTimeoutMs? })`: the caller's PCM16 LE mono 24 kHz frames go to
|
|
8
|
+
`GET /v1/audio/transcriptions/stream`, and Alia's events come back while the person speaks:
|
|
9
|
+
`dictation.ready`, `transcript.interim` / `transcript.delta` / `transcript.final` per numbered
|
|
10
|
+
`segment`, `error { code, retryable }` and one `dictation.closed { reason }`. A segment's
|
|
11
|
+
`transcript.final` is the only text to keep. It returns `{ append, commit, end, close, closed }`;
|
|
12
|
+
`end()` resolves once the finals have arrived. The protocol module (`DICTATION_PROTOCOL_VERSION`
|
|
13
|
+
1, `parseDictationServerEvent`, `dictationStreamUrl`) is exported beside it. It reaches no React
|
|
14
|
+
or native module (`check:entries`). `useDictation` still records a clip for batch
|
|
15
|
+
transcription; adopting the stream there is a follow-up.
|
|
16
|
+
- The API that serves the stream ships with this release; against an older API the socket's
|
|
17
|
+
path answers 404 and the stream reports `TRANSCRIPTION_FAILED`, so batch
|
|
18
|
+
`requestTranscription` stays the fallback.
|
|
19
|
+
|
|
20
|
+
## 22.7.0
|
|
21
|
+
|
|
22
|
+
- **Image results for awaited host tools.** An awaited tool's `execute` may answer
|
|
23
|
+
`result: { type: "image", mediaType, data, text? }` (`AliaHostImageResult`: `image/jpeg`,
|
|
24
|
+
`image/png` or `image/webp`, `data` in standard base64 with no `data:` prefix, an optional
|
|
25
|
+
caption of at most `HOST_IMAGE_TEXT_MAX_CHARS`, 4,000). The model sees the image itself in that
|
|
26
|
+
turn; the stored conversation and the `alia.tool_result` frame keep
|
|
27
|
+
`{ type, mediaType, text?, omitted: true }` instead. The whole result still counts toward
|
|
28
|
+
`HOST_RESULT_MAX_CHARS` (100,000 characters as JSON), so size a screenshot to fit.
|
|
29
|
+
- A result that claims `type: "image"` but is malformed is answered `failed` with the reason
|
|
30
|
+
(`hostImageResultProblem`), instead of being sent for the API to refuse while the model waits.
|
|
31
|
+
- Needs the Alia API that ships with this release (`docs/agent-embedding.md` §2); an older API
|
|
32
|
+
passes an image result to the model as JSON text.
|
|
33
|
+
|
|
3
34
|
## 22.6.0
|
|
4
35
|
|
|
5
36
|
- **`@alia.onl/sdk/dictation`: dictation's request on its own.** `requestTranscription`,
|
package/package.json
CHANGED
package/src/dictation.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// @alia.onl/sdk/dictation — dictation's
|
|
2
|
-
// `POST /v1/audio/transcriptions` (ADR 0017 §7)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// @alia.onl/sdk/dictation — dictation's network half, without a component, a hook or a recorder
|
|
2
|
+
// `POST /v1/audio/transcriptions` (ADR 0017 §7) and the streaming dictation socket
|
|
3
|
+
// `GET /v1/audio/transcriptions/stream` (ADR 0024) as plain functions: an app that records audio
|
|
4
|
+
// its own way (Astro's browser shell) dictates from here and reaches no React, no native module
|
|
5
|
+
// and no Oxy client. `useDictation` and `AliaComposer` use the same batch request.
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
DictationError,
|
|
@@ -18,3 +19,22 @@ export type {
|
|
|
18
19
|
} from './lib/dictation';
|
|
19
20
|
export type { AudioRequestAuthentication } from './lib/audio-request-auth';
|
|
20
21
|
export type { VoiceErrorCode } from './lib/speech-messages';
|
|
22
|
+
export {
|
|
23
|
+
DICTATION_PROTOCOL_VERSION,
|
|
24
|
+
DICTATION_SAMPLE_RATE,
|
|
25
|
+
DICTATION_STREAM_PATH,
|
|
26
|
+
dictationStreamUrl,
|
|
27
|
+
parseDictationServerEvent,
|
|
28
|
+
} from './lib/dictation-protocol';
|
|
29
|
+
export type {
|
|
30
|
+
DictationCloseReason,
|
|
31
|
+
DictationServerEvent,
|
|
32
|
+
DictationStreamErrorCode,
|
|
33
|
+
} from './lib/dictation-protocol';
|
|
34
|
+
export { openDictationStream } from './lib/dictation-stream';
|
|
35
|
+
export type {
|
|
36
|
+
DictationSocket,
|
|
37
|
+
DictationStream,
|
|
38
|
+
DictationStreamAuthentication,
|
|
39
|
+
DictationStreamOptions,
|
|
40
|
+
} from './lib/dictation-stream';
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of Alia's streaming dictation protocol,
|
|
3
|
+
* `GET /v1/audio/transcriptions/stream` (ADR 0024).
|
|
4
|
+
*
|
|
5
|
+
* The server's copy is `packages/api/src/lib/dictation/protocol.ts`; the SDK
|
|
6
|
+
* does not depend on the API package, so the shapes are restated here and
|
|
7
|
+
* `DICTATION_PROTOCOL_VERSION` is what turns a mismatch into a refusal instead
|
|
8
|
+
* of a misunderstanding. The API's tests hold the two copies equal.
|
|
9
|
+
*
|
|
10
|
+
* - Binary, client → Alia: microphone PCM16 LE, 24 kHz, mono.
|
|
11
|
+
* - Text: one JSON event per frame, both ways.
|
|
12
|
+
*
|
|
13
|
+
* Segments are Alia's numbers, from 0, in the order the person spoke them. A
|
|
14
|
+
* segment's `transcript.final` is the only text a client keeps:
|
|
15
|
+
* `transcript.interim` (the segment's provisional text, whole, replacing the
|
|
16
|
+
* last) and `transcript.delta` (locked text appended) are display state the
|
|
17
|
+
* final supersedes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const DICTATION_PROTOCOL_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export const DICTATION_STREAM_PATH = '/v1/audio/transcriptions/stream';
|
|
23
|
+
|
|
24
|
+
/** PCM16 mono at this rate: the one encoding the stream takes. */
|
|
25
|
+
export const DICTATION_SAMPLE_RATE = 24_000;
|
|
26
|
+
|
|
27
|
+
/** What a dictation ends with that is not a failure. */
|
|
28
|
+
export type DictationCloseReason =
|
|
29
|
+
| 'ended'
|
|
30
|
+
| 'max-duration'
|
|
31
|
+
| 'idle-timeout'
|
|
32
|
+
| 'error'
|
|
33
|
+
| 'server-shutdown';
|
|
34
|
+
|
|
35
|
+
/** Why a dictation failed, as Alia names it. */
|
|
36
|
+
export type DictationStreamErrorCode =
|
|
37
|
+
| 'TRANSCRIPTION_UNAVAILABLE'
|
|
38
|
+
| 'TRANSCRIPTION_LIMITED'
|
|
39
|
+
| 'TRANSCRIPTION_REJECTED'
|
|
40
|
+
| 'TRANSCRIPTION_FAILED'
|
|
41
|
+
| 'CLIP_TOO_LONG'
|
|
42
|
+
| 'INVALID_REQUEST'
|
|
43
|
+
| 'NOT_AUTHENTICATED'
|
|
44
|
+
| 'CREDENTIAL_NOT_SUPPORTED';
|
|
45
|
+
|
|
46
|
+
export type DictationServerEvent =
|
|
47
|
+
| { readonly type: 'dictation.ready'; readonly maxDurationMs: number }
|
|
48
|
+
| { readonly type: 'transcript.interim'; readonly segment: number; readonly text: string }
|
|
49
|
+
| { readonly type: 'transcript.delta'; readonly segment: number; readonly text: string }
|
|
50
|
+
| { readonly type: 'transcript.final'; readonly segment: number; readonly text: string }
|
|
51
|
+
| {
|
|
52
|
+
readonly type: 'error';
|
|
53
|
+
readonly code: DictationStreamErrorCode;
|
|
54
|
+
readonly message: string;
|
|
55
|
+
readonly retryable: boolean;
|
|
56
|
+
}
|
|
57
|
+
| { readonly type: 'dictation.closed'; readonly reason: DictationCloseReason };
|
|
58
|
+
|
|
59
|
+
export type DictationClientEvent =
|
|
60
|
+
| { readonly type: 'auth'; readonly token: string }
|
|
61
|
+
| {
|
|
62
|
+
readonly type: 'dictation.start';
|
|
63
|
+
readonly protocol: number;
|
|
64
|
+
readonly language?: string;
|
|
65
|
+
readonly sampleRate: number;
|
|
66
|
+
}
|
|
67
|
+
| { readonly type: 'dictation.commit' }
|
|
68
|
+
| { readonly type: 'dictation.end' };
|
|
69
|
+
|
|
70
|
+
const CLOSE_REASONS: ReadonlySet<string> = new Set<DictationCloseReason>([
|
|
71
|
+
'ended',
|
|
72
|
+
'max-duration',
|
|
73
|
+
'idle-timeout',
|
|
74
|
+
'error',
|
|
75
|
+
'server-shutdown',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const ERROR_CODES: ReadonlySet<string> = new Set<DictationStreamErrorCode>([
|
|
79
|
+
'TRANSCRIPTION_UNAVAILABLE',
|
|
80
|
+
'TRANSCRIPTION_LIMITED',
|
|
81
|
+
'TRANSCRIPTION_REJECTED',
|
|
82
|
+
'TRANSCRIPTION_FAILED',
|
|
83
|
+
'CLIP_TOO_LONG',
|
|
84
|
+
'INVALID_REQUEST',
|
|
85
|
+
'NOT_AUTHENTICATED',
|
|
86
|
+
'CREDENTIAL_NOT_SUPPORTED',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const isSegment = (value: unknown): value is number =>
|
|
90
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
91
|
+
|
|
92
|
+
/** One server text frame, or `null` when it is not one of protocol 1's events. */
|
|
93
|
+
export function parseDictationServerEvent(text: string): DictationServerEvent | null {
|
|
94
|
+
let value: unknown;
|
|
95
|
+
try {
|
|
96
|
+
value = JSON.parse(text);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (value === null || typeof value !== 'object') return null;
|
|
101
|
+
const event = value as Record<string, unknown>;
|
|
102
|
+
switch (event.type) {
|
|
103
|
+
case 'dictation.ready':
|
|
104
|
+
return typeof event.maxDurationMs === 'number' && event.maxDurationMs > 0
|
|
105
|
+
? { type: 'dictation.ready', maxDurationMs: event.maxDurationMs }
|
|
106
|
+
: null;
|
|
107
|
+
case 'transcript.interim':
|
|
108
|
+
case 'transcript.delta':
|
|
109
|
+
case 'transcript.final':
|
|
110
|
+
return isSegment(event.segment) && typeof event.text === 'string'
|
|
111
|
+
? { type: event.type, segment: event.segment, text: event.text }
|
|
112
|
+
: null;
|
|
113
|
+
case 'error':
|
|
114
|
+
return typeof event.code === 'string' &&
|
|
115
|
+
ERROR_CODES.has(event.code) &&
|
|
116
|
+
typeof event.message === 'string'
|
|
117
|
+
? {
|
|
118
|
+
type: 'error',
|
|
119
|
+
code: event.code as DictationStreamErrorCode,
|
|
120
|
+
message: event.message,
|
|
121
|
+
retryable: event.retryable === true,
|
|
122
|
+
}
|
|
123
|
+
: null;
|
|
124
|
+
case 'dictation.closed':
|
|
125
|
+
return typeof event.reason === 'string' && CLOSE_REASONS.has(event.reason)
|
|
126
|
+
? { type: 'dictation.closed', reason: event.reason as DictationCloseReason }
|
|
127
|
+
: null;
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `wss://…/v1/audio/transcriptions/stream` for an Alia API origin. */
|
|
134
|
+
export function dictationStreamUrl(apiUrl: string): string {
|
|
135
|
+
const base = apiUrl.replace(/\/+$/, '');
|
|
136
|
+
const ws = base.startsWith('https://')
|
|
137
|
+
? `wss://${base.slice('https://'.length)}`
|
|
138
|
+
: base.startsWith('http://')
|
|
139
|
+
? `ws://${base.slice('http://'.length)}`
|
|
140
|
+
: base;
|
|
141
|
+
return `${ws}${DICTATION_STREAM_PATH}`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming dictation's network half (ADR 0024): the caller's microphone PCM to
|
|
3
|
+
* `GET /v1/audio/transcriptions/stream`, and Alia's segments back while the
|
|
4
|
+
* person speaks.
|
|
5
|
+
*
|
|
6
|
+
* It records nothing and draws nothing: the caller brings PCM16 LE mono at
|
|
7
|
+
* 24 kHz from its own recorder (an AudioWorklet, a native module) and shows
|
|
8
|
+
* the segments its own way. A segment's `transcript.final` is the only text to
|
|
9
|
+
* keep; `transcript.interim` and `transcript.delta` are display state the
|
|
10
|
+
* final supersedes. When the stream cannot open (`TRANSCRIPTION_UNAVAILABLE`),
|
|
11
|
+
* batch `requestTranscription` is the fallback.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type DictationClientEvent,
|
|
16
|
+
type DictationCloseReason,
|
|
17
|
+
type DictationServerEvent,
|
|
18
|
+
DICTATION_PROTOCOL_VERSION,
|
|
19
|
+
DICTATION_SAMPLE_RATE,
|
|
20
|
+
dictationStreamUrl,
|
|
21
|
+
parseDictationServerEvent,
|
|
22
|
+
} from './dictation-protocol';
|
|
23
|
+
|
|
24
|
+
/** The WebSocket the stream drives: a browser's, React Native's or a test's. */
|
|
25
|
+
export interface DictationSocket {
|
|
26
|
+
binaryType: string;
|
|
27
|
+
readonly readyState: number;
|
|
28
|
+
onopen: ((event: unknown) => void) | null;
|
|
29
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
30
|
+
onclose: ((event: unknown) => void) | null;
|
|
31
|
+
onerror: ((event: unknown) => void) | null;
|
|
32
|
+
send(data: string | ArrayBufferLike | ArrayBufferView): void;
|
|
33
|
+
close(code?: number, reason?: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How the stream proves the person: their Oxy session token, read once at open. */
|
|
37
|
+
export type DictationStreamAuthentication =
|
|
38
|
+
| {
|
|
39
|
+
readonly getToken: (
|
|
40
|
+
target: string,
|
|
41
|
+
options?: { signal?: AbortSignal },
|
|
42
|
+
) => string | null | Promise<string | null>;
|
|
43
|
+
readonly token?: never;
|
|
44
|
+
}
|
|
45
|
+
| { readonly token: string; readonly getToken?: never };
|
|
46
|
+
|
|
47
|
+
export type DictationStreamOptions = DictationStreamAuthentication & {
|
|
48
|
+
/** Alia's API origin, `https://api.alia.onl`. */
|
|
49
|
+
readonly apiUrl: string;
|
|
50
|
+
/** BCP 47; omitted lets the model detect the language. */
|
|
51
|
+
readonly language?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Every event, in order. It always ends with one `dictation.closed`; a failure
|
|
54
|
+
* comes as `error` just before it. A connection Alia never answered on is
|
|
55
|
+
* reported as `TRANSCRIPTION_FAILED`, retryable.
|
|
56
|
+
*/
|
|
57
|
+
readonly onEvent: (event: DictationServerEvent) => void;
|
|
58
|
+
/** Aborting it is `close()`. */
|
|
59
|
+
readonly signal?: AbortSignal;
|
|
60
|
+
/** The socket factory; the platform's `WebSocket` by default. */
|
|
61
|
+
readonly openSocket?: (url: string) => DictationSocket;
|
|
62
|
+
/** How long `end()` waits for Alia's `dictation.closed` before closing the socket. */
|
|
63
|
+
readonly endTimeoutMs?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface DictationStream {
|
|
67
|
+
/** One microphone frame: PCM16 LE, mono, {@link DICTATION_SAMPLE_RATE} Hz. */
|
|
68
|
+
append(pcm16: Uint8Array): void;
|
|
69
|
+
/** End the current segment now (push-to-talk). */
|
|
70
|
+
commit(): void;
|
|
71
|
+
/** No more audio: resolves with how the dictation closed, once the finals have arrived. */
|
|
72
|
+
end(): Promise<DictationCloseReason>;
|
|
73
|
+
/** Drop the dictation now; nothing more is reported. */
|
|
74
|
+
close(): void;
|
|
75
|
+
/** Settles with how the dictation closed, whatever closed it. */
|
|
76
|
+
readonly closed: Promise<DictationCloseReason>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const OPEN = 1;
|
|
80
|
+
const DEFAULT_END_TIMEOUT_MS = 8_000;
|
|
81
|
+
/** Audio held until the socket is open and the start sent: ten seconds. */
|
|
82
|
+
const MAX_HELD_BYTES = 10 * DICTATION_SAMPLE_RATE * 2;
|
|
83
|
+
|
|
84
|
+
function defaultSocket(url: string): DictationSocket {
|
|
85
|
+
const WebSocketClass = (globalThis as { WebSocket?: new (url: string) => DictationSocket })
|
|
86
|
+
.WebSocket;
|
|
87
|
+
if (!WebSocketClass) throw new Error('This platform has no WebSocket');
|
|
88
|
+
return new WebSocketClass(url);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Open one streaming dictation. */
|
|
92
|
+
export function openDictationStream(options: DictationStreamOptions): DictationStream {
|
|
93
|
+
const url = dictationStreamUrl(options.apiUrl);
|
|
94
|
+
const credentialAbort = new AbortController();
|
|
95
|
+
let socket: DictationSocket | null = null;
|
|
96
|
+
let started = false;
|
|
97
|
+
let finished = false;
|
|
98
|
+
let held: Uint8Array[] = [];
|
|
99
|
+
let heldBytes = 0;
|
|
100
|
+
let endRequested = false;
|
|
101
|
+
let endTimer: ReturnType<typeof setTimeout> | null = null;
|
|
102
|
+
let resolveClosed!: (reason: DictationCloseReason) => void;
|
|
103
|
+
const closed = new Promise<DictationCloseReason>((resolve) => {
|
|
104
|
+
resolveClosed = resolve;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const sendEvent = (event: DictationClientEvent) => {
|
|
108
|
+
if (socket?.readyState === OPEN) socket.send(JSON.stringify(event));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const finish = (reason: DictationCloseReason, report: boolean) => {
|
|
112
|
+
if (finished) return;
|
|
113
|
+
finished = true;
|
|
114
|
+
if (endTimer !== null) clearTimeout(endTimer);
|
|
115
|
+
credentialAbort.abort();
|
|
116
|
+
held = [];
|
|
117
|
+
const current = socket;
|
|
118
|
+
socket = null;
|
|
119
|
+
if (current) {
|
|
120
|
+
current.onmessage = null;
|
|
121
|
+
current.onclose = null;
|
|
122
|
+
current.onerror = null;
|
|
123
|
+
if (current.readyState === OPEN || current.readyState === 0) current.close(1000);
|
|
124
|
+
}
|
|
125
|
+
if (report) options.onEvent({ type: 'dictation.closed', reason });
|
|
126
|
+
resolveClosed(reason);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const fail = () => {
|
|
130
|
+
if (finished) return;
|
|
131
|
+
options.onEvent({
|
|
132
|
+
type: 'error',
|
|
133
|
+
code: 'TRANSCRIPTION_FAILED',
|
|
134
|
+
message: 'The dictation lost its connection to Alia.',
|
|
135
|
+
retryable: true,
|
|
136
|
+
});
|
|
137
|
+
finish('error', true);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const readToken = async (): Promise<string | null> => {
|
|
141
|
+
if (options.token !== undefined) return options.token || null;
|
|
142
|
+
try {
|
|
143
|
+
const token = await options.getToken(url, { signal: credentialAbort.signal });
|
|
144
|
+
return token || null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const begin = async (opened: DictationSocket) => {
|
|
151
|
+
const token = await readToken();
|
|
152
|
+
if (finished || socket !== opened) return;
|
|
153
|
+
if (!token) {
|
|
154
|
+
options.onEvent({
|
|
155
|
+
type: 'error',
|
|
156
|
+
code: 'NOT_AUTHENTICATED',
|
|
157
|
+
message: 'Sign in to Alia to dictate.',
|
|
158
|
+
retryable: false,
|
|
159
|
+
});
|
|
160
|
+
finish('error', true);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
sendEvent({ type: 'auth', token });
|
|
164
|
+
sendEvent({
|
|
165
|
+
type: 'dictation.start',
|
|
166
|
+
protocol: DICTATION_PROTOCOL_VERSION,
|
|
167
|
+
sampleRate: DICTATION_SAMPLE_RATE,
|
|
168
|
+
...(options.language === undefined ? {} : { language: options.language }),
|
|
169
|
+
});
|
|
170
|
+
started = true;
|
|
171
|
+
for (const frame of held) opened.send(frame);
|
|
172
|
+
held = [];
|
|
173
|
+
heldBytes = 0;
|
|
174
|
+
if (endRequested) sendEvent({ type: 'dictation.end' });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
socket = (options.openSocket ?? defaultSocket)(url);
|
|
179
|
+
} catch {
|
|
180
|
+
queueMicrotask(fail);
|
|
181
|
+
}
|
|
182
|
+
if (socket) {
|
|
183
|
+
const opened = socket;
|
|
184
|
+
opened.binaryType = 'arraybuffer';
|
|
185
|
+
opened.onopen = () => {
|
|
186
|
+
void begin(opened);
|
|
187
|
+
};
|
|
188
|
+
opened.onmessage = (message) => {
|
|
189
|
+
if (typeof message.data !== 'string') return;
|
|
190
|
+
const event = parseDictationServerEvent(message.data);
|
|
191
|
+
if (event === null || finished) return;
|
|
192
|
+
if (event.type === 'dictation.closed') {
|
|
193
|
+
options.onEvent(event);
|
|
194
|
+
finish(event.reason, false);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
options.onEvent(event);
|
|
198
|
+
};
|
|
199
|
+
opened.onclose = () => fail();
|
|
200
|
+
opened.onerror = () => undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const close = () => finish('ended', false);
|
|
204
|
+
if (options.signal) {
|
|
205
|
+
if (options.signal.aborted) queueMicrotask(close);
|
|
206
|
+
else options.signal.addEventListener('abort', close, { once: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
append(pcm16) {
|
|
211
|
+
if (finished || endRequested || pcm16.byteLength === 0) return;
|
|
212
|
+
if (started && socket?.readyState === OPEN) {
|
|
213
|
+
socket.send(pcm16);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (heldBytes + pcm16.byteLength > MAX_HELD_BYTES) return;
|
|
217
|
+
held.push(pcm16.slice());
|
|
218
|
+
heldBytes += pcm16.byteLength;
|
|
219
|
+
},
|
|
220
|
+
commit() {
|
|
221
|
+
if (!finished && started) sendEvent({ type: 'dictation.commit' });
|
|
222
|
+
},
|
|
223
|
+
end() {
|
|
224
|
+
if (finished) return closed;
|
|
225
|
+
if (!endRequested) {
|
|
226
|
+
endRequested = true;
|
|
227
|
+
if (started) sendEvent({ type: 'dictation.end' });
|
|
228
|
+
endTimer = setTimeout(
|
|
229
|
+
() => finish('ended', true),
|
|
230
|
+
options.endTimeoutMs ?? DEFAULT_END_TIMEOUT_MS,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return closed;
|
|
234
|
+
},
|
|
235
|
+
close,
|
|
236
|
+
closed,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* A tool declared with `awaitsResult: true` is read in the SAME turn instead ("this tab's text",
|
|
11
11
|
* "the open tabs"): the server waits, and this client answers it at once on the turn's
|
|
12
|
-
* `host-results` route with the outcome and the `result` its `execute` returned
|
|
12
|
+
* `host-results` route with the outcome and the `result` its `execute` returned — text, JSON or
|
|
13
|
+
* an image ({@link AliaHostImageResult}) the model sees as one.
|
|
13
14
|
*
|
|
14
15
|
* Three rules hold here, in a queue that knows nothing of React:
|
|
15
16
|
* - an action is performed at most once, whatever the stream repeats;
|
|
@@ -45,11 +46,61 @@ export interface AliaHostOutcome {
|
|
|
45
46
|
detail?: string;
|
|
46
47
|
/**
|
|
47
48
|
* What an awaited tool read, for the model: a string or any JSON value, at most
|
|
48
|
-
* {@link HOST_RESULT_MAX_CHARS} characters as JSON
|
|
49
|
+
* {@link HOST_RESULT_MAX_CHARS} characters as JSON, or an {@link AliaHostImageResult} the model
|
|
50
|
+
* sees as an image. Ignored for a tool that does not await.
|
|
49
51
|
*/
|
|
50
52
|
result?: unknown;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
export const HOST_IMAGE_MEDIA_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
|
|
56
|
+
export type AliaHostImageMediaType = (typeof HOST_IMAGE_MEDIA_TYPES)[number];
|
|
57
|
+
|
|
58
|
+
/** The longest caption an image result may carry. */
|
|
59
|
+
export const HOST_IMAGE_TEXT_MAX_CHARS = 4_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An awaited tool's image (a tab's screenshot): the model sees the image itself in that turn, and
|
|
63
|
+
* the stored conversation keeps `{ type, mediaType, text?, omitted: true }` in its place.
|
|
64
|
+
* `data` is the image's bytes in standard base64, with no `data:` prefix; the whole result counts
|
|
65
|
+
* toward {@link HOST_RESULT_MAX_CHARS}, so size the image to fit (a JPEG of the viewport).
|
|
66
|
+
*/
|
|
67
|
+
export interface AliaHostImageResult {
|
|
68
|
+
type: 'image';
|
|
69
|
+
mediaType: AliaHostImageMediaType;
|
|
70
|
+
data: string;
|
|
71
|
+
/** A caption for the model (the tab's URL and title), at most {@link HOST_IMAGE_TEXT_MAX_CHARS}. */
|
|
72
|
+
text?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
76
|
+
const IMAGE_KEYS = ['type', 'mediaType', 'data', 'text'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Why a result that claims to be an image (`type: "image"`) is not a valid one, or `null`. The
|
|
80
|
+
* server refuses such an answer, which would leave the model waiting until the action goes stale;
|
|
81
|
+
* the runtime answers `failed` with this reason instead.
|
|
82
|
+
*/
|
|
83
|
+
export function hostImageResultProblem(result: unknown): string | null {
|
|
84
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result)) return null;
|
|
85
|
+
const image = result as Record<string, unknown>;
|
|
86
|
+
if (image.type !== 'image') return null;
|
|
87
|
+
const extra = Object.keys(image).find((key) => !IMAGE_KEYS.includes(key));
|
|
88
|
+
if (extra !== undefined) return `An image result has no "${extra}".`;
|
|
89
|
+
if (!HOST_IMAGE_MEDIA_TYPES.includes(image.mediaType as AliaHostImageMediaType)) {
|
|
90
|
+
return `An image result's mediaType is one of ${HOST_IMAGE_MEDIA_TYPES.join(', ')}.`;
|
|
91
|
+
}
|
|
92
|
+
if (typeof image.data !== 'string' || image.data === '' || !BASE64.test(image.data)) {
|
|
93
|
+
return "An image result's data is standard base64 with no data: prefix.";
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
image.text !== undefined &&
|
|
97
|
+
(typeof image.text !== 'string' || image.text.length > HOST_IMAGE_TEXT_MAX_CHARS)
|
|
98
|
+
) {
|
|
99
|
+
return `An image result's text is a string of at most ${HOST_IMAGE_TEXT_MAX_CHARS} characters.`;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
53
104
|
/**
|
|
54
105
|
* The host tools a surface declares (`hostTools` on the chat). They are the tools of the chat's
|
|
55
106
|
* `app`, which a surface with host tools must name: the app is said once, on the chat, and is what
|
|
@@ -96,8 +147,10 @@ const DETAIL_LIMIT = 500;
|
|
|
96
147
|
/** The server's bound on an awaited action's `result`, as JSON. */
|
|
97
148
|
export const HOST_RESULT_MAX_CHARS = 100_000;
|
|
98
149
|
|
|
99
|
-
function resultOf(result: unknown): { result?: unknown } | 'too_large' {
|
|
150
|
+
function resultOf(result: unknown): { result?: unknown } | 'too_large' | { invalid: string } {
|
|
100
151
|
if (result === undefined || result === null) return {};
|
|
152
|
+
const problem = hostImageResultProblem(result);
|
|
153
|
+
if (problem !== null) return { invalid: problem };
|
|
101
154
|
let text: string | undefined;
|
|
102
155
|
try {
|
|
103
156
|
text = JSON.stringify(result);
|
|
@@ -158,7 +211,9 @@ export class HostOutcomeQueue {
|
|
|
158
211
|
outcome: 'failed',
|
|
159
212
|
detail: 'The result was too large to send.',
|
|
160
213
|
})
|
|
161
|
-
:
|
|
214
|
+
: 'invalid' in result
|
|
215
|
+
? record(action.toolCallId, { outcome: 'failed', detail: result.invalid })
|
|
216
|
+
: { ...record(action.toolCallId, outcome), ...result };
|
|
162
217
|
try {
|
|
163
218
|
await send(answer);
|
|
164
219
|
} catch (error) {
|
package/src/runtime/index.ts
CHANGED
|
@@ -33,9 +33,19 @@ export {
|
|
|
33
33
|
useAgentProfile,
|
|
34
34
|
} from './agents';
|
|
35
35
|
export type { AliaAgentFeature, AliaAgentProfile, AliaAgentStarter, FeatureOffer } from './agents';
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
HOST_IMAGE_MEDIA_TYPES,
|
|
38
|
+
HOST_IMAGE_TEXT_MAX_CHARS,
|
|
39
|
+
HOST_OUTCOMES,
|
|
40
|
+
HOST_RESULT_MAX_CHARS,
|
|
41
|
+
HostOutcomeQueue,
|
|
42
|
+
hostImageResultProblem,
|
|
43
|
+
hostRequest,
|
|
44
|
+
} from './host-tools';
|
|
37
45
|
export type {
|
|
38
46
|
AliaHostAction,
|
|
47
|
+
AliaHostImageMediaType,
|
|
48
|
+
AliaHostImageResult,
|
|
39
49
|
AliaHostOutcome,
|
|
40
50
|
AliaHostOutcomeKind,
|
|
41
51
|
AliaHostToolDefinition,
|