@alia.onl/sdk 8.0.0 → 10.0.1
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 +82 -1
- package/README.md +11 -3
- package/package.json +4 -4
- package/src/components/AliaChatScreen.tsx +44 -28
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- package/src/components/voice/VoiceControls.tsx +46 -10
- package/src/hooks/useAliaChat.ts +8 -9
- package/src/hooks/useSpeechToText.ts +48 -14
- package/src/hooks/useTTS.ts +4 -9
- package/src/hooks/useVoiceRoom.ts +51 -30
- package/src/index.ts +6 -5
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +3 -2
- package/src/lib/keyboard.tsx +19 -1
- package/src/lib/speech-messages.ts +58 -13
- package/src/lib/speech-recognition-types.ts +33 -0
- package/src/lib/speech-recognition.native.ts +55 -0
- package/src/lib/speech-recognition.ts +9 -0
- package/src/lib/speech-synthesis.ts +2 -2
- package/src/lib/voice-turn.ts +3 -4
- package/src/voice.ts +4 -0
- package/src/lib/config.ts +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 10.0.1
|
|
4
|
+
|
|
5
|
+
### `@oxy.so/services` 9 is admitted
|
|
6
|
+
|
|
7
|
+
The `@oxy.so/services` peer is now `^8.0.0 || ^9.0.0`. Services 9.0.0 changes
|
|
8
|
+
only its Bloom peer (to Bloom 5); the one thing this package takes from it,
|
|
9
|
+
`useOxy`, is the same in both, so an app can move to Bloom 5 without this
|
|
10
|
+
package refusing the services it needs. No code changed.
|
|
11
|
+
|
|
12
|
+
## 10.0.0
|
|
13
|
+
|
|
14
|
+
### Peers: `@oxy.so/core` `^3.0.0`, `@oxy.so/services` `^8.0.0`
|
|
15
|
+
|
|
16
|
+
**Breaking.** Core 3 is the namespaced Oxy client (`oxyServices.session`,
|
|
17
|
+
`oxyServices.users`, …): the SDK now reads the session token as
|
|
18
|
+
`oxyServices.session.accessToken` instead of `getAccessToken()` /
|
|
19
|
+
`httpService`, so it runs only on a host with core 3 and services 8 — one app
|
|
20
|
+
holds one Oxy SDK. Nothing in the SDK's own API changes.
|
|
21
|
+
|
|
22
|
+
### `AliaMarkdown` builds its parser once
|
|
23
|
+
|
|
24
|
+
`react-native-markdown-display` evaluates its `markdownit`,
|
|
25
|
+
`allowedImageHandlers` and `topLevelMaxExceededItem` defaults on every render,
|
|
26
|
+
so each render of each block built a Markdown parser and rebuilt its renderer
|
|
27
|
+
and styles. `AliaMarkdown` now passes one shared parser and those defaults'
|
|
28
|
+
own values, created once. Output is unchanged; a 1,000-message thread holds
|
|
29
|
+
7 MiB less heap in the app.
|
|
30
|
+
|
|
31
|
+
### Voice errors have codes
|
|
32
|
+
|
|
33
|
+
Additive. `useSpeechToText` returns `errorCode` beside `error`, and
|
|
34
|
+
`useVoiceRoom` returns `errorCode` and `turnErrorCode` beside `error` and
|
|
35
|
+
`turnError`: a `VoiceErrorCode` (exported from both entries with
|
|
36
|
+
`VOICE_ERROR_MESSAGES`, the English copy) so an app can say what went wrong in
|
|
37
|
+
its own language. The English strings are unchanged.
|
|
38
|
+
|
|
39
|
+
`VoiceControls` takes `labels` (defaults in `VOICE_CONTROLS_LABELS`) for every
|
|
40
|
+
word it shows, and its mute and end buttons are named buttons for screen
|
|
41
|
+
readers, mute as a toggle.
|
|
42
|
+
|
|
43
|
+
`useSpeechToText` unmounted mid-dictation now also clears `useSTTStore`. It used
|
|
44
|
+
to abort the recognizer but leave the store reading `isRecording: true`, which
|
|
45
|
+
`useAmbientWave` then drew as dictation for as long as the page lived.
|
|
46
|
+
|
|
47
|
+
## 9.0.0
|
|
48
|
+
|
|
49
|
+
### The full-screen chat clears the gesture bar and the keyboard
|
|
50
|
+
|
|
51
|
+
`AliaChatScreen` padded only the top safe-area inset, so on edge-to-edge
|
|
52
|
+
Android its composer sat under the gesture bar (OxyHQ/Mention#1140). It now pads
|
|
53
|
+
the bottom inset too, and wraps the chat in a keyboard-avoiding view that
|
|
54
|
+
measures its own position in the window (keyboard-controller's
|
|
55
|
+
`automaticOffset`): the composer rises onto the keyboard, and because that view
|
|
56
|
+
already ends above the inset, the inset is not added on top of the keyboard. The
|
|
57
|
+
avoiding view `PromptInput` carries measured its parent-relative layout and so
|
|
58
|
+
never lifted inside the screen. The web `KeyboardAvoidingView` accepts and
|
|
59
|
+
ignores `automaticOffset`. Also released for 7.x as 7.2.9.
|
|
60
|
+
|
|
61
|
+
### Real models, and no model identifier in the package
|
|
62
|
+
|
|
63
|
+
Alia has no models of its own: `GET /catalogue` now lists the real models Oxy
|
|
64
|
+
serves (`publisher/model`), with the server's `defaultModelId` and
|
|
65
|
+
`featuredIds`. The SDK follows it, and the product modes and routing profiles
|
|
66
|
+
(`mode:*`, `route:*`) are gone from it.
|
|
67
|
+
|
|
68
|
+
**Breaking:**
|
|
69
|
+
|
|
70
|
+
- `PREFERRED_CHAT_MODEL_ID` and `PREFERRED_VOICE_MODEL_ID` are removed. With no
|
|
71
|
+
`model`, `useAliaChat`, the voice turn sender, `useTTS` and `useVoiceRoom` send
|
|
72
|
+
no `model` and the server's default (chat or speech) answers.
|
|
73
|
+
- `parseCatalogue` and `fetchCatalogue` return a `Catalogue`
|
|
74
|
+
(`{ entries, defaultModelId, featuredIds }`), and `CatalogueEntry` is the new
|
|
75
|
+
model shape (`name`, `publisher`, `contextWindow`, `reasoningEfforts`,
|
|
76
|
+
`pricing`, …). `CatalogueEntryKind` is removed.
|
|
77
|
+
- `resolveSelection(requestedId, catalogue)` and `resolveModelId(apiUrl,
|
|
78
|
+
requestedId?)` take no preferred id; an identifier the catalogue no longer
|
|
79
|
+
lists resolves to `undefined` (omit `model`) instead of a substitute.
|
|
80
|
+
- The `alia.model_switch` stream event is no longer accepted; the server no
|
|
81
|
+
longer sends it.
|
|
82
|
+
|
|
83
|
+
**Added:** `AliaMarkdown` takes `renderCodeBlock` to draw fenced code with the
|
|
84
|
+
host's own code card (the Alia app passes Bloom's `CodeBlock`).
|
|
4
85
|
|
|
5
86
|
### The consumer-backend path is documented
|
|
6
87
|
|
package/README.md
CHANGED
|
@@ -67,6 +67,12 @@ export function Assistant() {
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
`AliaChatScreen` is a full-screen route: it pads itself by the top and bottom
|
|
71
|
+
safe-area insets, so render it where no tab bar or other bottom chrome is drawn.
|
|
72
|
+
On iOS and Android its composer rises with the keyboard through
|
|
73
|
+
`react-native-keyboard-controller`, which needs the app wrapped in its
|
|
74
|
+
`KeyboardProvider` (re-exported here).
|
|
75
|
+
|
|
70
76
|
Or the hook on its own, with your own UI:
|
|
71
77
|
|
|
72
78
|
```tsx
|
|
@@ -88,8 +94,10 @@ purpose: it is a **product** client.
|
|
|
88
94
|
|
|
89
95
|
The hook sends its turns to `POST {apiUrl}/v1/chat/completions`, with `apiUrl`
|
|
90
96
|
defaulting to `EXPO_PUBLIC_ALIA_API_URL` or `https://api.alia.onl`. It also
|
|
91
|
-
reads `GET {apiUrl}/catalogue` once per base URL
|
|
92
|
-
|
|
97
|
+
reads `GET {apiUrl}/catalogue` once per base URL when a `model` is passed, to
|
|
98
|
+
check that `publisher/model` against the real models the server offers. With no
|
|
99
|
+
`model`, the request carries none and the server's default model answers — the
|
|
100
|
+
SDK ships no model identifier of its own.
|
|
93
101
|
|
|
94
102
|
That is the compatibility surface, not the product route. `POST /alia/chat` is
|
|
95
103
|
the same handler with the same authentication, and it is where Alia's own
|
|
@@ -207,7 +215,7 @@ app.get('/catalogue', (req, res) => relay(req, res, 'GET', '/catalogue'));
|
|
|
207
215
|
app.listen(3000);
|
|
208
216
|
```
|
|
209
217
|
|
|
210
|
-
The request body the SDK sends is `{ model
|
|
218
|
+
The request body the SDK sends is `{ model?, messages, stream: true }` and the
|
|
211
219
|
response Alia writes is `text/event-stream`; the relay forwards both without
|
|
212
220
|
reading them. CORS between your web app and your backend is yours to configure,
|
|
213
221
|
as it would be for any of your own routes.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alia.onl/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Alia AI chat SDK
|
|
3
|
+
"version": "10.0.1",
|
|
4
|
+
"description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
7
7
|
"source": "src/index.ts",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": "^19.2.3",
|
|
44
44
|
"react-native": "^0.86.0",
|
|
45
|
-
"@oxy.so/core": "^
|
|
46
|
-
"@oxy.so/services": "^
|
|
45
|
+
"@oxy.so/core": "^3.0.0",
|
|
46
|
+
"@oxy.so/services": "^8.0.0 || ^9.0.0",
|
|
47
47
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
48
48
|
"expo-blur": "^57.0.0",
|
|
49
49
|
"react-native-reanimated": "^4.5.0",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import React from 'react';
|
|
21
21
|
import { View, Text, TouchableOpacity } from 'react-native';
|
|
22
22
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
23
|
+
import { KeyboardAvoidingView } from '../lib/keyboard';
|
|
23
24
|
import { AliaChatContent } from './AliaChatContent';
|
|
24
25
|
import { IdentityMark } from './IdentityMark';
|
|
25
26
|
import type { WelcomeSuggestion } from './AliaWelcomeMessage';
|
|
@@ -65,36 +66,51 @@ export function AliaChatScreen({
|
|
|
65
66
|
}: AliaChatScreenProps) {
|
|
66
67
|
const insets = useSafeAreaInsets();
|
|
67
68
|
|
|
69
|
+
// The screen owns the whole window, so it keeps its composer out of both
|
|
70
|
+
// system bars: the top inset clears the status bar, the bottom one the
|
|
71
|
+
// gesture bar or home indicator an edge-to-edge app draws over.
|
|
72
|
+
//
|
|
73
|
+
// The keyboard is handled INSIDE the bottom inset. The avoiding view measures
|
|
74
|
+
// its own frame in the window (`automaticOffset`), and that frame already
|
|
75
|
+
// ends `insets.bottom` above the window's edge, so the padding it adds is the
|
|
76
|
+
// keyboard's height minus the inset: the composer sits on the keyboard, not
|
|
77
|
+
// one inset above it. (Its parent-relative layout would read as zero overlap
|
|
78
|
+
// this deep in a host's tree, which is why the offset is measured.)
|
|
68
79
|
return (
|
|
69
|
-
<View
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
<View
|
|
81
|
+
className="flex-1 bg-background"
|
|
82
|
+
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
|
83
|
+
>
|
|
84
|
+
<KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
|
|
85
|
+
<AliaChatContent
|
|
86
|
+
clientContext={clientContext}
|
|
87
|
+
model={model}
|
|
88
|
+
apiUrl={apiUrl}
|
|
89
|
+
welcomeGreeting={welcomeGreeting}
|
|
90
|
+
welcomeSubtitle={welcomeSubtitle}
|
|
91
|
+
welcomeSuggestions={welcomeSuggestions}
|
|
92
|
+
primaryColor={primaryColor}
|
|
93
|
+
isDarkMode={isDarkMode}
|
|
94
|
+
voiceSession={voiceSession}
|
|
95
|
+
header={({ markState, hasMessages, clear }) => (
|
|
96
|
+
<View className="flex-row items-center justify-between px-4 py-3">
|
|
97
|
+
<View className="flex-row items-center gap-2.5">
|
|
98
|
+
{headerLeft}
|
|
99
|
+
<IdentityMark size={28} state={markState} spinOnPress />
|
|
100
|
+
<Text className="text-lg font-semibold text-foreground">Alia</Text>
|
|
101
|
+
</View>
|
|
102
|
+
<View className="flex-row items-center gap-2">
|
|
103
|
+
{hasMessages && (
|
|
104
|
+
<TouchableOpacity onPress={clear} className="px-2.5 py-1.5">
|
|
105
|
+
<Text className="text-sm text-muted-foreground">Clear</Text>
|
|
106
|
+
</TouchableOpacity>
|
|
107
|
+
)}
|
|
108
|
+
{headerRight}
|
|
109
|
+
</View>
|
|
86
110
|
</View>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<Text className="text-sm text-muted-foreground">Clear</Text>
|
|
91
|
-
</TouchableOpacity>
|
|
92
|
-
)}
|
|
93
|
-
{headerRight}
|
|
94
|
-
</View>
|
|
95
|
-
</View>
|
|
96
|
-
)}
|
|
97
|
-
/>
|
|
111
|
+
)}
|
|
112
|
+
/>
|
|
113
|
+
</KeyboardAvoidingView>
|
|
98
114
|
</View>
|
|
99
115
|
);
|
|
100
116
|
}
|
|
@@ -203,6 +203,9 @@ export function IdentityMark({
|
|
|
203
203
|
// button would promise an action it does not have.
|
|
204
204
|
accessibilityRole={onPress === undefined ? 'image' : 'button'}
|
|
205
205
|
accessibilityLabel={accessibilityLabel}
|
|
206
|
+
// Nor a Tab stop: on web a Pressable is focusable by default, so the
|
|
207
|
+
// flourish was an unlabelled stop in the keyboard order that did nothing.
|
|
208
|
+
focusable={onPress !== undefined}
|
|
206
209
|
className={className}
|
|
207
210
|
style={box}
|
|
208
211
|
>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useMemo, type ReactNode } from 'react';
|
|
2
2
|
import { View, Text, Platform, Linking, useColorScheme } from 'react-native';
|
|
3
|
-
import Markdown, { type ASTNode } from 'react-native-markdown-display';
|
|
3
|
+
import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
|
|
4
4
|
import type { AliaColors } from '../theme';
|
|
5
5
|
|
|
6
6
|
// Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
|
|
@@ -24,10 +24,32 @@ const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
|
|
|
24
24
|
const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
|
|
25
25
|
const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
|
|
26
26
|
|
|
27
|
-
function createRules(colors: AliaColors) {
|
|
27
|
+
function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
|
|
28
28
|
const { text: textColor, muted, border, primary, mutedForeground } = colors;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Fenced and indented code, drawn by the host when it brings a code card of
|
|
32
|
+
* its own (the app passes Bloom's `CodeBlock`). This package has no Bloom
|
|
33
|
+
* dependency, so it cannot name one itself; without the prop the library's
|
|
34
|
+
* default `fence` / `code_block` rendering, styled below, stays.
|
|
35
|
+
*/
|
|
36
|
+
const codeRules = renderCodeBlock === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: {
|
|
39
|
+
fence: (node: ASTNode) => (
|
|
40
|
+
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
41
|
+
{renderCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
|
|
42
|
+
</View>
|
|
43
|
+
),
|
|
44
|
+
code_block: (node: ASTNode) => (
|
|
45
|
+
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
46
|
+
{renderCodeBlock(node.content.replace(/\n$/, ''), undefined)}
|
|
47
|
+
</View>
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
|
|
30
51
|
return {
|
|
52
|
+
...codeRules,
|
|
31
53
|
heading1: (node: ASTNode, children: ReactNode[]) => (
|
|
32
54
|
<Text
|
|
33
55
|
key={node.key}
|
|
@@ -249,6 +271,16 @@ function createStyles(colors: AliaColors, fontFamily: string | undefined) {
|
|
|
249
271
|
};
|
|
250
272
|
}
|
|
251
273
|
|
|
274
|
+
/** Draws one block of code: its source, and the fence's language when it named one. */
|
|
275
|
+
export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
|
|
276
|
+
|
|
277
|
+
/** The fence's info string up to the first space (```ts title=x → `ts`). */
|
|
278
|
+
function codeLanguage(node: ASTNode): string | undefined {
|
|
279
|
+
const info = (node as ASTNode & { sourceInfo?: string }).sourceInfo?.trim() ?? '';
|
|
280
|
+
const language = info.split(/\s+/)[0];
|
|
281
|
+
return language === '' ? undefined : language;
|
|
282
|
+
}
|
|
283
|
+
|
|
252
284
|
export interface AliaMarkdownProps {
|
|
253
285
|
/**
|
|
254
286
|
* The sans family for body text, as a SINGLE family name — RN's `fontFamily`
|
|
@@ -263,6 +295,12 @@ export interface AliaMarkdownProps {
|
|
|
263
295
|
fontFamily?: string;
|
|
264
296
|
content: string;
|
|
265
297
|
colors?: Partial<AliaColors>;
|
|
298
|
+
/**
|
|
299
|
+
* Draw fenced code with the host's own code card. Keep the function's
|
|
300
|
+
* identity stable (a module-level function or a `useCallback`): it is part of
|
|
301
|
+
* the rules' memo, and a new one per render re-parses every block.
|
|
302
|
+
*/
|
|
303
|
+
renderCodeBlock?: RenderCodeBlock;
|
|
266
304
|
}
|
|
267
305
|
|
|
268
306
|
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
@@ -327,21 +365,64 @@ type MarkdownBlockProps = {
|
|
|
327
365
|
styles: ReturnType<typeof createStyles>;
|
|
328
366
|
};
|
|
329
367
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
368
|
+
/**
|
|
369
|
+
* `react-native-markdown-display` gives three of its props DEFAULT VALUES that
|
|
370
|
+
* are new objects on every render: `markdownit = MarkdownIt({ typographer:
|
|
371
|
+
* true })`, `allowedImageHandlers = [...]` and `topLevelMaxExceededItem =
|
|
372
|
+
* <Text>`. A default parameter is evaluated per call, so each render of each
|
|
373
|
+
* `<Markdown>` built a parser (compiling linkify-it's regexes), and the two
|
|
374
|
+
* others sit in the deps of its renderer's `useMemo`, so the renderer — and
|
|
375
|
+
* the `StyleSheet.create` of every style inside it — was rebuilt every render
|
|
376
|
+
* too. A streamed answer re-renders its last block per token, and opening a
|
|
377
|
+
* thread renders every block once: both paid for it. Passing the defaults'
|
|
378
|
+
* own values, created once, makes both memos hold. The parser is shared:
|
|
379
|
+
* `parse` keeps no state between calls.
|
|
380
|
+
*
|
|
381
|
+
* The typings omit the last two props, which the component does take.
|
|
382
|
+
*/
|
|
383
|
+
const PARSER = MarkdownIt({ typographer: true });
|
|
384
|
+
const IMAGE_HANDLERS = [
|
|
385
|
+
'data:image/png;base64',
|
|
386
|
+
'data:image/gif;base64',
|
|
387
|
+
'data:image/jpeg;base64',
|
|
388
|
+
'https://',
|
|
389
|
+
'http://',
|
|
390
|
+
];
|
|
391
|
+
const TOP_LEVEL_EXCEEDED = <Text key="dotdotdot">...</Text>;
|
|
392
|
+
const StableMarkdown = Markdown as React.ComponentType<
|
|
393
|
+
React.ComponentProps<typeof Markdown> & {
|
|
394
|
+
allowedImageHandlers?: string[];
|
|
395
|
+
topLevelMaxExceededItem?: ReactNode;
|
|
396
|
+
}
|
|
397
|
+
>;
|
|
398
|
+
|
|
399
|
+
function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
|
|
400
|
+
return (
|
|
401
|
+
<StableMarkdown
|
|
402
|
+
rules={rules}
|
|
403
|
+
style={styles}
|
|
404
|
+
markdownit={PARSER}
|
|
405
|
+
allowedImageHandlers={IMAGE_HANDLERS}
|
|
406
|
+
topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
|
|
407
|
+
>
|
|
408
|
+
{content}
|
|
409
|
+
</StableMarkdown>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const MarkdownBlock = React.memo(ParsedMarkdown);
|
|
333
414
|
|
|
334
|
-
export function AliaMarkdown({ content, colors: colorOverrides, fontFamily }: AliaMarkdownProps) {
|
|
415
|
+
export function AliaMarkdown({ content, colors: colorOverrides, fontFamily, renderCodeBlock }: AliaMarkdownProps) {
|
|
335
416
|
const scheme = useColorScheme();
|
|
336
417
|
const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
|
|
337
418
|
const colors = { ...fallback, ...colorOverrides } as AliaColors;
|
|
338
419
|
|
|
339
|
-
const customRules = useMemo(() => createRules(colors), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground]);
|
|
420
|
+
const customRules = useMemo(() => createRules(colors, renderCodeBlock), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, renderCodeBlock]);
|
|
340
421
|
const markdownStyles = useMemo(() => createStyles(colors, fontFamily), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily]);
|
|
341
422
|
const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
|
|
342
423
|
|
|
343
424
|
if (blocks.length === 1) {
|
|
344
|
-
return <
|
|
425
|
+
return <ParsedMarkdown content={blocks[0]} rules={customRules} styles={markdownStyles} />;
|
|
345
426
|
}
|
|
346
427
|
|
|
347
428
|
// Each block's `body` rule cancels its own trailing paragraph margin
|
|
@@ -9,7 +9,32 @@ import MicOff from 'lucide-react-native/icons/mic-off';
|
|
|
9
9
|
import PhoneOff from 'lucide-react-native/icons/phone-off';
|
|
10
10
|
import type { RoomState, AgentState } from '../../types';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Every word the bar shows or announces. An app in another language passes its own. */
|
|
13
|
+
export interface VoiceControlsLabels {
|
|
14
|
+
connecting: string;
|
|
15
|
+
connected: string;
|
|
16
|
+
listening: string;
|
|
17
|
+
muted: string;
|
|
18
|
+
thinking: string;
|
|
19
|
+
speaking: string;
|
|
20
|
+
mute: string;
|
|
21
|
+
unmute: string;
|
|
22
|
+
end: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
|
|
26
|
+
connecting: 'Connecting...',
|
|
27
|
+
connected: 'Connected',
|
|
28
|
+
listening: 'Listening...',
|
|
29
|
+
muted: 'Muted',
|
|
30
|
+
thinking: 'Thinking...',
|
|
31
|
+
speaking: 'Speaking...',
|
|
32
|
+
mute: 'Mute',
|
|
33
|
+
unmute: 'Unmute',
|
|
34
|
+
end: 'End',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface VoiceControlsProps {
|
|
13
38
|
roomState: RoomState;
|
|
14
39
|
agentState: AgentState;
|
|
15
40
|
isMuted: boolean;
|
|
@@ -17,19 +42,22 @@ interface VoiceControlsProps {
|
|
|
17
42
|
onEnd: () => void;
|
|
18
43
|
/** Override theme primary color (resolved hex/hsl value, not CSS var) */
|
|
19
44
|
primaryColor?: string;
|
|
45
|
+
/** Overrides for any of {@link VOICE_CONTROLS_LABELS}. */
|
|
46
|
+
labels?: Partial<VoiceControlsLabels>;
|
|
20
47
|
}
|
|
21
48
|
|
|
22
49
|
function getStatusText(
|
|
23
50
|
roomState: RoomState,
|
|
24
51
|
agentState: AgentState,
|
|
25
52
|
isMuted: boolean,
|
|
53
|
+
labels: VoiceControlsLabels,
|
|
26
54
|
): string {
|
|
27
|
-
if (roomState === 'connecting') return
|
|
55
|
+
if (roomState === 'connecting') return labels.connecting;
|
|
28
56
|
if (roomState === 'connected') {
|
|
29
|
-
if (agentState === 'listening') return isMuted ?
|
|
30
|
-
if (agentState === 'thinking') return
|
|
31
|
-
if (agentState === 'speaking') return
|
|
32
|
-
return
|
|
57
|
+
if (agentState === 'listening') return isMuted ? labels.muted : labels.listening;
|
|
58
|
+
if (agentState === 'thinking') return labels.thinking;
|
|
59
|
+
if (agentState === 'speaking') return labels.speaking;
|
|
60
|
+
return labels.connected;
|
|
33
61
|
}
|
|
34
62
|
return '';
|
|
35
63
|
}
|
|
@@ -41,13 +69,15 @@ export function VoiceControls({
|
|
|
41
69
|
onToggleMute,
|
|
42
70
|
onEnd,
|
|
43
71
|
primaryColor,
|
|
72
|
+
labels: labelOverrides,
|
|
44
73
|
}: VoiceControlsProps) {
|
|
45
|
-
const
|
|
74
|
+
const labels = { ...VOICE_CONTROLS_LABELS, ...labelOverrides };
|
|
75
|
+
const statusText = getStatusText(roomState, agentState, isMuted, labels);
|
|
46
76
|
|
|
47
77
|
return (
|
|
48
78
|
<View style={styles.container}>
|
|
49
79
|
{statusText ? (
|
|
50
|
-
<Text className="text-lg font-medium mb-4 text-foreground">
|
|
80
|
+
<Text className="text-lg font-medium mb-4 text-foreground" accessibilityLiveRegion="polite" aria-live="polite">
|
|
51
81
|
{statusText}
|
|
52
82
|
</Text>
|
|
53
83
|
) : null}
|
|
@@ -57,6 +87,10 @@ export function VoiceControls({
|
|
|
57
87
|
<View style={styles.buttonWrapper}>
|
|
58
88
|
<Pressable
|
|
59
89
|
onPress={onToggleMute}
|
|
90
|
+
accessibilityRole="button"
|
|
91
|
+
accessibilityLabel={labels.mute}
|
|
92
|
+
accessibilityState={{ selected: isMuted }}
|
|
93
|
+
aria-pressed={isMuted}
|
|
60
94
|
className={isMuted ? undefined : 'bg-muted'}
|
|
61
95
|
style={[styles.button, isMuted ? { backgroundColor: '#ef4444' } : undefined]}
|
|
62
96
|
>
|
|
@@ -67,18 +101,20 @@ export function VoiceControls({
|
|
|
67
101
|
)}
|
|
68
102
|
</Pressable>
|
|
69
103
|
<Text className="text-xs text-muted-foreground">
|
|
70
|
-
{isMuted ?
|
|
104
|
+
{isMuted ? labels.unmute : labels.mute}
|
|
71
105
|
</Text>
|
|
72
106
|
</View>
|
|
73
107
|
|
|
74
108
|
<View style={styles.buttonWrapper}>
|
|
75
109
|
<Pressable
|
|
76
110
|
onPress={onEnd}
|
|
111
|
+
accessibilityRole="button"
|
|
112
|
+
accessibilityLabel={labels.end}
|
|
77
113
|
style={[styles.button, { backgroundColor: '#ef4444' }]}
|
|
78
114
|
>
|
|
79
115
|
<PhoneOff size={24} color="white" />
|
|
80
116
|
</Pressable>
|
|
81
|
-
<Text className="text-xs text-muted-foreground">
|
|
117
|
+
<Text className="text-xs text-muted-foreground">{labels.end}</Text>
|
|
82
118
|
</View>
|
|
83
119
|
</View>
|
|
84
120
|
)}
|
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { useOxy } from '@oxy.so/services';
|
|
|
3
3
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
4
4
|
import { getTextFromContent } from '../lib/content-utils';
|
|
5
5
|
import { resolveModelId } from '../lib/catalogue';
|
|
6
|
-
import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
|
|
7
6
|
import { streamAliaChat } from '../lib/chat-transport';
|
|
8
7
|
import type { AliaChatStreamEvent } from '../lib/chat-stream';
|
|
9
8
|
|
|
@@ -28,12 +27,12 @@ export interface UseAliaChatOptions {
|
|
|
28
27
|
*/
|
|
29
28
|
apiUrl?: string;
|
|
30
29
|
/**
|
|
31
|
-
*
|
|
30
|
+
* The model to answer with, as `publisher/model` from `GET /catalogue`.
|
|
32
31
|
*
|
|
33
|
-
* Optional, and checked against
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* Optional, and checked against the catalogue before a request carries it: an
|
|
33
|
+
* identifier the server no longer offers is not sent, so a retirement does not
|
|
34
|
+
* turn into a 400 inside a consumer's app. Omitted, the request carries no
|
|
35
|
+
* `model` and the server's default model answers.
|
|
37
36
|
*/
|
|
38
37
|
model?: string;
|
|
39
38
|
/** App context injected as system message so Alia knows which app the user is in. */
|
|
@@ -115,7 +114,7 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
|
|
|
115
114
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
116
115
|
const {
|
|
117
116
|
apiUrl = API_URL,
|
|
118
|
-
model
|
|
117
|
+
model,
|
|
119
118
|
clientContext,
|
|
120
119
|
accessToken: accessTokenProp,
|
|
121
120
|
} = options;
|
|
@@ -236,7 +235,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
236
235
|
const trimmed = text.trim();
|
|
237
236
|
if (!trimmed) return;
|
|
238
237
|
|
|
239
|
-
const activeToken = oxyServices.
|
|
238
|
+
const activeToken = oxyServices.session.accessToken;
|
|
240
239
|
if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
|
|
241
240
|
setError('The supplied token is not the active Oxy session.');
|
|
242
241
|
return;
|
|
@@ -377,7 +376,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
377
376
|
|
|
378
377
|
try {
|
|
379
378
|
const effectiveModel = await waitWithAbort(
|
|
380
|
-
resolveModelId(apiUrl, model
|
|
379
|
+
resolveModelId(apiUrl, model),
|
|
381
380
|
controller.signal,
|
|
382
381
|
);
|
|
383
382
|
await streamAliaChat(
|