@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/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
+ }
@@ -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
- const value = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
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
- /** Synthesize `input` and return the private link the route answers with. */
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
- if (response.status === 504) {
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 === '') throw new Error('TTS failed');
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';