@alia.onl/sdk 15.7.0 → 19.0.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 +255 -12
- package/README.md +241 -155
- package/package.json +17 -5
- 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/csp.ts +50 -0
- package/src/hooks/useAliaActivity.ts +3 -3
- 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/useDictation.ts +2 -3
- 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/useTTS.ts +2 -3
- 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 +2 -6
- 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/character-runtime.ts +6 -4
- 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/origins.ts +15 -0
- 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-audio-engine.ts +14 -158
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/voice-worklets.ts +19 -0
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -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 +1427 -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/worklets/voice-processors.js +152 -0
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/api-url.ts +0 -2
- 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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useAliaRuntime } from './config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A refusal or failure from Alia's API, shaped so the helpers in
|
|
7
|
+
* `error-utils.ts` read it: `status`, and the parsed body under
|
|
8
|
+
* `response.data`.
|
|
9
|
+
*/
|
|
10
|
+
export class AliaApiError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly response: { status: number; data: unknown };
|
|
13
|
+
|
|
14
|
+
constructor(status: number, data: unknown, message?: string) {
|
|
15
|
+
super(message ?? `Alia request failed with status ${status}.`);
|
|
16
|
+
this.name = 'AliaApiError';
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.response = { status, data };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AliaApiResult<T> {
|
|
23
|
+
status: number;
|
|
24
|
+
data: T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AliaApiRequest {
|
|
28
|
+
params?: Record<string, string | number | undefined>;
|
|
29
|
+
body?: unknown;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Alia's REST API for the signed-in person. Authentication, the preflight
|
|
35
|
+
* refresh and the single 401 replay belong to the linked Oxy client; this
|
|
36
|
+
* layer never reads or writes a bearer itself.
|
|
37
|
+
*/
|
|
38
|
+
export interface AliaApi {
|
|
39
|
+
get<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
40
|
+
post<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
41
|
+
patch<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
42
|
+
delete<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withParams(path: string, params: AliaApiRequest['params']): string {
|
|
46
|
+
if (!params) return path;
|
|
47
|
+
const search = new URLSearchParams();
|
|
48
|
+
for (const [key, value] of Object.entries(params)) {
|
|
49
|
+
if (value !== undefined) search.append(key, String(value));
|
|
50
|
+
}
|
|
51
|
+
const query = search.toString();
|
|
52
|
+
return query === '' ? path : `${path}${path.includes('?') ? '&' : '?'}${query}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readBody(response: Response): Promise<unknown> {
|
|
56
|
+
const text = await response.text().catch(() => '');
|
|
57
|
+
if (text === '') return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The structural slice of `OxyServices` the API layer needs. */
|
|
66
|
+
export interface LinkableOxy {
|
|
67
|
+
createLinkedClient(config: { baseURL: string }): {
|
|
68
|
+
client: {
|
|
69
|
+
requestAuthenticatedResponse(config: {
|
|
70
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
71
|
+
url: string;
|
|
72
|
+
body?: string;
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
}): Promise<Response>;
|
|
77
|
+
};
|
|
78
|
+
dispose(): void;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the API over an Oxy client for `apiUrl`. Exposed for tests and for non-React callers. */
|
|
83
|
+
export function createAliaApi(oxyServices: LinkableOxy, apiUrl: string): AliaApi {
|
|
84
|
+
async function send<T>(
|
|
85
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
86
|
+
path: string,
|
|
87
|
+
request: AliaApiRequest = {},
|
|
88
|
+
): Promise<AliaApiResult<T>> {
|
|
89
|
+
const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
90
|
+
try {
|
|
91
|
+
const response = await linked.client.requestAuthenticatedResponse({
|
|
92
|
+
method,
|
|
93
|
+
url: withParams(path, request.params),
|
|
94
|
+
...(request.body === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: {
|
|
97
|
+
body: JSON.stringify(request.body),
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
}),
|
|
100
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
101
|
+
});
|
|
102
|
+
const data = await readBody(response);
|
|
103
|
+
if (!response.ok) throw new AliaApiError(response.status, data);
|
|
104
|
+
return { status: response.status, data: data as T };
|
|
105
|
+
} finally {
|
|
106
|
+
linked.dispose();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
get: (path, request) => send('GET', path, request),
|
|
112
|
+
post: (path, body, request) => send('POST', path, { ...request, body: body ?? {} }),
|
|
113
|
+
patch: (path, body, request) => send('PATCH', path, { ...request, body: body ?? {} }),
|
|
114
|
+
delete: (path, request) => send('DELETE', path, request),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Alia's REST API for the nearest runtime's `apiUrl`, as the signed-in person. */
|
|
119
|
+
export function useAliaApi(): AliaApi {
|
|
120
|
+
const { oxyServices } = useOxy();
|
|
121
|
+
const { apiUrl } = useAliaRuntime();
|
|
122
|
+
return useMemo(() => createAliaApi(oxyServices as LinkableOxy, apiUrl), [oxyServices, apiUrl]);
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { create } from 'zustand';
|
|
3
|
+
import type { AliaAttachment } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a composer is holding before it is sent: the text, the attachments and
|
|
7
|
+
* the turn's own choices (connector, skills). One record per account and
|
|
8
|
+
* composer, and the only place any of it lives.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this replaced three owners
|
|
11
|
+
*
|
|
12
|
+
* The text was `useState` inside each mounted chat screen, the attachments
|
|
13
|
+
* were ONE global list in `global-store.ts`, and a draft handed from elsewhere
|
|
14
|
+
* (a failed send, the memory settings) travelled through a third slot,
|
|
15
|
+
* `composerDraft`, that every mounted screen read on every render and matched
|
|
16
|
+
* against its own id with a sequence number. The drawer keeps each visited
|
|
17
|
+
* chat mounted, so the attachment list was on every one of them at once — a
|
|
18
|
+
* picture added in one chat was sitting in the composer of the next — and
|
|
19
|
+
* nothing reset any of it when the account changed, so profile B was handed
|
|
20
|
+
* profile A's half-written message and files (#608 §4).
|
|
21
|
+
*
|
|
22
|
+
* Now each composer names its draft by {@link DraftTarget}, reads only that
|
|
23
|
+
* one, and a hand-off is a write to the target's record: there is nothing to
|
|
24
|
+
* broadcast and nothing to "consume".
|
|
25
|
+
*
|
|
26
|
+
* ## Accounts
|
|
27
|
+
*
|
|
28
|
+
* The store holds the drafts of ONE account, the one bound by the app layout
|
|
29
|
+
* (`bindAccount`). Moving to a different account, or signing out, drops every
|
|
30
|
+
* draft and releases what their attachments were holding. The one exception
|
|
31
|
+
* is signing IN from signed-out: the drafts carry over, because they were
|
|
32
|
+
* typed by the person now signing in — "continue without an account" leaves
|
|
33
|
+
* a draft that must still be there after the sign-in its send opened (#608
|
|
34
|
+
* §3.2).
|
|
35
|
+
*
|
|
36
|
+
* Every write names the account it was made for ({@link DraftAddress}). A
|
|
37
|
+
* write for an account that is no longer bound — a send that fails after the
|
|
38
|
+
* switch, a picker that resolves after it — is refused and its attachments
|
|
39
|
+
* released, so a slow answer for A cannot land in B's composer.
|
|
40
|
+
*
|
|
41
|
+
* ## Not persisted
|
|
42
|
+
*
|
|
43
|
+
* A picture is a `data:` or `blob:` URL; writing megabytes of base64 into
|
|
44
|
+
* AsyncStorage on every keystroke is not a draft feature worth its cost, and a
|
|
45
|
+
* `blob:` URL does not survive a reload anyway.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which composer: a conversation's id, `null` for the new-chat screen (ghost
|
|
50
|
+
* included — it is the same screen), or a `surface:` name for a screen that
|
|
51
|
+
* asks Alia something outside a chat.
|
|
52
|
+
*/
|
|
53
|
+
export type DraftTarget = string | null;
|
|
54
|
+
|
|
55
|
+
/** A draft's owner at the time the address was taken. */
|
|
56
|
+
export interface DraftAddress {
|
|
57
|
+
account: string | null;
|
|
58
|
+
target: DraftTarget;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ComposerDraft {
|
|
62
|
+
text: string;
|
|
63
|
+
attachments: AliaAttachment[];
|
|
64
|
+
/** The connector chosen for the next turn. */
|
|
65
|
+
mcpServerId: string | null;
|
|
66
|
+
/** The skills chosen for the next turn. */
|
|
67
|
+
skillNames: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Set while the composer is rewriting a turn already sent rather than
|
|
70
|
+
* writing a new one: its send replaces that turn and everything after it.
|
|
71
|
+
* Absent for an ordinary draft.
|
|
72
|
+
*/
|
|
73
|
+
editing?: DraftEdit;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type DraftTurn = Pick<ComposerDraft, 'mcpServerId' | 'skillNames'>;
|
|
77
|
+
|
|
78
|
+
/** What the composer held before an edit took it over, for a cancel to give back. */
|
|
79
|
+
export type DraftBeforeEdit = Pick<ComposerDraft, 'text' | 'mcpServerId' | 'skillNames'>;
|
|
80
|
+
|
|
81
|
+
export interface DraftEdit {
|
|
82
|
+
/** The user turn being rewritten. */
|
|
83
|
+
messageId: string;
|
|
84
|
+
before: DraftBeforeEdit;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The turn an edit loads into the composer: its words and the options it was sent with. */
|
|
88
|
+
export interface EditedTurn extends DraftTurn {
|
|
89
|
+
messageId: string;
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const EMPTY_DRAFT: ComposerDraft = Object.freeze({
|
|
94
|
+
text: '',
|
|
95
|
+
attachments: [],
|
|
96
|
+
mcpServerId: null,
|
|
97
|
+
skillNames: [],
|
|
98
|
+
}) as ComposerDraft;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Give back a temporary URL, if this one is temporary.
|
|
102
|
+
*
|
|
103
|
+
* On web both pickers — and a dropped document — hand back
|
|
104
|
+
* `URL.createObjectURL(file)`, which pins the file's bytes in the page until
|
|
105
|
+
* it is revoked. `data:` and `file://` URLs are plain strings: nothing to give
|
|
106
|
+
* back.
|
|
107
|
+
*/
|
|
108
|
+
export function releaseAttachmentUri(uri: string | undefined): void {
|
|
109
|
+
if (uri === undefined || !uri.startsWith('blob:')) return;
|
|
110
|
+
if (typeof URL === 'undefined' || typeof URL.revokeObjectURL !== 'function') return;
|
|
111
|
+
URL.revokeObjectURL(uri);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function releaseAttachments(attachments: readonly AliaAttachment[]): void {
|
|
115
|
+
for (const attachment of attachments) releaseAttachmentUri(attachment.uri);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const keyOf = (target: DraftTarget) => target ?? '';
|
|
119
|
+
|
|
120
|
+
interface DraftStoreState {
|
|
121
|
+
/** The account whose drafts these are; `null` while signed out. */
|
|
122
|
+
account: string | null;
|
|
123
|
+
drafts: Record<string, ComposerDraft>;
|
|
124
|
+
|
|
125
|
+
bindAccount: (userId: string | null) => void;
|
|
126
|
+
/** The address of `target` under the account bound now. */
|
|
127
|
+
address: (target: DraftTarget) => DraftAddress;
|
|
128
|
+
|
|
129
|
+
setText: (address: DraftAddress, text: string) => void;
|
|
130
|
+
addAttachment: (address: DraftAddress, attachment: AliaAttachment) => void;
|
|
131
|
+
/** Takes the tile away and releases its URL. */
|
|
132
|
+
removeAttachment: (address: DraftAddress, id: string) => void;
|
|
133
|
+
/** Change the turn's connector and skills, from what the draft holds now. */
|
|
134
|
+
updateTurn: (address: DraftAddress, change: (turn: DraftTurn) => DraftTurn) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Put a draft back — a send that failed, or text another screen hands to a
|
|
137
|
+
* composer. The fields given replace the draft's; attachments given are put
|
|
138
|
+
* back in front of any added since, which are kept.
|
|
139
|
+
*/
|
|
140
|
+
restore: (address: DraftAddress, draft: Partial<ComposerDraft>) => void;
|
|
141
|
+
/**
|
|
142
|
+
* Empty the draft for a send. The attachments are NOT released: the send
|
|
143
|
+
* still needs them, and a failed one hands them back through `restore`.
|
|
144
|
+
*/
|
|
145
|
+
clear: (address: DraftAddress) => void;
|
|
146
|
+
/**
|
|
147
|
+
* Load a sent turn into the composer to be rewritten. What the composer held
|
|
148
|
+
* is kept aside (attachments stay where they are), and a second edit started
|
|
149
|
+
* before the first is sent or cancelled keeps the ORIGINAL draft aside, not
|
|
150
|
+
* the first edit's text.
|
|
151
|
+
*/
|
|
152
|
+
startEdit: (address: DraftAddress, turn: EditedTurn) => void;
|
|
153
|
+
/** Leave edit mode and give the composer back what it held before. */
|
|
154
|
+
cancelEdit: (address: DraftAddress) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const useComposerDraftStore = create<DraftStoreState>((set, get) => {
|
|
158
|
+
/** Apply `change` to the target's draft, if the address is still current. */
|
|
159
|
+
const write = (
|
|
160
|
+
address: DraftAddress,
|
|
161
|
+
change: (draft: ComposerDraft) => ComposerDraft | null,
|
|
162
|
+
rejected?: () => void,
|
|
163
|
+
) => {
|
|
164
|
+
if (address.account !== get().account) {
|
|
165
|
+
rejected?.();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
set((state) => {
|
|
169
|
+
const key = keyOf(address.target);
|
|
170
|
+
const next = change(state.drafts[key] ?? EMPTY_DRAFT);
|
|
171
|
+
const drafts = { ...state.drafts };
|
|
172
|
+
if (next === null) delete drafts[key];
|
|
173
|
+
else drafts[key] = next;
|
|
174
|
+
return { drafts };
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
account: null,
|
|
180
|
+
drafts: {},
|
|
181
|
+
|
|
182
|
+
bindAccount: (userId) => {
|
|
183
|
+
const { account, drafts } = get();
|
|
184
|
+
if (userId === account) return;
|
|
185
|
+
// Signed out -> signed in: the same person, carrying what they typed.
|
|
186
|
+
if (account === null) {
|
|
187
|
+
set({ account: userId });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const draft of Object.values(drafts)) releaseAttachments(draft.attachments);
|
|
191
|
+
set({ account: userId, drafts: {} });
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
address: (target) => ({ account: get().account, target }),
|
|
195
|
+
|
|
196
|
+
setText: (address, text) => write(address, (draft) => ({ ...draft, text })),
|
|
197
|
+
|
|
198
|
+
addAttachment: (address, attachment) =>
|
|
199
|
+
write(
|
|
200
|
+
address,
|
|
201
|
+
(draft) => ({ ...draft, attachments: [...draft.attachments, attachment] }),
|
|
202
|
+
() => releaseAttachmentUri(attachment.uri),
|
|
203
|
+
),
|
|
204
|
+
|
|
205
|
+
removeAttachment: (address, id) =>
|
|
206
|
+
write(address, (draft) => {
|
|
207
|
+
const removed = draft.attachments.find((a) => a.id === id);
|
|
208
|
+
if (removed === undefined) return draft;
|
|
209
|
+
releaseAttachmentUri(removed.uri);
|
|
210
|
+
return { ...draft, attachments: draft.attachments.filter((a) => a.id !== id) };
|
|
211
|
+
}),
|
|
212
|
+
|
|
213
|
+
updateTurn: (address, change) =>
|
|
214
|
+
write(address, (draft) => ({
|
|
215
|
+
...draft,
|
|
216
|
+
...change({ mcpServerId: draft.mcpServerId, skillNames: draft.skillNames }),
|
|
217
|
+
})),
|
|
218
|
+
|
|
219
|
+
restore: (address, restored) =>
|
|
220
|
+
write(
|
|
221
|
+
address,
|
|
222
|
+
(draft) => {
|
|
223
|
+
const returning = restored.attachments ?? [];
|
|
224
|
+
const ids = new Set(returning.map((a) => a.id));
|
|
225
|
+
return {
|
|
226
|
+
...draft,
|
|
227
|
+
...restored,
|
|
228
|
+
attachments: [...returning, ...draft.attachments.filter((a) => !ids.has(a.id))],
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
() => releaseAttachments(restored.attachments ?? []),
|
|
232
|
+
),
|
|
233
|
+
|
|
234
|
+
clear: (address) => write(address, () => null),
|
|
235
|
+
|
|
236
|
+
startEdit: (address, turn) =>
|
|
237
|
+
write(address, (draft) => ({
|
|
238
|
+
...draft,
|
|
239
|
+
text: turn.text,
|
|
240
|
+
mcpServerId: turn.mcpServerId,
|
|
241
|
+
skillNames: turn.skillNames,
|
|
242
|
+
editing: {
|
|
243
|
+
messageId: turn.messageId,
|
|
244
|
+
before: draft.editing?.before ?? {
|
|
245
|
+
text: draft.text,
|
|
246
|
+
mcpServerId: draft.mcpServerId,
|
|
247
|
+
skillNames: draft.skillNames,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
})),
|
|
251
|
+
|
|
252
|
+
cancelEdit: (address) =>
|
|
253
|
+
write(address, (draft) => {
|
|
254
|
+
if (draft.editing === undefined) return draft;
|
|
255
|
+
const { editing, ...rest } = draft;
|
|
256
|
+
return { ...rest, ...editing.before };
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/** The draft of `target`, as a component reads it. */
|
|
262
|
+
export function useComposerDraft(target: DraftTarget): ComposerDraft {
|
|
263
|
+
return useComposerDraftStore((state) => state.drafts[keyOf(target)] ?? EMPTY_DRAFT);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The address of `target` under the account bound now, as a component takes it. */
|
|
267
|
+
export function useDraftAddress(target: DraftTarget): DraftAddress {
|
|
268
|
+
const account = useComposerDraftStore((state) => state.account);
|
|
269
|
+
return useMemo(() => ({ account, target }), [account, target]);
|
|
270
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
2
|
+
import { ALIA_API_URL } from '../lib/origins';
|
|
3
|
+
import type { AgentRun, Message } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a signed-out session keeps its conversations, per account. The host
|
|
7
|
+
* binds it to the signed-in account; unbound it reads nothing and writes
|
|
8
|
+
* nothing. `AccountScopedKey` in the Alia app is one.
|
|
9
|
+
*/
|
|
10
|
+
export interface OfflineConversationStore {
|
|
11
|
+
getItem(): Promise<string | null>;
|
|
12
|
+
setItem(value: string): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** An artifact a tool produced (a generated file, a canvas), for the host to show beside the thread. */
|
|
16
|
+
export interface AliaArtifact {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
content: unknown;
|
|
20
|
+
title: string;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The usage frame a stream ends with: the balance left and, sometimes, a spending warning. */
|
|
25
|
+
export interface AliaUsageFrame {
|
|
26
|
+
credits_remaining?: number;
|
|
27
|
+
credit_warning?: {
|
|
28
|
+
level?: string;
|
|
29
|
+
daysRemaining?: unknown;
|
|
30
|
+
todaySpend?: unknown;
|
|
31
|
+
avgDailySpend?: unknown;
|
|
32
|
+
currentModelMultiplier?: unknown;
|
|
33
|
+
};
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the request carries that the host's own controls decide, read at send time. */
|
|
38
|
+
export interface AliaTurnRequest {
|
|
39
|
+
/** Only `false` is sent: `true` is the server's default. */
|
|
40
|
+
webSearch?: boolean;
|
|
41
|
+
deepResearch?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Why the thread asks its host to show the thought panel. */
|
|
45
|
+
export type ThoughtOpening =
|
|
46
|
+
/** The reader pressed a turn's "worked for" line or a sources row. */
|
|
47
|
+
| 'pressed'
|
|
48
|
+
/** A running turn began using tools; offered only while the host has nothing else open. */
|
|
49
|
+
| 'automatic';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How the person speaks and likes to be spoken to: read-aloud, dictation and calls all use it.
|
|
53
|
+
* Each is the server's or the platform's own default when absent.
|
|
54
|
+
*/
|
|
55
|
+
export interface SpeechPreferences {
|
|
56
|
+
voice?: 'male' | 'female';
|
|
57
|
+
tone?: 'brief' | 'chill' | 'default';
|
|
58
|
+
/** The language spoken and dictated, a BCP 47 tag; the platform's own when absent. */
|
|
59
|
+
language?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Everything the conversation runtime needs from the app around it, as typed
|
|
64
|
+
* callbacks. Every member is optional: a host that omits one gets the plain
|
|
65
|
+
* behaviour (no navigation, no artifacts panel, no credits). The Alia app and
|
|
66
|
+
* the sheet mount the SAME runtime and differ only in what they put here.
|
|
67
|
+
*/
|
|
68
|
+
export interface AliaRuntimeHost {
|
|
69
|
+
/** Device facts sent as `X-Device-Info`, so the model's `getDeviceInfo` tool can answer. */
|
|
70
|
+
deviceInfo?: () => Promise<unknown>;
|
|
71
|
+
/** The modes the host's composer controls own; read when the turn is sent, not when it is built. */
|
|
72
|
+
turnRequest?: () => AliaTurnRequest;
|
|
73
|
+
/** A turn is about to be sent from the composer or a retry. */
|
|
74
|
+
onTurnStart?: () => void;
|
|
75
|
+
/** Real answer text just arrived (throttled): a haptic tick. */
|
|
76
|
+
onOutput?: () => void;
|
|
77
|
+
/** The stream's usage frame: credits left and the spending warning. */
|
|
78
|
+
onUsage?: (usage: AliaUsageFrame) => void;
|
|
79
|
+
/** A tool finished; `name` is the tool, `output` its structural result. */
|
|
80
|
+
onToolResult?: (result: {
|
|
81
|
+
toolCallId: string;
|
|
82
|
+
name: string | undefined;
|
|
83
|
+
output: unknown;
|
|
84
|
+
}) => void;
|
|
85
|
+
/** A tool produced something worth its own panel. */
|
|
86
|
+
onArtifact?: (artifact: AliaArtifact) => void;
|
|
87
|
+
/** What this turn put in the context window (`alia.context`), raw. */
|
|
88
|
+
onContext?: (conversationId: string | null, payload: unknown) => void;
|
|
89
|
+
/**
|
|
90
|
+
* An agent's feature gate refused what the person tried (`feature_entitlement_required`, or a
|
|
91
|
+
* locked row of the attach menu): `key` is the feature, e.g. `attachments.documents`. The host
|
|
92
|
+
* shows its own upsell; the person's words stay in the composer.
|
|
93
|
+
*/
|
|
94
|
+
onFeatureLocked?: (key: string) => void;
|
|
95
|
+
/** A delegated agent started a run of its own for this turn. */
|
|
96
|
+
onAgentTurn?: (turn: AgentRun & { conversationId: string | null }) => void;
|
|
97
|
+
/** The agent's row in the host's list, ahead of the server: your line, then her answer. */
|
|
98
|
+
onTurnPreview?: (agentId: string | null | undefined, text: string) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Messages an agent writes into the open conversation by itself (a result, a check-in).
|
|
101
|
+
* Returns the unsubscribe.
|
|
102
|
+
*/
|
|
103
|
+
subscribeConversationMessages?: (
|
|
104
|
+
conversationId: string,
|
|
105
|
+
listener: (message: Message) => void,
|
|
106
|
+
) => () => void;
|
|
107
|
+
/**
|
|
108
|
+
* Show where the work of a turn is read — the host's right panel — now that the thread selected
|
|
109
|
+
* one (`useThoughtStore`; the panel is `ThoughtPanel`). Return `false` to decline an
|
|
110
|
+
* `automatic` opening because something else is open. A host without it has no thought panel:
|
|
111
|
+
* the thread draws the "worked for" line as text and never opens one.
|
|
112
|
+
*/
|
|
113
|
+
showThought?: (opening: ThoughtOpening) => boolean;
|
|
114
|
+
/** A scheduled-task card in the thread was pressed: show that task. Omitted, the card is not pressable. */
|
|
115
|
+
openAutomation?: (automationId: string) => void;
|
|
116
|
+
/** The URL of a site's cached icon for the work log's sources, or nothing for Bloom's quiet dot. */
|
|
117
|
+
faviconUrl?: (url: string) => string | null | undefined;
|
|
118
|
+
/** The runtime created or chose a conversation: show it. Omitted, the host shows nothing new. */
|
|
119
|
+
openConversation?: (conversationId: string) => void;
|
|
120
|
+
/** The open conversation is gone (deleted elsewhere, or a first send failed): leave it. */
|
|
121
|
+
closeConversation?: () => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AliaRuntimeConfig {
|
|
125
|
+
/** Alia's API origin. Every route the SDK calls hangs off it, under `/v1` (`routes.ts`). */
|
|
126
|
+
apiUrl: string;
|
|
127
|
+
/** The offline copy of the conversations, when the host keeps one. */
|
|
128
|
+
offline: OfflineConversationStore | null;
|
|
129
|
+
/**
|
|
130
|
+
* The `fetch` a turn is streamed with. React Native's own cannot stream a
|
|
131
|
+
* body; Expo's (`expo/fetch`) can. Omitted, the platform's.
|
|
132
|
+
*/
|
|
133
|
+
streamFetch: typeof fetch | undefined;
|
|
134
|
+
/** How read-aloud, dictation and calls speak; omitted, the server's and the platform's defaults. */
|
|
135
|
+
speech: SpeechPreferences;
|
|
136
|
+
host: AliaRuntimeHost;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AliaRuntimeProviderProps extends Partial<Omit<AliaRuntimeConfig, 'host'>> {
|
|
140
|
+
host?: AliaRuntimeHost;
|
|
141
|
+
children: ReactNode;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const DEFAULT_CONFIG: AliaRuntimeConfig = {
|
|
145
|
+
apiUrl: ALIA_API_URL,
|
|
146
|
+
offline: null,
|
|
147
|
+
streamFetch: undefined,
|
|
148
|
+
speech: {},
|
|
149
|
+
host: {},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const AliaRuntimeContext = createContext<AliaRuntimeConfig>(DEFAULT_CONFIG);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Configure the conversation runtime for everything below. Providers nest and
|
|
156
|
+
* merge: what a nearer one leaves out, the outer one (or the default) answers.
|
|
157
|
+
* The sheet and the screen mount one from their own props; the Alia app mounts
|
|
158
|
+
* one above its routes, so its sidebar and its conversation read the same
|
|
159
|
+
* conversations through the same hooks.
|
|
160
|
+
*/
|
|
161
|
+
export function AliaRuntimeProvider({
|
|
162
|
+
apiUrl,
|
|
163
|
+
offline,
|
|
164
|
+
streamFetch,
|
|
165
|
+
speech,
|
|
166
|
+
host,
|
|
167
|
+
children,
|
|
168
|
+
}: AliaRuntimeProviderProps) {
|
|
169
|
+
const parent = useContext(AliaRuntimeContext);
|
|
170
|
+
const value = useMemo<AliaRuntimeConfig>(() => {
|
|
171
|
+
const nextApiUrl = apiUrl ?? parent.apiUrl;
|
|
172
|
+
return {
|
|
173
|
+
apiUrl: nextApiUrl,
|
|
174
|
+
offline: offline === undefined ? parent.offline : offline,
|
|
175
|
+
streamFetch: streamFetch ?? parent.streamFetch,
|
|
176
|
+
speech: speech ?? parent.speech,
|
|
177
|
+
host: host === undefined ? parent.host : { ...parent.host, ...host },
|
|
178
|
+
};
|
|
179
|
+
}, [apiUrl, offline, streamFetch, speech, host, parent]);
|
|
180
|
+
return <AliaRuntimeContext.Provider value={value}>{children}</AliaRuntimeContext.Provider>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The runtime's configuration: the nearest provider's, or the defaults. */
|
|
184
|
+
export function useAliaRuntime(): AliaRuntimeConfig {
|
|
185
|
+
return useContext(AliaRuntimeContext);
|
|
186
|
+
}
|