@alia.onl/sdk 15.8.0 → 19.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 +262 -13
- package/README.md +223 -151
- package/package.json +16 -6
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +4 -4
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +98 -35
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +34 -15
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +141 -0
- package/src/runtime/app-assertion.ts +53 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1433 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* `session.accepted` carries `seq`; a resume replays from `afterSeq`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 2: every transcript and tool event names `messageId`, the message it belongs
|
|
18
|
+
* to as it is stored.
|
|
19
|
+
*/
|
|
20
|
+
export const VOICE_PROTOCOL_VERSION = 2;
|
|
17
21
|
|
|
18
22
|
export const VOICE_SESSION_PATH = '/v1/voice/session';
|
|
19
23
|
|
|
@@ -81,7 +85,8 @@ const CLOSE_REASONS: ReadonlySet<string> = new Set<VoiceCloseReason>([
|
|
|
81
85
|
export type VoiceRole = 'user' | 'assistant';
|
|
82
86
|
|
|
83
87
|
export type VoiceClientEvent =
|
|
84
|
-
|
|
88
|
+
/** `appAssertion`: the host's Oxy app assertion (ADR 0021); a socket cannot carry the header. */
|
|
89
|
+
| { type: 'auth'; token: string; appAssertion?: string }
|
|
85
90
|
| {
|
|
86
91
|
type: 'session.start';
|
|
87
92
|
protocol: number;
|
|
@@ -107,17 +112,20 @@ export type VoiceServerEvent =
|
|
|
107
112
|
| { type: 'input.ready' }
|
|
108
113
|
| { type: 'user.speech_started'; itemId: string }
|
|
109
114
|
| { type: 'user.speech_stopped'; itemId: string }
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
/**
|
|
116
|
+
* `messageId` is the message the text belongs to, the id it is stored and
|
|
117
|
+
* announced under: an assistant turn's items are one message.
|
|
118
|
+
*/
|
|
119
|
+
| { type: 'transcript.delta'; role: VoiceRole; messageId: string; itemId: string; text: string }
|
|
120
|
+
| { type: 'transcript.done'; role: VoiceRole; messageId: string; itemId: string; text: string }
|
|
112
121
|
| { type: 'assistant.audio_done'; itemId: string }
|
|
113
122
|
| {
|
|
114
123
|
type: 'assistant.done';
|
|
115
124
|
itemId: string | null;
|
|
116
125
|
status: 'completed' | 'cancelled' | 'incomplete' | 'failed';
|
|
117
126
|
}
|
|
118
|
-
| { type: 'tool.started'; callId: string; name: string }
|
|
119
|
-
| { type: 'tool.finished'; callId: string; name: string; ok: boolean }
|
|
120
|
-
| { type: 'approval.requested'; toolName: string }
|
|
127
|
+
| { type: 'tool.started'; messageId: string; callId: string; name: string }
|
|
128
|
+
| { type: 'tool.finished'; messageId: string; callId: string; name: string; ok: boolean }
|
|
121
129
|
| { type: 'messages.saved'; conversationId: string; messageIds: string[] }
|
|
122
130
|
| { type: 'session.warning'; code: 'ending-soon'; remainingMs: number }
|
|
123
131
|
| { type: 'session.reconnecting' }
|
|
@@ -196,9 +204,17 @@ export function parseVoiceServerEvent(raw: string): ReceivedVoiceEvent | null {
|
|
|
196
204
|
case 'transcript.delta':
|
|
197
205
|
case 'transcript.done':
|
|
198
206
|
return (event.role === 'user' || event.role === 'assistant') &&
|
|
207
|
+
isString(event.messageId) &&
|
|
199
208
|
isString(event.itemId) &&
|
|
200
209
|
isString(event.text)
|
|
201
|
-
? {
|
|
210
|
+
? {
|
|
211
|
+
type: event.type,
|
|
212
|
+
role: event.role,
|
|
213
|
+
messageId: event.messageId,
|
|
214
|
+
itemId: event.itemId,
|
|
215
|
+
text: event.text,
|
|
216
|
+
...seq,
|
|
217
|
+
}
|
|
202
218
|
: null;
|
|
203
219
|
case 'assistant.done':
|
|
204
220
|
return event.itemId === null || isString(event.itemId)
|
|
@@ -215,23 +231,26 @@ export function parseVoiceServerEvent(raw: string): ReceivedVoiceEvent | null {
|
|
|
215
231
|
}
|
|
216
232
|
: null;
|
|
217
233
|
case 'tool.started':
|
|
218
|
-
return isString(event.callId) && isString(event.name)
|
|
219
|
-
? {
|
|
234
|
+
return isString(event.messageId) && isString(event.callId) && isString(event.name)
|
|
235
|
+
? {
|
|
236
|
+
type: 'tool.started',
|
|
237
|
+
messageId: event.messageId,
|
|
238
|
+
callId: event.callId,
|
|
239
|
+
name: event.name,
|
|
240
|
+
...seq,
|
|
241
|
+
}
|
|
220
242
|
: null;
|
|
221
243
|
case 'tool.finished':
|
|
222
|
-
return isString(event.callId) && isString(event.name)
|
|
244
|
+
return isString(event.messageId) && isString(event.callId) && isString(event.name)
|
|
223
245
|
? {
|
|
224
246
|
type: 'tool.finished',
|
|
247
|
+
messageId: event.messageId,
|
|
225
248
|
callId: event.callId,
|
|
226
249
|
name: event.name,
|
|
227
250
|
ok: event.ok === true,
|
|
228
251
|
...seq,
|
|
229
252
|
}
|
|
230
253
|
: null;
|
|
231
|
-
case 'approval.requested':
|
|
232
|
-
return isString(event.toolName)
|
|
233
|
-
? { type: 'approval.requested', toolName: event.toolName, ...seq }
|
|
234
|
-
: null;
|
|
235
254
|
case 'messages.saved':
|
|
236
255
|
return isString(event.conversationId) && Array.isArray(event.messageIds)
|
|
237
256
|
? {
|
package/src/lib/work-log.ts
CHANGED
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
} from '@oxy.so/bloom/icons';
|
|
9
9
|
import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/task-list';
|
|
10
10
|
import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
|
|
11
|
-
import type { ToolInvocation } from '../types';
|
|
11
|
+
import type { ToolInvocation } from '../runtime/types';
|
|
12
12
|
import { extractCitationSources } from './citations';
|
|
13
13
|
import { domainOf } from './domain';
|
|
14
14
|
import { getToolDoneLabel, getToolPillLabel } from './task-labels';
|
|
15
15
|
import { toolCallStatus } from './tool-call-status';
|
|
16
|
-
import {
|
|
16
|
+
import { englishCopy, type Translate } from './copy';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* A turn's tool calls, as the two Bloom logs draw them.
|
|
@@ -32,7 +32,7 @@ import { englishWorkLog, type Translate } from './work-log-copy';
|
|
|
32
32
|
|
|
33
33
|
/** What a host can swap: the words, and where a site's icon is served from. */
|
|
34
34
|
export interface WorkLogOptions {
|
|
35
|
-
/** Translator for the log's words (see `
|
|
35
|
+
/** Translator for the log's words (see `copy.ts`). Default: English. */
|
|
36
36
|
t?: Translate;
|
|
37
37
|
/** The URL of a site's cached icon, or nothing for Bloom's quiet dot. */
|
|
38
38
|
faviconUrl?: (url: string) => string | null | undefined;
|
|
@@ -154,7 +154,7 @@ function webStep(
|
|
|
154
154
|
live: LiveResearch | undefined,
|
|
155
155
|
options: WorkLogOptions,
|
|
156
156
|
): MappedStep {
|
|
157
|
-
const t = options.t ??
|
|
157
|
+
const t = options.t ?? englishCopy;
|
|
158
158
|
const arrived = inv.state !== 'partial-call';
|
|
159
159
|
const ok = succeeded(inv);
|
|
160
160
|
const res = resultOf(inv);
|
|
@@ -256,7 +256,7 @@ export function webSearchLog(
|
|
|
256
256
|
research: LiveResearch | undefined,
|
|
257
257
|
options: WorkLogOptions = {},
|
|
258
258
|
): WebSearchLog {
|
|
259
|
-
const t = options.t ??
|
|
259
|
+
const t = options.t ?? englishCopy;
|
|
260
260
|
const web = (invocations ?? []).filter(isWebInvocation);
|
|
261
261
|
const mapped = web.map((inv) => webStep(inv, research, options));
|
|
262
262
|
|
|
@@ -333,7 +333,7 @@ export function taskListLog(
|
|
|
333
333
|
live: boolean,
|
|
334
334
|
options: WorkLogOptions = {},
|
|
335
335
|
): TaskListLog {
|
|
336
|
-
const t = options.t ??
|
|
336
|
+
const t = options.t ?? englishCopy;
|
|
337
337
|
let revealed = 0;
|
|
338
338
|
let open = true;
|
|
339
339
|
const tasks = (invocations ?? [])
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { useAliaApi } from './api';
|
|
4
|
+
import { aliaQueryKeys } from './query-keys';
|
|
5
|
+
import { API_ROUTES } from './routes';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The agent a chat is opened on, as `GET /v1/agents/:id/profile` answers it
|
|
9
|
+
* (`docs/agent-embedding.md` §1). Alia herself has no profile: she is the lead when no agent is
|
|
10
|
+
* named.
|
|
11
|
+
*/
|
|
12
|
+
export interface AliaAgentProfile {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
handle?: string;
|
|
16
|
+
/** Its look, as Oxy stores a character (JSON); drawn through `readAgentCharacter`. */
|
|
17
|
+
character?: unknown;
|
|
18
|
+
color?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
/** What the empty chat says; omitted, the chat's own greeting. */
|
|
21
|
+
greeting?: string;
|
|
22
|
+
/** The lines offered over the composer of an empty chat. */
|
|
23
|
+
starters: AliaAgentStarter[];
|
|
24
|
+
access?: 'private' | 'public';
|
|
25
|
+
/** Which of Alia's features this agent gates behind an app's own check. */
|
|
26
|
+
features: Array<{ key: string; gated: boolean }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AliaAgentStarter {
|
|
30
|
+
title: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One of Alia's features, for one agent and the person present (`GET /v1/agents/:id/features`). */
|
|
35
|
+
export interface AliaAgentFeature {
|
|
36
|
+
key: string;
|
|
37
|
+
state: 'available' | 'locked';
|
|
38
|
+
/** How many uses are left, when the publishing app counts them. */
|
|
39
|
+
remaining?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The feature keys the chat itself reads: the attach menu's two rows. */
|
|
43
|
+
export const ALIA_FEATURES = {
|
|
44
|
+
documents: 'attachments.documents',
|
|
45
|
+
images: 'attachments.images',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
const FIVE_MINUTES = 1000 * 60 * 5;
|
|
49
|
+
|
|
50
|
+
/** A string, or nothing: the profile is read field by field rather than cast. */
|
|
51
|
+
const text = (value: unknown): string | undefined =>
|
|
52
|
+
typeof value === 'string' && value !== '' ? value : undefined;
|
|
53
|
+
|
|
54
|
+
/** The profile the server sent, kept only where it has the shape the contract names. */
|
|
55
|
+
export function readAgentProfile(value: unknown): AliaAgentProfile | null {
|
|
56
|
+
if (value === null || typeof value !== 'object') return null;
|
|
57
|
+
const raw = value as Record<string, unknown>;
|
|
58
|
+
const id = text(raw.id);
|
|
59
|
+
const name = text(raw.name);
|
|
60
|
+
if (id === undefined || name === undefined) return null;
|
|
61
|
+
const starters = Array.isArray(raw.starters)
|
|
62
|
+
? raw.starters.flatMap((starter): AliaAgentStarter[] => {
|
|
63
|
+
if (starter === null || typeof starter !== 'object') return [];
|
|
64
|
+
const { title, prompt } = starter as Record<string, unknown>;
|
|
65
|
+
const line = text(prompt);
|
|
66
|
+
return line === undefined ? [] : [{ title: text(title) ?? line, prompt: line }];
|
|
67
|
+
})
|
|
68
|
+
: [];
|
|
69
|
+
const features = Array.isArray(raw.features)
|
|
70
|
+
? raw.features.flatMap((feature): Array<{ key: string; gated: boolean }> => {
|
|
71
|
+
if (feature === null || typeof feature !== 'object') return [];
|
|
72
|
+
const { key, gated } = feature as Record<string, unknown>;
|
|
73
|
+
return typeof key === 'string' ? [{ key, gated: gated === true }] : [];
|
|
74
|
+
})
|
|
75
|
+
: [];
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
name,
|
|
79
|
+
handle: text(raw.handle),
|
|
80
|
+
character: raw.character ?? undefined,
|
|
81
|
+
color: text(raw.color),
|
|
82
|
+
description: text(raw.description),
|
|
83
|
+
greeting: text(raw.greeting),
|
|
84
|
+
starters,
|
|
85
|
+
access: raw.access === 'public' || raw.access === 'private' ? raw.access : undefined,
|
|
86
|
+
features,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The feature states the server sent; an entry of any other shape is not a state. */
|
|
91
|
+
export function readAgentFeatures(value: unknown): AliaAgentFeature[] {
|
|
92
|
+
const list =
|
|
93
|
+
value !== null && typeof value === 'object'
|
|
94
|
+
? (value as { features?: unknown }).features
|
|
95
|
+
: undefined;
|
|
96
|
+
if (!Array.isArray(list)) return [];
|
|
97
|
+
return list.flatMap((feature): AliaAgentFeature[] => {
|
|
98
|
+
if (feature === null || typeof feature !== 'object') return [];
|
|
99
|
+
const { key, state, remaining } = feature as Record<string, unknown>;
|
|
100
|
+
if (typeof key !== 'string' || (state !== 'available' && state !== 'locked')) return [];
|
|
101
|
+
return [{ key, state, ...(typeof remaining === 'number' ? { remaining } : {}) }];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The profile of the agent `agentId`, or nothing for Alia (no id) and while it loads. */
|
|
106
|
+
export function useAgentProfile(agentId: string | undefined) {
|
|
107
|
+
const { isAuthenticated } = useOxy();
|
|
108
|
+
const api = useAliaApi();
|
|
109
|
+
return useQuery({
|
|
110
|
+
queryKey: aliaQueryKeys.agents.profile(agentId ?? ''),
|
|
111
|
+
queryFn: async () =>
|
|
112
|
+
readAgentProfile((await api.get<unknown>(API_ROUTES.agents.profile(agentId ?? ''))).data),
|
|
113
|
+
enabled: agentId !== undefined && isAuthenticated,
|
|
114
|
+
staleTime: FIVE_MINUTES,
|
|
115
|
+
retry: 1,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What of Alia's features the agent `agentId` offers the person present. Alia (no id) gates
|
|
121
|
+
* nothing, so she has no answer to wait for; see `featureRows` for what the chat makes of it.
|
|
122
|
+
*/
|
|
123
|
+
export function useAgentFeatures(agentId: string | undefined) {
|
|
124
|
+
const { isAuthenticated } = useOxy();
|
|
125
|
+
const api = useAliaApi();
|
|
126
|
+
return useQuery({
|
|
127
|
+
queryKey: aliaQueryKeys.agents.features(agentId ?? ''),
|
|
128
|
+
queryFn: async () =>
|
|
129
|
+
readAgentFeatures((await api.get<unknown>(API_ROUTES.agents.features(agentId ?? ''))).data),
|
|
130
|
+
enabled: agentId !== undefined && isAuthenticated,
|
|
131
|
+
// A gate's state moves with the person's plan in another app: read it each time the chat opens.
|
|
132
|
+
staleTime: 0,
|
|
133
|
+
retry: 1,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How an attachment feature is offered: attached, an upsell, or not at all. */
|
|
138
|
+
export type FeatureOffer = 'available' | 'locked' | 'absent';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What the attach menu offers for `key`. Alia's own chat (`features === undefined`) offers every
|
|
142
|
+
* attachment; an agent offers what its feature answer lists — `locked` is still offered, as the
|
|
143
|
+
* host's upsell — and nothing it does not list.
|
|
144
|
+
*/
|
|
145
|
+
export function featureOffer(
|
|
146
|
+
features: readonly AliaAgentFeature[] | undefined,
|
|
147
|
+
key: string,
|
|
148
|
+
): FeatureOffer {
|
|
149
|
+
if (features === undefined) return 'available';
|
|
150
|
+
return features.find((feature) => feature.key === key)?.state ?? 'absent';
|
|
151
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type AppAssertionHeaders,
|
|
5
|
+
NO_APP_ASSERTION,
|
|
6
|
+
useAppAssertionHeaders,
|
|
7
|
+
} from './app-assertion';
|
|
8
|
+
import { useAliaRuntime } from './config';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A refusal or failure from Alia's API, shaped so the helpers in
|
|
12
|
+
* `error-utils.ts` read it: `status`, and the parsed body under
|
|
13
|
+
* `response.data`.
|
|
14
|
+
*/
|
|
15
|
+
export class AliaApiError extends Error {
|
|
16
|
+
readonly status: number;
|
|
17
|
+
readonly response: { status: number; data: unknown };
|
|
18
|
+
|
|
19
|
+
constructor(status: number, data: unknown, message?: string) {
|
|
20
|
+
super(message ?? `Alia request failed with status ${status}.`);
|
|
21
|
+
this.name = 'AliaApiError';
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.response = { status, data };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AliaApiResult<T> {
|
|
28
|
+
status: number;
|
|
29
|
+
data: T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AliaApiRequest {
|
|
33
|
+
params?: Record<string, string | number | undefined>;
|
|
34
|
+
body?: unknown;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Alia's REST API for the signed-in person. Authentication, the preflight
|
|
40
|
+
* refresh and the single 401 replay belong to the linked Oxy client; this
|
|
41
|
+
* layer never reads or writes a bearer itself.
|
|
42
|
+
*/
|
|
43
|
+
export interface AliaApi {
|
|
44
|
+
get<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
45
|
+
post<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
46
|
+
patch<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
47
|
+
delete<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function withParams(path: string, params: AliaApiRequest['params']): string {
|
|
51
|
+
if (!params) return path;
|
|
52
|
+
const search = new URLSearchParams();
|
|
53
|
+
for (const [key, value] of Object.entries(params)) {
|
|
54
|
+
if (value !== undefined) search.append(key, String(value));
|
|
55
|
+
}
|
|
56
|
+
const query = search.toString();
|
|
57
|
+
return query === '' ? path : `${path}${path.includes('?') ? '&' : '?'}${query}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readBody(response: Response): Promise<unknown> {
|
|
61
|
+
const text = await response.text().catch(() => '');
|
|
62
|
+
if (text === '') return undefined;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The structural slice of `OxyServices` the API layer needs. */
|
|
71
|
+
export interface LinkableOxy {
|
|
72
|
+
createLinkedClient(config: { baseURL: string }): {
|
|
73
|
+
client: {
|
|
74
|
+
requestAuthenticatedResponse(config: {
|
|
75
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
76
|
+
url: string;
|
|
77
|
+
body?: string;
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
fetch?: typeof fetch;
|
|
81
|
+
}): Promise<Response>;
|
|
82
|
+
};
|
|
83
|
+
dispose(): void;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the API over an Oxy client for `apiUrl`. Exposed for tests and for non-React callers.
|
|
89
|
+
*
|
|
90
|
+
* @param appAssertionHeaders the host's `X-Oxy-App-Assertion` (`app-assertion.ts`), on every request
|
|
91
|
+
*/
|
|
92
|
+
export function createAliaApi(
|
|
93
|
+
oxyServices: LinkableOxy,
|
|
94
|
+
apiUrl: string,
|
|
95
|
+
appAssertionHeaders: AppAssertionHeaders = NO_APP_ASSERTION,
|
|
96
|
+
): AliaApi {
|
|
97
|
+
async function send<T>(
|
|
98
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
99
|
+
path: string,
|
|
100
|
+
request: AliaApiRequest = {},
|
|
101
|
+
): Promise<AliaApiResult<T>> {
|
|
102
|
+
const assertion = await appAssertionHeaders();
|
|
103
|
+
const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
104
|
+
try {
|
|
105
|
+
const headers = {
|
|
106
|
+
...assertion,
|
|
107
|
+
...(request.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
108
|
+
};
|
|
109
|
+
const response = await linked.client.requestAuthenticatedResponse({
|
|
110
|
+
method,
|
|
111
|
+
url: withParams(path, request.params),
|
|
112
|
+
...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
|
|
113
|
+
...(Object.keys(headers).length === 0 ? {} : { headers }),
|
|
114
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
115
|
+
});
|
|
116
|
+
const data = await readBody(response);
|
|
117
|
+
if (!response.ok) throw new AliaApiError(response.status, data);
|
|
118
|
+
return { status: response.status, data: data as T };
|
|
119
|
+
} finally {
|
|
120
|
+
linked.dispose();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
get: (path, request) => send('GET', path, request),
|
|
126
|
+
post: (path, body, request) => send('POST', path, { ...request, body: body ?? {} }),
|
|
127
|
+
patch: (path, body, request) => send('PATCH', path, { ...request, body: body ?? {} }),
|
|
128
|
+
delete: (path, request) => send('DELETE', path, request),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Alia's REST API for the nearest runtime's `apiUrl`, as the signed-in person. */
|
|
133
|
+
export function useAliaApi(): AliaApi {
|
|
134
|
+
const { oxyServices } = useOxy();
|
|
135
|
+
const { apiUrl } = useAliaRuntime();
|
|
136
|
+
const appAssertionHeaders = useAppAssertionHeaders();
|
|
137
|
+
return useMemo(
|
|
138
|
+
() => createAliaApi(oxyServices as LinkableOxy, apiUrl, appAssertionHeaders),
|
|
139
|
+
[oxyServices, apiUrl, appAssertionHeaders],
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `@oxy.so/services/ui`, not the root barrel (which ships the sign-in panels, `check:entries`):
|
|
2
|
+
// `ui/client` does not export `useAppAssertion`.
|
|
3
|
+
import { useAppAssertion } from '@oxy.so/services/ui';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which Oxy app this conversation is in, for Alia (Alia ADR 0021, Oxy ADR 0045).
|
|
8
|
+
*
|
|
9
|
+
* An Oxy app may confine its own agent to itself — Clarity's assistant opens
|
|
10
|
+
* only inside Clarity — and the official apps share one session that names no
|
|
11
|
+
* app, so Alia reads the app from the Oxy-signed assertion the host's
|
|
12
|
+
* `OxyProvider` obtains and keeps current. Every request the SDK makes to Alia
|
|
13
|
+
* carries it as `X-Oxy-App-Assertion` when there is one; without one (an app
|
|
14
|
+
* Oxy cannot recognise yet, a signed-out person) nothing is sent, and only a
|
|
15
|
+
* confined agent is affected.
|
|
16
|
+
*
|
|
17
|
+
* The SDK never builds, stores or inspects the assertion: it is the host's
|
|
18
|
+
* provider's, read at send time so it is never stale.
|
|
19
|
+
*/
|
|
20
|
+
export type AppAssertionHeaders = () => Promise<Record<string, string>>;
|
|
21
|
+
|
|
22
|
+
/** No app assertion: what a caller outside a React tree, or a test, passes. */
|
|
23
|
+
export const NO_APP_ASSERTION: AppAssertionHeaders = async () => ({});
|
|
24
|
+
|
|
25
|
+
/** The host's app assertion as request headers, `{}` when it has none or cannot get one. */
|
|
26
|
+
export function useAppAssertionHeaders(): AppAssertionHeaders {
|
|
27
|
+
const { getAppAssertionHeaders } = useAppAssertion();
|
|
28
|
+
return useMemo(
|
|
29
|
+
() => async () => {
|
|
30
|
+
try {
|
|
31
|
+
return await getAppAssertionHeaders();
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[getAppAssertionHeaders],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The host's app assertion itself, for the voice socket, which cannot carry a header. */
|
|
41
|
+
export function useAppAssertionToken(): () => Promise<string | null> {
|
|
42
|
+
const { getAppAssertion } = useAppAssertion();
|
|
43
|
+
return useMemo(
|
|
44
|
+
() => async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await getAppAssertion();
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
[getAppAssertion],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AliaAttachment } from '../types';
|
|
2
|
+
|
|
3
|
+
export type MessageContentPart =
|
|
4
|
+
| { type: 'text'; text: string }
|
|
5
|
+
| { type: 'image_url'; image_url: { url: string } }
|
|
6
|
+
| { type: 'file'; file: { file_id: string; filename: string; mime: string } };
|
|
7
|
+
|
|
8
|
+
export interface BuiltMessageContent {
|
|
9
|
+
content: string | MessageContentPart[];
|
|
10
|
+
/**
|
|
11
|
+
* Attachments the request does NOT carry, so the caller can say so.
|
|
12
|
+
*
|
|
13
|
+
* Never empty silently: everything here was visibly attached in the composer,
|
|
14
|
+
* and a file that appears attached and then is not sent is the failure #608
|
|
15
|
+
* §1.6 is about — a visible state that does not correspond to a real
|
|
16
|
+
* capability.
|
|
17
|
+
*/
|
|
18
|
+
dropped: DroppedAttachment[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DroppedAttachment {
|
|
22
|
+
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* `unsupported` — the chat has no way to send a document (no upload was given).
|
|
25
|
+
* `unreadable` — an image whose bytes could not be read off the device, or a
|
|
26
|
+
* document that could not be uploaded.
|
|
27
|
+
*/
|
|
28
|
+
reason: 'unsupported' | 'unreadable';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Puts a document in the person's Oxy Files and answers its id. */
|
|
32
|
+
export type UploadDocument = (attachment: AliaAttachment) => Promise<string>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The request body for a turn that carries attachments, and an account of what
|
|
36
|
+
* it could not carry.
|
|
37
|
+
*
|
|
38
|
+
* A photo travels inline, as an `image_url` part with its `data:` URL. A
|
|
39
|
+
* document travels as a `file` part naming an Oxy Files id
|
|
40
|
+
* (`docs/agent-embedding.md` §5): it is uploaded to the person's Files first,
|
|
41
|
+
* with `upload`, and a chat given no `upload` cannot send documents and says so.
|
|
42
|
+
*/
|
|
43
|
+
export async function buildMessageContent(
|
|
44
|
+
text: string,
|
|
45
|
+
attachments: AliaAttachment[],
|
|
46
|
+
upload?: UploadDocument,
|
|
47
|
+
): Promise<BuiltMessageContent> {
|
|
48
|
+
const dropped: DroppedAttachment[] = [];
|
|
49
|
+
|
|
50
|
+
const usable = attachments.filter((att) => {
|
|
51
|
+
if (!att.uri) return false;
|
|
52
|
+
if (att.type !== 'image' && upload === undefined) {
|
|
53
|
+
dropped.push({ name: att.name, reason: 'unsupported' });
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (usable.length === 0) return { content: text, dropped };
|
|
60
|
+
|
|
61
|
+
const parts: MessageContentPart[] = [{ type: 'text', text }];
|
|
62
|
+
|
|
63
|
+
// The readers need the platform's file access, loaded on the first attachment: a turn of plain
|
|
64
|
+
// text (and everything that imports this module) never touches it.
|
|
65
|
+
const { imageUriToDataUrl } = await import('../lib/image-data-url');
|
|
66
|
+
for (const att of usable) {
|
|
67
|
+
if (att.type !== 'image' && upload !== undefined) {
|
|
68
|
+
try {
|
|
69
|
+
const fileId = await upload(att);
|
|
70
|
+
parts.push({
|
|
71
|
+
type: 'file',
|
|
72
|
+
file: { file_id: fileId, filename: att.name, mime: att.mimeType },
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
dropped.push({ name: att.name, reason: 'unreadable' });
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
parts.push({
|
|
81
|
+
type: 'image_url',
|
|
82
|
+
image_url: { url: await imageUriToDataUrl(att.uri, att.mimeType) },
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
// Reported rather than skipped. A picture that did not arrive is worth
|
|
86
|
+
// a sentence; it used to be a `continue`.
|
|
87
|
+
dropped.push({ name: att.name, reason: 'unreadable' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every attachment failed, so the parts array is a text part and nothing
|
|
92
|
+
// else — which is just the text, said in a more expensive way.
|
|
93
|
+
if (parts.length === 1) return { content: text, dropped };
|
|
94
|
+
|
|
95
|
+
return { content: parts, dropped };
|
|
96
|
+
}
|