@alia.onl/sdk 20.1.0 → 21.1.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 +73 -0
- package/README.md +14 -1
- package/package.json +2 -1
- package/src/ask.ts +7 -0
- package/src/components/AliaAskProvider.tsx +36 -22
- package/src/components/AliaAskSheet.tsx +52 -0
- package/src/components/AliaChatContent.tsx +24 -4
- package/src/components/AliaChatScreen.tsx +4 -0
- package/src/components/AliaChatSheet.tsx +4 -0
- package/src/components/AliaComposer.tsx +4 -8
- package/src/components/AliaRecentConversations.tsx +9 -2
- package/src/hooks/useChatComposer.ts +4 -3
- package/src/hooks/useComposerSuggestions.ts +2 -2
- package/src/lib/keyboard.tsx +8 -5
- package/src/lib/origins.ts +9 -0
- package/src/lib/text-input.ts +9 -0
- package/src/runtime/config.tsx +18 -1
- package/src/runtime/conversations.ts +22 -13
- package/src/runtime/host-tools.ts +27 -11
- package/src/runtime/index.ts +2 -1
- package/src/runtime/query-keys.ts +14 -2
- package/src/runtime/sharing.ts +10 -2
- package/src/runtime/types.ts +2 -0
- package/src/runtime/use-conversation-stream.ts +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 21.1.0
|
|
4
|
+
|
|
5
|
+
A shared conversation has one address, Alia's, whichever app minted it (`docs/agent-embedding.md`
|
|
6
|
+
§6).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`url` on `AliaConversationShare`**: `useShareConversation().share` (and a host's `onShare`)
|
|
11
|
+
gets the link to hand on, `https://alia.onl/shared/<token>`, rather than a token to build its own
|
|
12
|
+
page around.
|
|
13
|
+
- **`aliaSharedConversationUrl(token)`**: the same link, for an app that redirects the share links
|
|
14
|
+
it minted before to Alia's page.
|
|
15
|
+
|
|
16
|
+
## 21.0.0
|
|
17
|
+
|
|
18
|
+
A conversation records the Oxy app it started in, so an embedding app lists only its own while
|
|
19
|
+
the Alia app lists all of them (`docs/agent-embedding.md` §6). The app is named once, on the chat.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`app` on `AliaChatContent`, `AliaChatSheet`, `AliaChatScreen`, `AliaAskProvider` and
|
|
24
|
+
`AliaRuntimeProvider`**: the embedding app's slug (`homiio`, `mercaria`). Every turn carries it
|
|
25
|
+
as `host.app` — with an empty tool list when the chat has no host tools — and
|
|
26
|
+
`POST /v1/conversations/new` and `POST /v1/conversations` carry it as `app`, so a conversation
|
|
27
|
+
the chat starts is labelled with it. Omitted, the chat is Alia's own and labels nothing.
|
|
28
|
+
- **`useConversations({ app })`**: only the conversations started in that app
|
|
29
|
+
(`GET /v1/conversations?app=`), composable with `agentId`. The chat's `app` does not imply it:
|
|
30
|
+
the app that wants only its own asks for them.
|
|
31
|
+
- **`Conversation.app`**: the app a conversation started in, or `null`.
|
|
32
|
+
- **The sheet's recent conversations are the app's.** In a sheet with an `app`, the recents under
|
|
33
|
+
the greeting (20.1.0) are the ones started in that app; Alia's own sheet still lists any.
|
|
34
|
+
|
|
35
|
+
### Changed (breaking)
|
|
36
|
+
|
|
37
|
+
- **`AliaHostTools` has no `app`.** Host tools are the chat's `app`'s, which a chat with
|
|
38
|
+
`hostTools` must name; one without throws on render. Move `hostTools.app` to the chat's `app`.
|
|
39
|
+
- **`hostRequest(app, tools, outcomes)`** and **`HostOutcomeQueue.dispatch(action, tools, app)`**
|
|
40
|
+
take the app.
|
|
41
|
+
- **`aliaQueryKeys.conversations.withAgent(id)` is `filtered({ agentId, app })`.**
|
|
42
|
+
|
|
43
|
+
## 20.3.0
|
|
44
|
+
|
|
45
|
+
A host hears when a turn has stopped streaming.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`AliaRuntimeHost.onTurnEnd`**, the pair of `onTurnStart`: called exactly once for each turn
|
|
50
|
+
the runtime sends, once it stops streaming for any reason — finished, stopped (Stop) or failed —
|
|
51
|
+
and only while the conversation that sent it is still mounted, so a surface that left mid-turn
|
|
52
|
+
is never called back. A host that holds back what would take the chat off the screen runs it
|
|
53
|
+
here, instead of watching `useConversationStore().streamingConversationId`.
|
|
54
|
+
|
|
55
|
+
## 20.2.0
|
|
56
|
+
|
|
57
|
+
An app mounts its one sheet at the root without the chat in its entry bundle.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **`@alia.onl/sdk/ask`**, a leaf entry with `AliaAskProvider` and `useAskAgent` alone. The root
|
|
62
|
+
entry still exports both, but brings the whole chat with them.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **`AliaAskProvider` loads its sheet lazily** (`components/AliaAskSheet.tsx`, one `import()`) and
|
|
67
|
+
mounts it on the first `ask`. Signed in, the module is fetched in the background, so the first
|
|
68
|
+
`ask` does not wait on the network. `check:entries` holds that `./ask` loads none of the chat
|
|
69
|
+
with the entry.
|
|
70
|
+
- **Ref and event types are React Native 0.88-ready.** A ref to a `ScrollView` or `TextInput` is
|
|
71
|
+
typed `ComponentRef<typeof …>` (0.88 types both as function components), and key presses are
|
|
72
|
+
`TextInputKeyPressEvent` (`TextInputKeyPressEventData` is deprecated since 0.86). Nothing
|
|
73
|
+
changes on 0.86. The peer ranges stay on Expo 57 / React Native 0.86: Bloom's own `TextInput`
|
|
74
|
+
ref props are not 0.88-ready yet.
|
|
75
|
+
|
|
3
76
|
## 20.1.0
|
|
4
77
|
|
|
5
78
|
The one sheet is the whole surface an app needs for an agent: it picks a conversation up again and
|
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
|
|
@@ -169,6 +169,10 @@ resume('sindi', conversationId); // a stored conversation, where the person left
|
|
|
169
169
|
dismiss(); // e.g. after a host tool changed the page under the sheet
|
|
170
170
|
```
|
|
171
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
|
+
|
|
172
176
|
The sheet offers the person's latest conversations with its lead under the greeting
|
|
173
177
|
(`recentConversations={false}` turns it off). The code that configures the sheet sits above it, so
|
|
174
178
|
`AliaAskProvider` forwards a ref to the same `AliaAsk` for it.
|
|
@@ -337,6 +341,9 @@ const host: AliaRuntimeHost = {
|
|
|
337
341
|
showThought: (opening) => (opening === 'automatic' && panel.isOpen ? false : panel.show('thought')),
|
|
338
342
|
openAutomation: (id) => router.push(`/automations/${id}`), // a scheduled-task card is a link
|
|
339
343
|
faviconUrl: (url) => favicons.of(url), // the work log's source icons
|
|
344
|
+
// the edges of a turn this runtime sends
|
|
345
|
+
onTurnStart: () => executor.beginTurn(),
|
|
346
|
+
onTurnEnd: () => executor.runHeldBack(), // finished, stopped or failed: once per turn
|
|
340
347
|
};
|
|
341
348
|
|
|
342
349
|
<AliaRuntimeProvider
|
|
@@ -350,6 +357,12 @@ const host: AliaRuntimeHost = {
|
|
|
350
357
|
</AliaRuntimeProvider>;
|
|
351
358
|
```
|
|
352
359
|
|
|
360
|
+
`onTurnEnd` is called exactly once for each turn the runtime sends, when it stops streaming —
|
|
361
|
+
the answer was written, the person pressed Stop, or the turn failed — and only while the
|
|
362
|
+
conversation that sent it is still mounted. A host that holds back what would take the chat off
|
|
363
|
+
the screen (a host tool that navigates away) runs it there; a surface that left mid-turn is never
|
|
364
|
+
called back, so what it held back goes with it.
|
|
365
|
+
|
|
353
366
|
The sheet and the Alia app differ ONLY in what they put there:
|
|
354
367
|
|
|
355
368
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alia.onl/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.1.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,13 +5,20 @@
|
|
|
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,
|
|
13
18
|
forwardRef,
|
|
19
|
+
lazy,
|
|
14
20
|
type ReactNode,
|
|
21
|
+
Suspense,
|
|
15
22
|
useCallback,
|
|
16
23
|
useContext,
|
|
17
24
|
useEffect,
|
|
@@ -21,7 +28,11 @@ import {
|
|
|
21
28
|
useState,
|
|
22
29
|
} from 'react';
|
|
23
30
|
import type { AliaTurn } from '../hooks/useChatComposer';
|
|
24
|
-
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 })));
|
|
25
36
|
|
|
26
37
|
/** The agents an app offers, by its own key. An entry without `id` is Alia. */
|
|
27
38
|
export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
|
|
@@ -29,6 +40,7 @@ export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
|
|
|
29
40
|
export interface AliaAskProviderProps
|
|
30
41
|
extends Pick<
|
|
31
42
|
AliaChatSheetProps,
|
|
43
|
+
| 'app'
|
|
32
44
|
| 'hostTools'
|
|
33
45
|
| 'toolRenderers'
|
|
34
46
|
| 'labels'
|
|
@@ -73,14 +85,9 @@ export function useAskAgent(): AliaAsk {
|
|
|
73
85
|
return ask;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
/**
|
|
77
|
-
interface Opening {
|
|
88
|
+
/** The opening that waits for the sheet of agent `key` to be the one mounted. */
|
|
89
|
+
interface Opening extends AliaAskOpening {
|
|
78
90
|
key: string | undefined;
|
|
79
|
-
turn: AliaTurn | undefined;
|
|
80
|
-
/** A stored conversation to open instead of the welcome. */
|
|
81
|
-
conversationId?: string;
|
|
82
|
-
/** Changes on every `ask`, so asking the same agent twice opens twice. */
|
|
83
|
-
serial: number;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
@@ -95,6 +102,8 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
|
|
|
95
102
|
const sheetRef = useRef<AliaChatSheetRef>(null);
|
|
96
103
|
const [opening, setOpening] = useState<Opening | null>(null);
|
|
97
104
|
const [current, setCurrent] = useState<string | undefined>(undefined);
|
|
105
|
+
/** Nothing is mounted before the first `ask`. */
|
|
106
|
+
const [asked, setAsked] = useState(false);
|
|
98
107
|
const agentsRef = useRef(agents);
|
|
99
108
|
agentsRef.current = agents;
|
|
100
109
|
|
|
@@ -107,6 +116,7 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
|
|
|
107
116
|
void signIn().catch(() => undefined);
|
|
108
117
|
return;
|
|
109
118
|
}
|
|
119
|
+
setAsked(true);
|
|
110
120
|
setCurrent(agentKey);
|
|
111
121
|
setOpening((previous) => ({
|
|
112
122
|
key: agentKey,
|
|
@@ -125,13 +135,12 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
|
|
|
125
135
|
);
|
|
126
136
|
const dismiss = useCallback(() => sheetRef.current?.dismiss(), []);
|
|
127
137
|
|
|
128
|
-
|
|
138
|
+
const opened = useCallback(() => setOpening(null), []);
|
|
139
|
+
|
|
140
|
+
// Signed in, the sheet's module comes down in the background, ahead of the first `ask`.
|
|
129
141
|
useEffect(() => {
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
else sheetRef.current?.present(opening.turn);
|
|
133
|
-
setOpening(null);
|
|
134
|
-
}, [opening, current]);
|
|
142
|
+
if (canUsePrivateApi) void loadSheet().catch(() => undefined);
|
|
143
|
+
}, [canUsePrivateApi]);
|
|
135
144
|
|
|
136
145
|
const agentId = current === undefined ? undefined : agents?.[current]?.id;
|
|
137
146
|
const agent = useMemo(() => (agentId === undefined ? undefined : { id: agentId }), [agentId]);
|
|
@@ -141,14 +150,19 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
|
|
|
141
150
|
return (
|
|
142
151
|
<AskContext.Provider value={value}>
|
|
143
152
|
{children}
|
|
144
|
-
{canUsePrivateApi ? (
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
{canUsePrivateApi && asked ? (
|
|
154
|
+
<Suspense fallback={null}>
|
|
155
|
+
<LazyAskSheet
|
|
156
|
+
key={agentId ?? 'alia'}
|
|
157
|
+
ref={sheetRef}
|
|
158
|
+
{...sheet}
|
|
159
|
+
agent={agent}
|
|
160
|
+
welcomeSuggestions={agent === undefined ? welcomeSuggestions : undefined}
|
|
161
|
+
// A different agent is a new sheet: the opening waits for that one to mount.
|
|
162
|
+
opening={opening !== null && opening.key === current ? opening : null}
|
|
163
|
+
onOpened={opened}
|
|
164
|
+
/>
|
|
165
|
+
</Suspense>
|
|
152
166
|
) : null}
|
|
153
167
|
</AskContext.Provider>
|
|
154
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
|
+
});
|
|
@@ -50,7 +50,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
|
|
|
50
50
|
import { type DeviceModel, buildPowerLevelOptions } from '../lib/power-level-options';
|
|
51
51
|
import { ALIA_FEATURES, featureOffer, useAgentFeatures, useAgentProfile } from '../runtime/agents';
|
|
52
52
|
import { type AliaRuntimeHost, AliaRuntimeProvider, useAliaRuntime } from '../runtime/config';
|
|
53
|
-
import type
|
|
53
|
+
import { type AliaHostTools, APP_SLUG } from '../runtime/host-tools';
|
|
54
54
|
import { useCreateConversation } from '../runtime/conversations';
|
|
55
55
|
import { aliaQueryKeys } from '../runtime/query-keys';
|
|
56
56
|
import type { Message } from '../runtime/types';
|
|
@@ -137,12 +137,20 @@ export interface AliaChatContentProps {
|
|
|
137
137
|
*/
|
|
138
138
|
ephemeral?: boolean;
|
|
139
139
|
apiUrl?: string;
|
|
140
|
+
/**
|
|
141
|
+
* The Oxy app this chat is embedded in, by its slug (`homiio`, `mercaria`). A conversation the
|
|
142
|
+
* chat starts is labelled with it, so the app can list only its own
|
|
143
|
+
* (`useConversations({ app })`), and its `hostTools` are this app's. Omitted, the chat is
|
|
144
|
+
* Alia's own (`docs/agent-embedding.md` §6).
|
|
145
|
+
*/
|
|
146
|
+
app?: string;
|
|
140
147
|
/** App context injected into the system prompt. */
|
|
141
148
|
clientContext?: string;
|
|
142
149
|
/**
|
|
143
150
|
* The UI effects this app's client performs for the lead (`docs/agent-embedding.md` §2). Declared
|
|
144
|
-
* with every turn
|
|
145
|
-
* outcome reaches the model with the
|
|
151
|
+
* with every turn under the chat's `app`, which a chat with host tools must name; an action the
|
|
152
|
+
* stream asks for is performed once, by `execute`, and its outcome reaches the model with the
|
|
153
|
+
* next turn.
|
|
146
154
|
*/
|
|
147
155
|
hostTools?: AliaHostTools;
|
|
148
156
|
/** The app's own drawing of a catalogue tool's result, keyed `<app>:<tool>`, before the generic card. */
|
|
@@ -715,12 +723,24 @@ export function AliaChatContent({
|
|
|
715
723
|
onConversationChange,
|
|
716
724
|
runtime,
|
|
717
725
|
apiUrl,
|
|
726
|
+
app,
|
|
718
727
|
toolRenderers,
|
|
719
728
|
onFeatureLocked,
|
|
720
729
|
...props
|
|
721
730
|
}: AliaChatContentProps) {
|
|
722
731
|
const [ownId, setOwnId] = useState<string | undefined>(undefined);
|
|
723
732
|
const parent = useAliaRuntime();
|
|
733
|
+
const chatApp = app ?? parent.app;
|
|
734
|
+
if (chatApp !== undefined && !APP_SLUG.test(chatApp)) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
`AliaChatContent: app "${chatApp}" is not an app slug (^[a-z][a-z0-9-]{0,31}$)`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (props.hostTools !== undefined && chatApp === undefined) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
"AliaChatContent: hostTools are an app's, so a chat with hostTools names its app",
|
|
742
|
+
);
|
|
743
|
+
}
|
|
724
744
|
// Who decides which conversation is open: a host that navigates (a route) does; with none, the
|
|
725
745
|
// chat does (the sheet). Either way the host hears of it first, then `onConversationChange`.
|
|
726
746
|
const hostOpens = runtime?.openConversation ?? parent.host.openConversation;
|
|
@@ -746,7 +766,7 @@ export function AliaChatContent({
|
|
|
746
766
|
|
|
747
767
|
return (
|
|
748
768
|
<AliaChatLabelsProvider labels={labels}>
|
|
749
|
-
<AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host}>
|
|
769
|
+
<AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host} app={chatApp}>
|
|
750
770
|
<AliaToolRenderersProvider value={toolRenderers ?? NO_RENDERERS}>
|
|
751
771
|
<AliaConversationView {...props} conversationId={controlledId ?? ownId} />
|
|
752
772
|
</AliaToolRenderersProvider>
|
|
@@ -42,6 +42,8 @@ export interface AliaChatScreenProps {
|
|
|
42
42
|
agent?: AliaChatAgentRef;
|
|
43
43
|
/** App context injected into system prompt */
|
|
44
44
|
clientContext?: string;
|
|
45
|
+
/** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
|
|
46
|
+
app?: string;
|
|
45
47
|
/** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
|
|
46
48
|
hostTools?: AliaHostTools;
|
|
47
49
|
/** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
|
|
@@ -87,6 +89,7 @@ export interface AliaChatScreenProps {
|
|
|
87
89
|
|
|
88
90
|
export function AliaChatScreen({
|
|
89
91
|
agent,
|
|
92
|
+
app,
|
|
90
93
|
clientContext,
|
|
91
94
|
hostTools,
|
|
92
95
|
toolRenderers,
|
|
@@ -147,6 +150,7 @@ export function AliaChatScreen({
|
|
|
147
150
|
<KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
|
|
148
151
|
<AliaChatContent
|
|
149
152
|
agent={agent}
|
|
153
|
+
app={app}
|
|
150
154
|
clientContext={clientContext}
|
|
151
155
|
hostTools={hostTools}
|
|
152
156
|
toolRenderers={toolRenderers}
|
|
@@ -54,6 +54,8 @@ export interface AliaChatSheetProps {
|
|
|
54
54
|
agent?: AliaChatAgentRef;
|
|
55
55
|
/** App context injected into system prompt */
|
|
56
56
|
clientContext?: string;
|
|
57
|
+
/** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
|
|
58
|
+
app?: string;
|
|
57
59
|
/** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
|
|
58
60
|
hostTools?: AliaHostTools;
|
|
59
61
|
/** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
|
|
@@ -110,6 +112,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
110
112
|
(
|
|
111
113
|
{
|
|
112
114
|
agent,
|
|
115
|
+
app,
|
|
113
116
|
clientContext,
|
|
114
117
|
hostTools,
|
|
115
118
|
toolRenderers,
|
|
@@ -268,6 +271,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
268
271
|
flights,
|
|
269
272
|
layout: 'sheet' as const,
|
|
270
273
|
agent,
|
|
274
|
+
app,
|
|
271
275
|
clientContext,
|
|
272
276
|
hostTools,
|
|
273
277
|
toolRenderers,
|
|
@@ -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. */
|
|
@@ -3,6 +3,7 @@ import { Text } from '@oxy.so/bloom/typography';
|
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
4
|
import { View } from 'react-native';
|
|
5
5
|
import { useLabels } from '../lib/labels';
|
|
6
|
+
import { useAliaRuntime } from '../runtime/config';
|
|
6
7
|
import { useConversations } from '../runtime/conversations';
|
|
7
8
|
|
|
8
9
|
/** How many of the lead's conversations the sheet offers to pick up again. */
|
|
@@ -11,7 +12,9 @@ export const RECENT_CONVERSATIONS_SHOWN = 3;
|
|
|
11
12
|
/**
|
|
12
13
|
* The person's latest conversations with the sheet's lead, under its greeting: "Ask Sindi" picks
|
|
13
14
|
* 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.
|
|
15
|
+
* person's latest conversations of any kind. In an app's sheet (the chat's `app`) they are the ones
|
|
16
|
+
* started in that app: Mercaria's sheet offers Mercaria's, never a Homiio chat. Nothing while
|
|
17
|
+
* loading or when there are none.
|
|
15
18
|
*/
|
|
16
19
|
export function AliaRecentConversations({
|
|
17
20
|
agentId,
|
|
@@ -22,7 +25,11 @@ export function AliaRecentConversations({
|
|
|
22
25
|
onOpen: (conversationId: string) => void;
|
|
23
26
|
}) {
|
|
24
27
|
const labels = useLabels();
|
|
25
|
-
const {
|
|
28
|
+
const { app } = useAliaRuntime();
|
|
29
|
+
const { data } = useConversations({
|
|
30
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
31
|
+
...(app === undefined ? {} : { app }),
|
|
32
|
+
});
|
|
26
33
|
const recent = useMemo(
|
|
27
34
|
() => data?.pages[0]?.conversations.slice(0, RECENT_CONVERSATIONS_SHOWN) ?? [],
|
|
28
35
|
[data],
|
|
@@ -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/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/origins.ts
CHANGED
|
@@ -13,3 +13,12 @@ export const ALIA_API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api
|
|
|
13
13
|
* with CORS open (`packages/app/worker/index.js`).
|
|
14
14
|
*/
|
|
15
15
|
export const ALIA_WEB_ORIGIN = 'https://alia.onl';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one public page of a shared conversation, on Alia's web app, for every agent and every app
|
|
19
|
+
* that embeds one (`docs/agent-embedding.md` §6). An app shares this URL rather than a page of its
|
|
20
|
+
* own, so a link reads the same wherever it was minted.
|
|
21
|
+
*/
|
|
22
|
+
export function aliaSharedConversationUrl(token: string): string {
|
|
23
|
+
return `${ALIA_WEB_ORIGIN}/shared/${encodeURIComponent(token)}`;
|
|
24
|
+
}
|
|
@@ -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>;
|
package/src/runtime/config.tsx
CHANGED
|
@@ -72,6 +72,12 @@ export interface AliaRuntimeHost {
|
|
|
72
72
|
turnRequest?: () => AliaTurnRequest;
|
|
73
73
|
/** A turn is about to be sent from the composer or a retry. */
|
|
74
74
|
onTurnStart?: () => void;
|
|
75
|
+
/**
|
|
76
|
+
* A turn this runtime sent has stopped streaming: it finished, was stopped, or failed. Called
|
|
77
|
+
* exactly once per turn, after the stream has settled, and only while the conversation that
|
|
78
|
+
* sent it is still mounted: a surface that left mid-turn is never called back for it.
|
|
79
|
+
*/
|
|
80
|
+
onTurnEnd?: () => void;
|
|
75
81
|
/** Real answer text just arrived (throttled): a haptic tick. */
|
|
76
82
|
onOutput?: () => void;
|
|
77
83
|
/** The stream's usage frame: credits left and the spending warning. */
|
|
@@ -134,6 +140,14 @@ export interface AliaRuntimeConfig {
|
|
|
134
140
|
/** How read-aloud, dictation and calls speak; omitted, the server's and the platform's defaults. */
|
|
135
141
|
speech: SpeechPreferences;
|
|
136
142
|
host: AliaRuntimeHost;
|
|
143
|
+
/**
|
|
144
|
+
* The Oxy app the chat is embedded in, by its slug (`^[a-z][a-z0-9-]{0,31}$`): `homiio`,
|
|
145
|
+
* `mercaria`. A conversation it starts is labelled with it, and its host tools are this app's.
|
|
146
|
+
* Omitted, the chat is Alia's own and its conversations carry no app (`docs/agent-embedding.md`
|
|
147
|
+
* §6). A label for listing, never authority: which app a request comes from is proven by the
|
|
148
|
+
* app assertion, not by this.
|
|
149
|
+
*/
|
|
150
|
+
app: string | undefined;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
export interface AliaRuntimeProviderProps extends Partial<Omit<AliaRuntimeConfig, 'host'>> {
|
|
@@ -147,6 +161,7 @@ const DEFAULT_CONFIG: AliaRuntimeConfig = {
|
|
|
147
161
|
streamFetch: undefined,
|
|
148
162
|
speech: {},
|
|
149
163
|
host: {},
|
|
164
|
+
app: undefined,
|
|
150
165
|
};
|
|
151
166
|
|
|
152
167
|
const AliaRuntimeContext = createContext<AliaRuntimeConfig>(DEFAULT_CONFIG);
|
|
@@ -164,6 +179,7 @@ export function AliaRuntimeProvider({
|
|
|
164
179
|
streamFetch,
|
|
165
180
|
speech,
|
|
166
181
|
host,
|
|
182
|
+
app,
|
|
167
183
|
children,
|
|
168
184
|
}: AliaRuntimeProviderProps) {
|
|
169
185
|
const parent = useContext(AliaRuntimeContext);
|
|
@@ -175,8 +191,9 @@ export function AliaRuntimeProvider({
|
|
|
175
191
|
streamFetch: streamFetch ?? parent.streamFetch,
|
|
176
192
|
speech: speech ?? parent.speech,
|
|
177
193
|
host: host === undefined ? parent.host : { ...parent.host, ...host },
|
|
194
|
+
app: app ?? parent.app,
|
|
178
195
|
};
|
|
179
|
-
}, [apiUrl, offline, streamFetch, speech, host, parent]);
|
|
196
|
+
}, [apiUrl, offline, streamFetch, speech, host, app, parent]);
|
|
180
197
|
return <AliaRuntimeContext.Provider value={value}>{children}</AliaRuntimeContext.Provider>;
|
|
181
198
|
}
|
|
182
199
|
|
|
@@ -13,7 +13,7 @@ import { type AliaApi, useAliaApi } from './api';
|
|
|
13
13
|
import { type OfflineConversationStore, useAliaRuntime } from './config';
|
|
14
14
|
import { errorMessage as getErrorMessage, errorStatus } from './error-utils';
|
|
15
15
|
import { normalizeConversationMessages } from './message-history';
|
|
16
|
-
import { aliaQueryKeys } from './query-keys';
|
|
16
|
+
import { aliaQueryKeys, type ConversationsFilter } from './query-keys';
|
|
17
17
|
import { API_ROUTES } from './routes';
|
|
18
18
|
import { createRandomUuid } from './random-uuid';
|
|
19
19
|
import type { Conversation, ConversationsInfiniteData, ConversationsPage, Message } from './types';
|
|
@@ -96,23 +96,25 @@ export interface ConversationsClient {
|
|
|
96
96
|
offline: OfflineConversations;
|
|
97
97
|
/** Read at call time: the title of a chat created while signed out. */
|
|
98
98
|
newChatTitle: string;
|
|
99
|
+
/** The chat's app, which labels a conversation it creates (`AliaRuntimeConfig.app`). */
|
|
100
|
+
app: string | undefined;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** The runtime's conversation client for the nearest provider. */
|
|
102
104
|
export function useConversationsClient(): ConversationsClient {
|
|
103
105
|
const api = useAliaApi();
|
|
104
|
-
const { offline } = useAliaRuntime();
|
|
106
|
+
const { offline, app } = useAliaRuntime();
|
|
105
107
|
const { newChat } = useLabels();
|
|
106
108
|
return useMemo(
|
|
107
|
-
() => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat }),
|
|
108
|
-
[api, offline, newChat],
|
|
109
|
+
() => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat, app }),
|
|
110
|
+
[api, offline, newChat, app],
|
|
109
111
|
);
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
async function fetchConversationsPage(
|
|
113
115
|
client: ConversationsClient,
|
|
114
116
|
pageParam: string | undefined,
|
|
115
|
-
agentId
|
|
117
|
+
{ agentId, app }: ConversationsFilter,
|
|
116
118
|
): Promise<ConversationsPage> {
|
|
117
119
|
try {
|
|
118
120
|
const { data } = await client.api.get<{
|
|
@@ -124,6 +126,7 @@ async function fetchConversationsPage(
|
|
|
124
126
|
limit: PAGE_SIZE,
|
|
125
127
|
...(pageParam ? { cursor: pageParam } : {}),
|
|
126
128
|
...(agentId === undefined ? {} : { agentId }),
|
|
129
|
+
...(app === undefined ? {} : { app }),
|
|
127
130
|
},
|
|
128
131
|
});
|
|
129
132
|
return {
|
|
@@ -137,9 +140,9 @@ async function fetchConversationsPage(
|
|
|
137
140
|
hasMore: data.hasMore,
|
|
138
141
|
};
|
|
139
142
|
} catch (error: unknown) {
|
|
140
|
-
// If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's
|
|
141
|
-
// never kept offline).
|
|
142
|
-
if (errorStatus(error) === 401 && agentId === undefined) {
|
|
143
|
+
// If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's and an
|
|
144
|
+
// app's are never kept offline).
|
|
145
|
+
if (errorStatus(error) === 401 && agentId === undefined && app === undefined) {
|
|
143
146
|
const conversations = (await client.offline.read()).map((c) => ({
|
|
144
147
|
...c,
|
|
145
148
|
messages: [] as Message[],
|
|
@@ -158,19 +161,21 @@ async function fetchConversationsPage(
|
|
|
158
161
|
|
|
159
162
|
/**
|
|
160
163
|
* The person's conversations, newest first, a page at a time. The sidebar and the sheet read this.
|
|
161
|
-
* Given `agentId`, only the conversations with that agent
|
|
164
|
+
* Given `agentId`, only the conversations with that agent; given `app`, only the ones started in
|
|
165
|
+
* that app — an embedding app's own list (`GET /v1/conversations?agentId=&app=`). Neither is
|
|
166
|
+
* implied by the chat's `app`: the app that wants only its own says so here.
|
|
162
167
|
*/
|
|
163
|
-
export function useConversations({ agentId }:
|
|
168
|
+
export function useConversations({ agentId, app }: ConversationsFilter = {}) {
|
|
164
169
|
const { isAuthenticated } = useOxy();
|
|
165
170
|
const client = useConversationsClient();
|
|
166
171
|
|
|
167
172
|
return useInfiniteQuery({
|
|
168
173
|
queryKey:
|
|
169
|
-
agentId === undefined
|
|
174
|
+
agentId === undefined && app === undefined
|
|
170
175
|
? aliaQueryKeys.conversations.all
|
|
171
|
-
: aliaQueryKeys.conversations.
|
|
176
|
+
: aliaQueryKeys.conversations.filtered({ agentId, app }),
|
|
172
177
|
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
|
173
|
-
fetchConversationsPage(client, pageParam, agentId),
|
|
178
|
+
fetchConversationsPage(client, pageParam, { agentId, app }),
|
|
174
179
|
initialPageParam: undefined as string | undefined,
|
|
175
180
|
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
|
|
176
181
|
staleTime: FIVE_MINUTES,
|
|
@@ -309,12 +314,14 @@ export function useSaveConversation() {
|
|
|
309
314
|
conversationId: id,
|
|
310
315
|
messages,
|
|
311
316
|
...(title && { title }),
|
|
317
|
+
...(client.app === undefined ? {} : { app: client.app }),
|
|
312
318
|
});
|
|
313
319
|
return {
|
|
314
320
|
id: data.id,
|
|
315
321
|
title: data.title,
|
|
316
322
|
lastMessage: data.lastMessage,
|
|
317
323
|
source: data.source,
|
|
324
|
+
app: data.app,
|
|
318
325
|
createdAt: new Date(data.createdAt),
|
|
319
326
|
updatedAt: new Date(data.updatedAt),
|
|
320
327
|
messages,
|
|
@@ -515,6 +522,7 @@ export function useCreateConversation() {
|
|
|
515
522
|
try {
|
|
516
523
|
const { data } = await client.api.post<ApiConversation>(API_ROUTES.conversations.create, {
|
|
517
524
|
...(params?.agentId && { agentId: params.agentId }),
|
|
525
|
+
...(client.app === undefined ? {} : { app: client.app }),
|
|
518
526
|
});
|
|
519
527
|
return {
|
|
520
528
|
id: data.id,
|
|
@@ -522,6 +530,7 @@ export function useCreateConversation() {
|
|
|
522
530
|
lastMessage: undefined,
|
|
523
531
|
source: data.source,
|
|
524
532
|
agentId: data.agentId,
|
|
533
|
+
app: data.app,
|
|
525
534
|
createdAt: new Date(data.createdAt),
|
|
526
535
|
updatedAt: new Date(data.updatedAt),
|
|
527
536
|
messages: [],
|
|
@@ -35,10 +35,12 @@ export interface AliaHostOutcome {
|
|
|
35
35
|
detail?: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The host tools a surface declares (`hostTools` on the chat). They are the tools of the chat's
|
|
40
|
+
* `app`, which a surface with host tools must name: the app is said once, on the chat, and is what
|
|
41
|
+
* the model sees them under (`host_<app>__<name>`).
|
|
42
|
+
*/
|
|
39
43
|
export interface AliaHostTools {
|
|
40
|
-
/** The app's slug, `^[a-z][a-z0-9-]{0,31}$`. */
|
|
41
|
-
app: string;
|
|
42
44
|
/**
|
|
43
45
|
* At most 16. What the host's screen shows now is not part of the declaration: it is the chat's
|
|
44
46
|
* `clientContext`, built by the host per turn.
|
|
@@ -80,14 +82,18 @@ export class HostOutcomeQueue {
|
|
|
80
82
|
private ready: HostOutcomeRecord[] = [];
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
|
-
* Perform `action` with
|
|
84
|
-
* action had already been performed and nothing was done.
|
|
85
|
+
* Perform `action` with the tools `app` declared, once. Resolves when its outcome is queued;
|
|
86
|
+
* `false` when the action had already been performed and nothing was done.
|
|
85
87
|
*/
|
|
86
|
-
async dispatch(
|
|
88
|
+
async dispatch(
|
|
89
|
+
action: HostAction,
|
|
90
|
+
tools: AliaHostTools | undefined,
|
|
91
|
+
app: string | undefined,
|
|
92
|
+
): Promise<boolean> {
|
|
87
93
|
if (this.performed.has(action.toolCallId)) return false;
|
|
88
94
|
this.performed.add(action.toolCallId);
|
|
89
95
|
let outcome: AliaHostOutcome;
|
|
90
|
-
if (tools === undefined || action.app !==
|
|
96
|
+
if (tools === undefined || app === undefined || action.app !== app) {
|
|
91
97
|
outcome = { outcome: 'rejected', detail: 'This app did not declare that tool.' };
|
|
92
98
|
} else if (!tools.definitions.some((tool) => tool.name === action.name)) {
|
|
93
99
|
outcome = { outcome: 'rejected', detail: `Unknown tool "${action.name}".` };
|
|
@@ -121,16 +127,23 @@ export class HostOutcomeQueue {
|
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The `host` member of a request: the chat's `app` and its host tools (none is an empty list),
|
|
132
|
+
* or nothing for a chat that names no app — Alia's own.
|
|
133
|
+
*
|
|
134
|
+
* The app is also what labels a conversation this turn starts (`docs/agent-embedding.md` §6), so
|
|
135
|
+
* a surface without host tools still sends it.
|
|
136
|
+
*/
|
|
125
137
|
export function hostRequest(
|
|
138
|
+
app: string | undefined,
|
|
126
139
|
tools: AliaHostTools | undefined,
|
|
127
140
|
outcomes: readonly HostOutcomeRecord[],
|
|
128
141
|
): HostRequest | undefined {
|
|
129
|
-
if (
|
|
142
|
+
if (app === undefined) return undefined;
|
|
130
143
|
return {
|
|
131
|
-
app
|
|
144
|
+
app,
|
|
132
145
|
version: 1,
|
|
133
|
-
tools: tools
|
|
146
|
+
tools: (tools?.definitions ?? []).map(({ name, description, inputSchema }) => ({
|
|
134
147
|
name,
|
|
135
148
|
description,
|
|
136
149
|
inputSchema,
|
|
@@ -138,3 +151,6 @@ export function hostRequest(
|
|
|
138
151
|
...(outcomes.length === 0 ? {} : { outcomes: [...outcomes] }),
|
|
139
152
|
};
|
|
140
153
|
}
|
|
154
|
+
|
|
155
|
+
/** The slug an app names itself by, here and on the server: `^[a-z][a-z0-9-]{0,31}$`. */
|
|
156
|
+
export const APP_SLUG = /^[a-z][a-z0-9-]{0,31}$/;
|
package/src/runtime/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export {
|
|
|
51
51
|
useShareConversation,
|
|
52
52
|
useSharedConversation,
|
|
53
53
|
} from './sharing';
|
|
54
|
+
export { aliaSharedConversationUrl } from '../lib/origins';
|
|
54
55
|
export type {
|
|
55
56
|
AliaConversationShare,
|
|
56
57
|
AliaSharedAgent,
|
|
@@ -60,7 +61,7 @@ export type {
|
|
|
60
61
|
export { uploadToOxyFiles, useDocumentUpload } from './oxy-files';
|
|
61
62
|
export type { OxyFilesClient } from './oxy-files';
|
|
62
63
|
export type { AliaApi, AliaApiRequest, AliaApiResult } from './api';
|
|
63
|
-
export { aliaQueryKeys } from './query-keys';
|
|
64
|
+
export { aliaQueryKeys, type ConversationsFilter } from './query-keys';
|
|
64
65
|
export {
|
|
65
66
|
ConversationNotFoundError,
|
|
66
67
|
useClearConversation,
|
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
* The React Query keys of the conversation runtime. The host's persister and its sidebar read
|
|
3
3
|
* the same keys the hooks write, so a list shown anywhere is the list the chat updates.
|
|
4
4
|
*/
|
|
5
|
+
/** What a conversation list may be narrowed to (`GET /v1/conversations?agentId=&app=`). */
|
|
6
|
+
export interface ConversationsFilter {
|
|
7
|
+
/** Only the conversations with this agent. */
|
|
8
|
+
agentId?: string;
|
|
9
|
+
/** Only the conversations started in this app, by its slug (`docs/agent-embedding.md` §6). */
|
|
10
|
+
app?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export const aliaQueryKeys = {
|
|
6
14
|
conversations: {
|
|
7
15
|
all: ['conversations'] as const,
|
|
8
16
|
detail: (id: string) => ['conversation', id] as const,
|
|
9
|
-
/**
|
|
10
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The person's conversations narrowed to one agent, one app or both: under `all`, so every
|
|
19
|
+
* write to the list reaches it.
|
|
20
|
+
*/
|
|
21
|
+
filtered: (filter: ConversationsFilter) =>
|
|
22
|
+
['conversations', { agentId: filter.agentId, app: filter.app }] as const,
|
|
11
23
|
},
|
|
12
24
|
/** An agent the chat is opened on: its profile and the state of its feature gates. */
|
|
13
25
|
agents: {
|
package/src/runtime/sharing.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
|
+
import { aliaSharedConversationUrl } from '../lib/origins';
|
|
3
4
|
import { useAliaApi } from './api';
|
|
4
5
|
import { useAliaRuntime } from './config';
|
|
5
6
|
import { aliaQueryKeys } from './query-keys';
|
|
@@ -9,8 +10,10 @@ import type { Message } from './types';
|
|
|
9
10
|
/** A conversation's read-only link (`POST /v1/conversations/:id/share`, `docs/agent-embedding.md` §6). */
|
|
10
11
|
export interface AliaConversationShare {
|
|
11
12
|
conversationId: string;
|
|
12
|
-
/** What `GET /v1/shared/:token` reads it by
|
|
13
|
+
/** What `GET /v1/shared/:token` reads it by. */
|
|
13
14
|
token: string;
|
|
15
|
+
/** The link to hand on: Alia's one shared-conversation page (`aliaSharedConversationUrl`). */
|
|
16
|
+
url: string;
|
|
14
17
|
expiresAt: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -36,7 +39,12 @@ export function useShareConversation() {
|
|
|
36
39
|
if (typeof data?.token !== 'string' || typeof data.expiresAt !== 'string') {
|
|
37
40
|
throw new Error('The share link came back without a token.');
|
|
38
41
|
}
|
|
39
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
conversationId,
|
|
44
|
+
token: data.token,
|
|
45
|
+
url: aliaSharedConversationUrl(data.token),
|
|
46
|
+
expiresAt: data.expiresAt,
|
|
47
|
+
};
|
|
40
48
|
},
|
|
41
49
|
});
|
|
42
50
|
const revoke = useMutation({
|
package/src/runtime/types.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface Conversation {
|
|
|
93
93
|
* arrives carrying an explicit `null`.
|
|
94
94
|
*/
|
|
95
95
|
agentId?: string | null;
|
|
96
|
+
/** The app the conversation started in, by its slug, or `null` for Alia's own surfaces. */
|
|
97
|
+
app?: string | null;
|
|
96
98
|
createdAt: Date;
|
|
97
99
|
updatedAt: Date;
|
|
98
100
|
messages: Message[];
|
|
@@ -153,10 +153,12 @@ export function useConversationStream({
|
|
|
153
153
|
clientContext,
|
|
154
154
|
hostTools,
|
|
155
155
|
}: UseConversationStreamOptions = {}) {
|
|
156
|
-
const { apiUrl, streamFetch, host } = useAliaRuntime();
|
|
156
|
+
const { apiUrl, streamFetch, host, app } = useAliaRuntime();
|
|
157
157
|
/** Read at send time and when an action arrives: the host's declaration may change between turns. */
|
|
158
158
|
const hostToolsRef = useRef(hostTools);
|
|
159
159
|
hostToolsRef.current = hostTools;
|
|
160
|
+
const appRef = useRef(app);
|
|
161
|
+
appRef.current = app;
|
|
160
162
|
/** This conversation's host actions: performed once, their outcomes held until a request carries them. */
|
|
161
163
|
const hostOutcomesRef = useRef(new HostOutcomeQueue());
|
|
162
164
|
const labels = useLabels();
|
|
@@ -260,9 +262,14 @@ export function useConversationStream({
|
|
|
260
262
|
}, 50);
|
|
261
263
|
}, [flushPendingUpdates]);
|
|
262
264
|
|
|
263
|
-
|
|
265
|
+
/** Whether this thread is on screen: a turn that outlives it ends without `onTurnEnd`. */
|
|
266
|
+
const mountedRef = useRef(false);
|
|
267
|
+
|
|
268
|
+
// Mounted, a turn that ends is announced (`onTurnEnd`); unmounting flushes and clears the timer.
|
|
264
269
|
useEffect(() => {
|
|
270
|
+
mountedRef.current = true;
|
|
265
271
|
return () => {
|
|
272
|
+
mountedRef.current = false;
|
|
266
273
|
flushPendingUpdates();
|
|
267
274
|
if (flushTimerRef.current) {
|
|
268
275
|
clearTimeout(flushTimerRef.current);
|
|
@@ -613,7 +620,7 @@ export function useConversationStream({
|
|
|
613
620
|
|
|
614
621
|
// The host's tools, and the outcomes of the actions it performed since the last request.
|
|
615
622
|
const outcomes = hostOutcomesRef.current.pending();
|
|
616
|
-
const hostMember = hostRequest(hostToolsRef.current, outcomes);
|
|
623
|
+
const hostMember = hostRequest(appRef.current, hostToolsRef.current, outcomes);
|
|
617
624
|
|
|
618
625
|
// Authentication, the preflight refresh and the single 401 replay belong
|
|
619
626
|
// to the linked Oxy client; this layer never reads or writes a bearer.
|
|
@@ -885,7 +892,11 @@ export function useConversationStream({
|
|
|
885
892
|
// the next request. The stream does not wait for it.
|
|
886
893
|
const action = parseHostAction(parsed);
|
|
887
894
|
if (action !== null) {
|
|
888
|
-
void hostOutcomesRef.current.dispatch(
|
|
895
|
+
void hostOutcomesRef.current.dispatch(
|
|
896
|
+
action,
|
|
897
|
+
hostToolsRef.current,
|
|
898
|
+
appRef.current,
|
|
899
|
+
);
|
|
889
900
|
}
|
|
890
901
|
continue;
|
|
891
902
|
}
|
|
@@ -1344,6 +1355,8 @@ export function useConversationStream({
|
|
|
1344
1355
|
* rendered messages, which have not caught up with the last batched flush.
|
|
1345
1356
|
*/
|
|
1346
1357
|
if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
|
|
1358
|
+
// Every way a turn stops streaming — finished, stopped, failed — passes here once.
|
|
1359
|
+
if (mountedRef.current) host.onTurnEnd?.();
|
|
1347
1360
|
}
|
|
1348
1361
|
},
|
|
1349
1362
|
[
|