@alia.onl/sdk 20.0.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +12 -2
- package/package.json +2 -1
- package/src/ask.ts +7 -0
- package/src/components/AliaAskProvider.tsx +71 -30
- package/src/components/AliaAskSheet.tsx +52 -0
- package/src/components/AliaChatContent.tsx +8 -2
- package/src/components/AliaChatSheet.tsx +33 -4
- package/src/components/AliaComposer.tsx +4 -8
- package/src/components/AliaRecentConversations.tsx +58 -0
- package/src/hooks/useChatComposer.ts +4 -3
- package/src/hooks/useComposerSuggestions.ts +2 -2
- package/src/index.ts +1 -0
- package/src/lib/keyboard.tsx +8 -5
- package/src/lib/labels.tsx +3 -0
- package/src/lib/text-input.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 20.2.0
|
|
4
|
+
|
|
5
|
+
An app mounts its one sheet at the root without the chat in its entry bundle.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`@alia.onl/sdk/ask`**, a leaf entry with `AliaAskProvider` and `useAskAgent` alone. The root
|
|
10
|
+
entry still exports both, but brings the whole chat with them.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`AliaAskProvider` loads its sheet lazily** (`components/AliaAskSheet.tsx`, one `import()`) and
|
|
15
|
+
mounts it on the first `ask`. Signed in, the module is fetched in the background, so the first
|
|
16
|
+
`ask` does not wait on the network. `check:entries` holds that `./ask` loads none of the chat
|
|
17
|
+
with the entry.
|
|
18
|
+
- **Ref and event types are React Native 0.88-ready.** A ref to a `ScrollView` or `TextInput` is
|
|
19
|
+
typed `ComponentRef<typeof …>` (0.88 types both as function components), and key presses are
|
|
20
|
+
`TextInputKeyPressEvent` (`TextInputKeyPressEventData` is deprecated since 0.86). Nothing
|
|
21
|
+
changes on 0.86. The peer ranges stay on Expo 57 / React Native 0.86: Bloom's own `TextInput`
|
|
22
|
+
ref props are not 0.88-ready yet.
|
|
23
|
+
|
|
24
|
+
## 20.1.0
|
|
25
|
+
|
|
26
|
+
The one sheet is the whole surface an app needs for an agent: it picks a conversation up again and
|
|
27
|
+
the app can close it.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`useAskAgent().resume(agentKey, conversationId)`** opens the sheet of that agent on a stored
|
|
32
|
+
conversation, where the person left it (`AliaChatSheetRef.resume`).
|
|
33
|
+
- **`useAskAgent().dismiss()`**, and `AliaAskProvider` forwards a ref to the same `AliaAsk`, for
|
|
34
|
+
the code that configures the sheet and so sits above it — a host-tool executor that changed the
|
|
35
|
+
page under the sheet closes it once the turn is written.
|
|
36
|
+
- **The sheet's recent conversations.** Under the greeting, the person's three latest conversations
|
|
37
|
+
with the lead (`AliaRecentConversations`; Alia's sheet lists any). `recentConversations={false}`
|
|
38
|
+
turns it off. New label `recentConversations` (`'Recent'`).
|
|
39
|
+
- **`AliaChatContent` `welcomeAccessory`**, drawn under the empty chat's greeting.
|
|
40
|
+
|
|
3
41
|
## 20.0.0
|
|
4
42
|
|
|
5
43
|
Generic chat UI is Bloom's; the SDK composes it and keeps only what is Alia's. Two copies the SDK
|
package/README.md
CHANGED
|
@@ -146,7 +146,7 @@ Every agent — Alia included — is the same agent (ADR 0020), and any Oxy app
|
|
|
146
146
|
person can reach. The wire contract is [`docs/agent-embedding.md`](../../docs/agent-embedding.md).
|
|
147
147
|
|
|
148
148
|
```tsx
|
|
149
|
-
import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk';
|
|
149
|
+
import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk/ask';
|
|
150
150
|
|
|
151
151
|
<AliaAskProvider
|
|
152
152
|
agents={{ alia: {}, sindi: { id: SINDI_AGENT_ID } }} // no id = Alia
|
|
@@ -163,10 +163,20 @@ import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk';
|
|
|
163
163
|
{children}
|
|
164
164
|
</AliaAskProvider>;
|
|
165
165
|
|
|
166
|
-
const { ask } = useAskAgent();
|
|
166
|
+
const { ask, resume, dismiss } = useAskAgent();
|
|
167
167
|
ask('sindi', { text: 'Is this flat a good price?' }); // signed out: Oxy's sign-in instead
|
|
168
|
+
resume('sindi', conversationId); // a stored conversation, where the person left it
|
|
169
|
+
dismiss(); // e.g. after a host tool changed the page under the sheet
|
|
168
170
|
```
|
|
169
171
|
|
|
172
|
+
Mount it from `@alia.onl/sdk/ask`: that entry is the provider and its hook alone, and the sheet
|
|
173
|
+
loads with the first `ask` (fetched in the background once the person is signed in), so the chat
|
|
174
|
+
stays out of the app's entry bundle. The root entry exports the same two, with the whole chat.
|
|
175
|
+
|
|
176
|
+
The sheet offers the person's latest conversations with its lead under the greeting
|
|
177
|
+
(`recentConversations={false}` turns it off). The code that configures the sheet sits above it, so
|
|
178
|
+
`AliaAskProvider` forwards a ref to the same `AliaAsk` for it.
|
|
179
|
+
|
|
170
180
|
- **The lead.** `agent={{ id }}` (on `AliaChatSheet`, `AliaChatScreen` and `AliaChatContent`) makes
|
|
171
181
|
that agent the conversation's lead: its greeting, name, character and starters
|
|
172
182
|
(`GET /v1/agents/:id/profile`) and the header's first avatar. Omitted, Alia.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alia.onl/sdk",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.2.0",
|
|
4
4
|
"description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"types": "src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.ts",
|
|
11
|
+
"./ask": "./src/ask.ts",
|
|
11
12
|
"./avatar": "./src/avatar.ts",
|
|
12
13
|
"./content": "./src/lib/content-utils.ts",
|
|
13
14
|
"./csp": "./src/csp.ts",
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @alia.onl/sdk/ask — the app's one Alia sheet, without the chat in the entry bundle
|
|
2
|
+
// `AliaAskProvider` and `useAskAgent` alone: the sheet they open is loaded with the first `ask`.
|
|
3
|
+
// An app mounts the provider at its root from here rather than from the root entry, which
|
|
4
|
+
// would bring the whole chat along with it.
|
|
5
|
+
|
|
6
|
+
export { AliaAskProvider, useAskAgent } from './components/AliaAskProvider';
|
|
7
|
+
export type { AliaAsk, AliaAskAgents, AliaAskProviderProps } from './components/AliaAskProvider';
|
|
@@ -5,21 +5,34 @@
|
|
|
5
5
|
* once — its host tools, renderers, words, voice, context, upsell and share. A button calls
|
|
6
6
|
* `useAskAgent().ask(key, turn)`. Signed out, the sheet is not mounted (it could not call Alia):
|
|
7
7
|
* `ask` opens Oxy's sign-in instead, and the person asks again once signed in.
|
|
8
|
+
*
|
|
9
|
+
* The sheet itself (`AliaAskSheet`) is loaded lazily and mounted on the first `ask`, so an app
|
|
10
|
+
* that mounts this provider at its root — imported from `@alia.onl/sdk/ask` — keeps the chat out
|
|
11
|
+
* of its entry bundle. Signed in, its module is fetched in the background so the first `ask`
|
|
12
|
+
* does not wait on the network.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import { useAuth } from '@oxy.so/services/ui/client';
|
|
11
16
|
import {
|
|
12
17
|
createContext,
|
|
18
|
+
forwardRef,
|
|
19
|
+
lazy,
|
|
13
20
|
type ReactNode,
|
|
21
|
+
Suspense,
|
|
14
22
|
useCallback,
|
|
15
23
|
useContext,
|
|
16
24
|
useEffect,
|
|
25
|
+
useImperativeHandle,
|
|
17
26
|
useMemo,
|
|
18
27
|
useRef,
|
|
19
28
|
useState,
|
|
20
29
|
} from 'react';
|
|
21
30
|
import type { AliaTurn } from '../hooks/useChatComposer';
|
|
22
|
-
import {
|
|
31
|
+
import type { AliaChatSheetProps, AliaChatSheetRef } from './AliaChatSheet';
|
|
32
|
+
import type { AliaAskOpening } from './AliaAskSheet';
|
|
33
|
+
|
|
34
|
+
const loadSheet = () => import('./AliaAskSheet');
|
|
35
|
+
const LazyAskSheet = lazy(() => loadSheet().then((module) => ({ default: module.AliaAskSheet })));
|
|
23
36
|
|
|
24
37
|
/** The agents an app offers, by its own key. An entry without `id` is Alia. */
|
|
25
38
|
export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
|
|
@@ -38,6 +51,7 @@ export interface AliaAskProviderProps
|
|
|
38
51
|
| 'apiUrl'
|
|
39
52
|
| 'characterRuntimeUrl'
|
|
40
53
|
| 'headerMenu'
|
|
54
|
+
| 'recentConversations'
|
|
41
55
|
> {
|
|
42
56
|
/** The agents `ask` opens, by key. Omitted, the sheet only ever opens on Alia. */
|
|
43
57
|
agents?: AliaAskAgents;
|
|
@@ -52,6 +66,13 @@ export interface AliaAsk {
|
|
|
52
66
|
* ("ask Sindi about this listing"). Signed out, opens Oxy's sign-in instead.
|
|
53
67
|
*/
|
|
54
68
|
ask: (agentKey?: string, turn?: AliaTurn) => void;
|
|
69
|
+
/** Open the sheet on the agent `agentKey` (omitted, Alia), on its stored conversation `conversationId`. */
|
|
70
|
+
resume: (agentKey: string | undefined, conversationId: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Close the sheet — a host tool that changed the page under it shows the person the result, once
|
|
73
|
+
* the turn has been written.
|
|
74
|
+
*/
|
|
75
|
+
dismiss: () => void;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
const AskContext = createContext<AliaAsk | null>(null);
|
|
@@ -63,29 +84,30 @@ export function useAskAgent(): AliaAsk {
|
|
|
63
84
|
return ask;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
/**
|
|
67
|
-
interface Opening {
|
|
87
|
+
/** The opening that waits for the sheet of agent `key` to be the one mounted. */
|
|
88
|
+
interface Opening extends AliaAskOpening {
|
|
68
89
|
key: string | undefined;
|
|
69
|
-
turn: AliaTurn | undefined;
|
|
70
|
-
/** Changes on every `ask`, so asking the same agent twice opens twice. */
|
|
71
|
-
serial: number;
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Mounts the app's one sheet. Its ref is the same `AliaAsk` as `useAskAgent()`, for the code that
|
|
94
|
+
* configures the sheet and so sits above it — a host-tool executor that closes it.
|
|
95
|
+
*/
|
|
96
|
+
export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(function AliaAskProvider(
|
|
97
|
+
{ agents, welcomeSuggestions, children, ...sheet },
|
|
98
|
+
ref,
|
|
99
|
+
) {
|
|
80
100
|
const { canUsePrivateApi, signIn } = useAuth();
|
|
81
101
|
const sheetRef = useRef<AliaChatSheetRef>(null);
|
|
82
102
|
const [opening, setOpening] = useState<Opening | null>(null);
|
|
83
103
|
const [current, setCurrent] = useState<string | undefined>(undefined);
|
|
104
|
+
/** Nothing is mounted before the first `ask`. */
|
|
105
|
+
const [asked, setAsked] = useState(false);
|
|
84
106
|
const agentsRef = useRef(agents);
|
|
85
107
|
agentsRef.current = agents;
|
|
86
108
|
|
|
87
|
-
const
|
|
88
|
-
(agentKey
|
|
109
|
+
const open = useCallback(
|
|
110
|
+
(agentKey: string | undefined, turn?: AliaTurn, conversationId?: string) => {
|
|
89
111
|
if (agentKey !== undefined && agentsRef.current?.[agentKey] === undefined) {
|
|
90
112
|
throw new Error(`AliaAskProvider has no agent "${agentKey}"`);
|
|
91
113
|
}
|
|
@@ -93,35 +115,54 @@ export function AliaAskProvider({
|
|
|
93
115
|
void signIn().catch(() => undefined);
|
|
94
116
|
return;
|
|
95
117
|
}
|
|
118
|
+
setAsked(true);
|
|
96
119
|
setCurrent(agentKey);
|
|
97
|
-
setOpening((previous) => ({
|
|
120
|
+
setOpening((previous) => ({
|
|
121
|
+
key: agentKey,
|
|
122
|
+
turn,
|
|
123
|
+
...(conversationId === undefined ? {} : { conversationId }),
|
|
124
|
+
serial: (previous?.serial ?? 0) + 1,
|
|
125
|
+
}));
|
|
98
126
|
},
|
|
99
127
|
[canUsePrivateApi, signIn],
|
|
100
128
|
);
|
|
129
|
+
const ask = useCallback((agentKey?: string, turn?: AliaTurn) => open(agentKey, turn), [open]);
|
|
130
|
+
const resume = useCallback(
|
|
131
|
+
(agentKey: string | undefined, conversationId: string) =>
|
|
132
|
+
open(agentKey, undefined, conversationId),
|
|
133
|
+
[open],
|
|
134
|
+
);
|
|
135
|
+
const dismiss = useCallback(() => sheetRef.current?.dismiss(), []);
|
|
101
136
|
|
|
102
|
-
|
|
137
|
+
const opened = useCallback(() => setOpening(null), []);
|
|
138
|
+
|
|
139
|
+
// Signed in, the sheet's module comes down in the background, ahead of the first `ask`.
|
|
103
140
|
useEffect(() => {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
setOpening(null);
|
|
107
|
-
}, [opening, current]);
|
|
141
|
+
if (canUsePrivateApi) void loadSheet().catch(() => undefined);
|
|
142
|
+
}, [canUsePrivateApi]);
|
|
108
143
|
|
|
109
144
|
const agentId = current === undefined ? undefined : agents?.[current]?.id;
|
|
110
145
|
const agent = useMemo(() => (agentId === undefined ? undefined : { id: agentId }), [agentId]);
|
|
111
|
-
const value = useMemo<AliaAsk>(() => ({ ask }), [ask]);
|
|
146
|
+
const value = useMemo<AliaAsk>(() => ({ ask, resume, dismiss }), [ask, resume, dismiss]);
|
|
147
|
+
useImperativeHandle(ref, () => value, [value]);
|
|
112
148
|
|
|
113
149
|
return (
|
|
114
150
|
<AskContext.Provider value={value}>
|
|
115
151
|
{children}
|
|
116
|
-
{canUsePrivateApi ? (
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
{canUsePrivateApi && asked ? (
|
|
153
|
+
<Suspense fallback={null}>
|
|
154
|
+
<LazyAskSheet
|
|
155
|
+
key={agentId ?? 'alia'}
|
|
156
|
+
ref={sheetRef}
|
|
157
|
+
{...sheet}
|
|
158
|
+
agent={agent}
|
|
159
|
+
welcomeSuggestions={agent === undefined ? welcomeSuggestions : undefined}
|
|
160
|
+
// A different agent is a new sheet: the opening waits for that one to mount.
|
|
161
|
+
opening={opening !== null && opening.key === current ? opening : null}
|
|
162
|
+
onOpened={opened}
|
|
163
|
+
/>
|
|
164
|
+
</Suspense>
|
|
124
165
|
) : null}
|
|
125
166
|
</AskContext.Provider>
|
|
126
167
|
);
|
|
127
|
-
}
|
|
168
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sheet `AliaAskProvider` mounts, in its own module so the provider can load it lazily: an app
|
|
3
|
+
* that mounts the provider at its root carries only the provider in its entry bundle, and the
|
|
4
|
+
* chat — thread, composer, voice, renderers — arrives with the first `ask`.
|
|
5
|
+
*
|
|
6
|
+
* It presents the opening it is handed once it is mounted, which is the only moment a lazily
|
|
7
|
+
* loaded sheet can be presented, and tells the provider the opening is spent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
11
|
+
import type { AliaTurn } from '../hooks/useChatComposer';
|
|
12
|
+
import { AliaChatSheet, type AliaChatSheetProps, type AliaChatSheetRef } from './AliaChatSheet';
|
|
13
|
+
|
|
14
|
+
/** What the person asked for: a turn to send, or a stored conversation to pick up again. */
|
|
15
|
+
export interface AliaAskOpening {
|
|
16
|
+
turn: AliaTurn | undefined;
|
|
17
|
+
conversationId?: string;
|
|
18
|
+
/** Changes on every `ask`, so asking the same agent twice opens twice. */
|
|
19
|
+
serial: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AliaAskSheetProps extends AliaChatSheetProps {
|
|
23
|
+
/** The opening to present once mounted; `null` when there is none waiting. */
|
|
24
|
+
opening: AliaAskOpening | null;
|
|
25
|
+
/** The opening was presented. */
|
|
26
|
+
onOpened: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const AliaAskSheet = forwardRef<AliaChatSheetRef, AliaAskSheetProps>(function AliaAskSheet(
|
|
30
|
+
{ opening, onOpened, ...sheet },
|
|
31
|
+
ref,
|
|
32
|
+
) {
|
|
33
|
+
const sheetRef = useRef<AliaChatSheetRef>(null);
|
|
34
|
+
useImperativeHandle(
|
|
35
|
+
ref,
|
|
36
|
+
() => ({
|
|
37
|
+
present: (turn?: AliaTurn) => sheetRef.current?.present(turn),
|
|
38
|
+
resume: (conversationId: string) => sheetRef.current?.resume(conversationId),
|
|
39
|
+
dismiss: () => sheetRef.current?.dismiss(),
|
|
40
|
+
}),
|
|
41
|
+
[],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (opening === null) return;
|
|
46
|
+
if (opening.conversationId !== undefined) sheetRef.current?.resume(opening.conversationId);
|
|
47
|
+
else sheetRef.current?.present(opening.turn);
|
|
48
|
+
onOpened();
|
|
49
|
+
}, [opening, onOpened]);
|
|
50
|
+
|
|
51
|
+
return <AliaChatSheet ref={sheetRef} {...sheet} />;
|
|
52
|
+
});
|
|
@@ -170,6 +170,8 @@ export interface AliaChatContentProps {
|
|
|
170
170
|
welcomeSubtitle?: string;
|
|
171
171
|
/** The lines offered over the composer on an empty chat; omitted, the agent's starters. */
|
|
172
172
|
welcomeSuggestions?: AliaSuggestion[];
|
|
173
|
+
/** Drawn under the empty chat's greeting — the sheet's recent conversations with its lead. */
|
|
174
|
+
welcomeAccessory?: ReactNode;
|
|
173
175
|
/** What the host found for the typed text (see `onSuggestionQuery`). */
|
|
174
176
|
suggestionMatches?: AliaSuggestion[];
|
|
175
177
|
/** The typed text, once typing has settled, for the host to search suggestions with. */
|
|
@@ -279,6 +281,7 @@ function AliaConversationView({
|
|
|
279
281
|
welcomeGreeting,
|
|
280
282
|
welcomeSubtitle,
|
|
281
283
|
welcomeSuggestions,
|
|
284
|
+
welcomeAccessory,
|
|
282
285
|
suggestionMatches,
|
|
283
286
|
onSuggestionQuery,
|
|
284
287
|
onSuggestionPicked,
|
|
@@ -471,9 +474,12 @@ function AliaConversationView({
|
|
|
471
474
|
const welcome = useMemo(
|
|
472
475
|
() =>
|
|
473
476
|
profileLoading ? null : (
|
|
474
|
-
<
|
|
477
|
+
<View className="items-center gap-8">
|
|
478
|
+
<AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} lead={lead} />
|
|
479
|
+
{welcomeAccessory}
|
|
480
|
+
</View>
|
|
475
481
|
),
|
|
476
|
-
[profileLoading, greeting, welcomeSubtitle, lead],
|
|
482
|
+
[profileLoading, greeting, welcomeSubtitle, lead, welcomeAccessory],
|
|
477
483
|
);
|
|
478
484
|
|
|
479
485
|
// ── The agent's offer to start the next stretch fresh ──
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type AliaChatContentProps,
|
|
31
31
|
} from './AliaChatContent';
|
|
32
32
|
import { AliaChatHeader } from './AliaChatHeader';
|
|
33
|
+
import { AliaRecentConversations } from './AliaRecentConversations';
|
|
33
34
|
import { ThoughtPanel } from './thought/ThoughtPanel';
|
|
34
35
|
import type { AliaSuggestion } from '../types';
|
|
35
36
|
|
|
@@ -88,6 +89,8 @@ export interface AliaChatSheetProps {
|
|
|
88
89
|
onShare?: (share: AliaConversationShare) => void;
|
|
89
90
|
/** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
|
|
90
91
|
headerMenu?: React.ReactNode;
|
|
92
|
+
/** The person's latest conversations with the lead, under the greeting. Default `true`. */
|
|
93
|
+
recentConversations?: boolean;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export interface AliaChatSheetRef {
|
|
@@ -97,6 +100,9 @@ export interface AliaChatSheetRef {
|
|
|
97
100
|
* opens on its welcome, with the composer focused.
|
|
98
101
|
*/
|
|
99
102
|
present: (turn?: AliaTurn) => void;
|
|
103
|
+
/** Opens the sheet on a stored conversation with its lead, where the person left it. */
|
|
104
|
+
resume: (conversationId: string) => void;
|
|
105
|
+
/** Closes the sheet and what is stacked on it, stopping a turn still being written. */
|
|
100
106
|
dismiss: () => void;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -121,6 +127,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
121
127
|
characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
|
|
122
128
|
onShare,
|
|
123
129
|
headerMenu,
|
|
130
|
+
recentConversations = true,
|
|
124
131
|
},
|
|
125
132
|
ref,
|
|
126
133
|
) => {
|
|
@@ -135,9 +142,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
135
142
|
const mainStopRef = useRef<(() => void) | null>(null);
|
|
136
143
|
const conversationStopRef = useRef<(() => void) | null>(null);
|
|
137
144
|
|
|
138
|
-
// The conversation sheet, stacked over the main one while a chat is open
|
|
139
|
-
// every
|
|
140
|
-
const [conversation, setConversation] = useState<{
|
|
145
|
+
// The conversation sheet, stacked over the main one while a chat is open: begun by a first
|
|
146
|
+
// turn, or a stored one picked up again. The key makes every opening a fresh chat.
|
|
147
|
+
const [conversation, setConversation] = useState<{
|
|
148
|
+
key: number;
|
|
149
|
+
turn?: AliaTurn;
|
|
150
|
+
conversationId?: string;
|
|
151
|
+
} | null>(null);
|
|
141
152
|
|
|
142
153
|
const registerMainStop = useCallback((stop: (() => void) | null) => {
|
|
143
154
|
mainStopRef.current = stop;
|
|
@@ -149,6 +160,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
149
160
|
const startConversation = useCallback((turn: AliaTurn) => {
|
|
150
161
|
setConversation((current) => ({ key: (current?.key ?? 0) + 1, turn }));
|
|
151
162
|
}, []);
|
|
163
|
+
const resumeConversation = useCallback((conversationId: string) => {
|
|
164
|
+
setConversation((current) => ({ key: (current?.key ?? 0) + 1, conversationId }));
|
|
165
|
+
}, []);
|
|
166
|
+
// The chat inside follows its conversation (a first turn creates it, "start over" leaves it).
|
|
167
|
+
const followConversation = useCallback((conversationId: string | undefined) => {
|
|
168
|
+
setConversation((current) => (current ? { ...current, conversationId } : current));
|
|
169
|
+
}, []);
|
|
152
170
|
useEffect(() => {
|
|
153
171
|
if (conversation) conversationRef.current?.present();
|
|
154
172
|
}, [conversation]);
|
|
@@ -197,9 +215,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
197
215
|
mainRef.current?.present();
|
|
198
216
|
if (turn) startConversation(turn);
|
|
199
217
|
},
|
|
218
|
+
resume: (conversationId) => {
|
|
219
|
+
mainRef.current?.present();
|
|
220
|
+
resumeConversation(conversationId);
|
|
221
|
+
},
|
|
200
222
|
dismiss: handleDismiss,
|
|
201
223
|
}),
|
|
202
|
-
[handleDismiss, startConversation],
|
|
224
|
+
[handleDismiss, startConversation, resumeConversation],
|
|
203
225
|
);
|
|
204
226
|
|
|
205
227
|
// Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
|
|
@@ -282,6 +304,11 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
282
304
|
welcomeSuggestions={welcomeSuggestions}
|
|
283
305
|
suggestionMatches={suggestionMatches}
|
|
284
306
|
onSuggestionQuery={onSuggestionQuery}
|
|
307
|
+
welcomeAccessory={
|
|
308
|
+
recentConversations ? (
|
|
309
|
+
<AliaRecentConversations agentId={agent?.id} onOpen={resumeConversation} />
|
|
310
|
+
) : undefined
|
|
311
|
+
}
|
|
285
312
|
presentedProgress={mainProgress}
|
|
286
313
|
onStopReady={registerMainStop}
|
|
287
314
|
onStart={startConversation}
|
|
@@ -303,6 +330,8 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
303
330
|
<AliaChatContent
|
|
304
331
|
{...shared}
|
|
305
332
|
initialTurn={conversation.turn}
|
|
333
|
+
conversationId={conversation.conversationId}
|
|
334
|
+
onConversationChange={followConversation}
|
|
306
335
|
presentedProgress={conversationProgress}
|
|
307
336
|
onStopReady={registerConversationStop}
|
|
308
337
|
chrome={conversationChrome}
|
|
@@ -7,12 +7,7 @@ import {
|
|
|
7
7
|
type ReactNode,
|
|
8
8
|
type RefObject,
|
|
9
9
|
} from 'react';
|
|
10
|
-
import {
|
|
11
|
-
View,
|
|
12
|
-
type NativeSyntheticEvent,
|
|
13
|
-
type TextInput,
|
|
14
|
-
type TextInputKeyPressEventData,
|
|
15
|
-
} from 'react-native';
|
|
10
|
+
import { View, type TextInputKeyPressEvent } from 'react-native';
|
|
16
11
|
import {
|
|
17
12
|
ComposerPill,
|
|
18
13
|
type ComposerPanelAddMenuGroup,
|
|
@@ -28,6 +23,7 @@ import type { Completion } from '../lib/suggestions';
|
|
|
28
23
|
import { composerTiles, intakeError } from '../lib/attachment-tiles';
|
|
29
24
|
import type { IntakeKind } from '../lib/attachment-intake';
|
|
30
25
|
import { KeyboardAvoidingView } from '../lib/keyboard';
|
|
26
|
+
import type { TextInputHandle } from '../lib/text-input';
|
|
31
27
|
import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
|
|
32
28
|
import type { AskedQuestion } from '../lib/questions';
|
|
33
29
|
import type { VoiceErrorCode } from '../lib/speech-messages';
|
|
@@ -116,11 +112,11 @@ export interface AliaComposerProps<T extends AliaSuggestion = AliaSuggestion> {
|
|
|
116
112
|
*/
|
|
117
113
|
accessory?: ReactNode;
|
|
118
114
|
/** The field's keys, before the panel's own Enter rule (suggestions over it). */
|
|
119
|
-
onKeyPress?: (event:
|
|
115
|
+
onKeyPress?: (event: TextInputKeyPressEvent) => void;
|
|
120
116
|
/** The host already avoids the keyboard. */
|
|
121
117
|
disableKeyboardAvoidance?: boolean;
|
|
122
118
|
/** The field itself, for a host that moves focus into it. */
|
|
123
|
-
inputRef?: RefObject<
|
|
119
|
+
inputRef?: RefObject<TextInputHandle | null>;
|
|
124
120
|
/** Dictation's language, a BCP-47 tag; omitted, the platform's own. */
|
|
125
121
|
speechLanguage?: string;
|
|
126
122
|
/** Alia's API, which transcribes dictation; omitted, `EXPO_PUBLIC_ALIA_API_URL` or production. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Item } from '@oxy.so/bloom/item';
|
|
2
|
+
import { Text } from '@oxy.so/bloom/typography';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { View } from 'react-native';
|
|
5
|
+
import { useLabels } from '../lib/labels';
|
|
6
|
+
import { useConversations } from '../runtime/conversations';
|
|
7
|
+
|
|
8
|
+
/** How many of the lead's conversations the sheet offers to pick up again. */
|
|
9
|
+
export const RECENT_CONVERSATIONS_SHOWN = 3;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The person's latest conversations with the sheet's lead, under its greeting: "Ask Sindi" picks
|
|
13
|
+
* up where it left off instead of every opening being a blank page. Alia's own sheet lists the
|
|
14
|
+
* person's latest conversations of any kind. Nothing while loading or when there are none.
|
|
15
|
+
*/
|
|
16
|
+
export function AliaRecentConversations({
|
|
17
|
+
agentId,
|
|
18
|
+
onOpen,
|
|
19
|
+
}: {
|
|
20
|
+
/** The lead; omitted, Alia. */
|
|
21
|
+
agentId?: string;
|
|
22
|
+
onOpen: (conversationId: string) => void;
|
|
23
|
+
}) {
|
|
24
|
+
const labels = useLabels();
|
|
25
|
+
const { data } = useConversations(agentId === undefined ? {} : { agentId });
|
|
26
|
+
const recent = useMemo(
|
|
27
|
+
() => data?.pages[0]?.conversations.slice(0, RECENT_CONVERSATIONS_SHOWN) ?? [],
|
|
28
|
+
[data],
|
|
29
|
+
);
|
|
30
|
+
const when = useMemo(
|
|
31
|
+
() => new Intl.DateTimeFormat(labels.locale, { day: 'numeric', month: 'short' }),
|
|
32
|
+
[labels.locale],
|
|
33
|
+
);
|
|
34
|
+
if (recent.length === 0) return null;
|
|
35
|
+
return (
|
|
36
|
+
<View accessibilityRole="list" className="w-full max-w-[480px] self-center gap-1">
|
|
37
|
+
<Text className="px-3 text-sm text-text-secondary">{labels.recentConversations}</Text>
|
|
38
|
+
{recent.map((conversation) => {
|
|
39
|
+
const title = conversation.title || labels.newChat;
|
|
40
|
+
return (
|
|
41
|
+
<Item
|
|
42
|
+
key={conversation.id}
|
|
43
|
+
density="compact"
|
|
44
|
+
accessibilityRole="button"
|
|
45
|
+
accessibilityLabel={title}
|
|
46
|
+
onPress={() => onOpen(conversation.id)}
|
|
47
|
+
title={<Text numberOfLines={1}>{title}</Text>}
|
|
48
|
+
trailing={
|
|
49
|
+
<Text className="text-xs text-text-tertiary">
|
|
50
|
+
{when.format(new Date(conversation.updatedAt))}
|
|
51
|
+
</Text>
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { AvatarFlights } from '@oxy.so/bloom/avatar-flight';
|
|
2
2
|
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { TextInputKeyPressEvent } from 'react-native';
|
|
5
5
|
import type { SharedValue } from 'react-native-reanimated';
|
|
6
6
|
import { leadToProfile } from '../lib/flights';
|
|
7
7
|
import type { AliaLead } from '../lib/participants';
|
|
8
|
+
import type { TextInputHandle } from '../lib/text-input';
|
|
8
9
|
import { imageUriToDataUrl } from '../lib/image-data-url';
|
|
9
10
|
import {
|
|
10
11
|
useComposerDraft,
|
|
@@ -254,7 +255,7 @@ export function useChatComposer({
|
|
|
254
255
|
});
|
|
255
256
|
|
|
256
257
|
// ── The field: focus, and its keys ──
|
|
257
|
-
const inputRef = useRef<
|
|
258
|
+
const inputRef = useRef<TextInputHandle | null>(null);
|
|
258
259
|
useFocusWhenPresented(presentedProgress, () => inputRef.current?.focus());
|
|
259
260
|
// Starting an edit puts the caret in the composer that now holds the question: the Edit press
|
|
260
261
|
// left focus on the row's button, so typing went nowhere and Escape could not reach the edit.
|
|
@@ -265,7 +266,7 @@ export function useChatComposer({
|
|
|
265
266
|
const { onKeyPress: onSuggestionKeyPress } = suggestions;
|
|
266
267
|
const cancelEdit = edit.cancel;
|
|
267
268
|
const onKeyPress = useCallback(
|
|
268
|
-
(event:
|
|
269
|
+
(event: TextInputKeyPressEvent) => {
|
|
269
270
|
// Escape leaves an edit, as its Cancel does: it is the top-most thing open while the
|
|
270
271
|
// question is being rewritten.
|
|
271
272
|
if (editing !== undefined && event.nativeEvent.key === 'Escape') {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type { TextInputKeyPressEvent } from 'react-native';
|
|
3
3
|
import {
|
|
4
4
|
type Completion,
|
|
5
5
|
SEARCH_DEBOUNCE_MS,
|
|
@@ -62,7 +62,7 @@ export function useComposerSuggestions<T extends AliaSuggestion>({
|
|
|
62
62
|
);
|
|
63
63
|
|
|
64
64
|
const onKeyPress = useCallback(
|
|
65
|
-
(event:
|
|
65
|
+
(event: TextInputKeyPressEvent) => {
|
|
66
66
|
if (completions.length === 0) return;
|
|
67
67
|
const key = event.nativeEvent.key;
|
|
68
68
|
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type { AliaAddMenu } from './lib/add-menu';
|
|
|
27
27
|
export type { AliaVoiceOptions } from './hooks/useChatVoice';
|
|
28
28
|
export { AliaChatSheet } from './components/AliaChatSheet';
|
|
29
29
|
export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
|
|
30
|
+
export { AliaRecentConversations } from './components/AliaRecentConversations';
|
|
30
31
|
export type { AliaTurn } from './hooks/useChatComposer';
|
|
31
32
|
export { AliaChatScreen } from './components/AliaChatScreen';
|
|
32
33
|
export type { AliaChatScreenProps } from './components/AliaChatScreen';
|
package/src/lib/keyboard.tsx
CHANGED
|
@@ -16,11 +16,14 @@ type KeyboardAwareScrollViewProps = ScrollViewProps & {
|
|
|
16
16
|
extraKeyboardSpace?: number;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
// `ComponentRef`, not `ScrollView` itself: React Native 0.88 types `ScrollView` as a function
|
|
20
|
+
// component, so the class is no longer its instance type.
|
|
21
|
+
const KeyboardAwareScrollView = React.forwardRef<
|
|
22
|
+
React.ComponentRef<typeof ScrollView>,
|
|
23
|
+
KeyboardAwareScrollViewProps
|
|
24
|
+
>(({ bottomOffset, disableScrollOnKeyboardHide, enabled, extraKeyboardSpace, ...props }, ref) => (
|
|
25
|
+
<ScrollView ref={ref} {...props} />
|
|
26
|
+
));
|
|
24
27
|
KeyboardAwareScrollView.displayName = 'KeyboardAwareScrollView';
|
|
25
28
|
|
|
26
29
|
// `automaticOffset` is keyboard-controller's: measure the view's position in
|
package/src/lib/labels.tsx
CHANGED
|
@@ -87,6 +87,8 @@ export interface AliaChatLabels {
|
|
|
87
87
|
// Conversations (the runtime's own words: toasts and the errors it raises)
|
|
88
88
|
/** A conversation's title until it has one. */
|
|
89
89
|
newChat: string;
|
|
90
|
+
/** Over the sheet's recent conversations with its lead. */
|
|
91
|
+
recentConversations: string;
|
|
90
92
|
/** A turn could not be sent and the composer got its text back. */
|
|
91
93
|
sendFailed: string;
|
|
92
94
|
/** The session ended mid-request. */
|
|
@@ -165,6 +167,7 @@ export const DEFAULT_LABELS: AliaChatLabels = {
|
|
|
165
167
|
t: englishCopy,
|
|
166
168
|
locale: 'en',
|
|
167
169
|
newChat: 'New chat',
|
|
170
|
+
recentConversations: 'Recent',
|
|
168
171
|
sendFailed: "Your message couldn't be sent",
|
|
169
172
|
signInRequired: 'Sign in to keep chatting',
|
|
170
173
|
conversationSaveFailed: 'Failed to save conversation',
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentRef } from 'react';
|
|
2
|
+
import type { TextInput } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a ref to a `TextInput` holds. `ComponentRef`, not `TextInput` itself: React Native 0.88
|
|
6
|
+
* types `TextInput` as a function component, so the class is no longer its instance type, and
|
|
7
|
+
* `focus()` is only on the instance.
|
|
8
|
+
*/
|
|
9
|
+
export type TextInputHandle = ComponentRef<typeof TextInput>;
|