@alia.onl/sdk 15.8.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.
Files changed (141) hide show
  1. package/CHANGELOG.md +242 -13
  2. package/README.md +223 -151
  3. package/package.json +15 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +0 -3
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +76 -34
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +32 -14
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +123 -0
  91. package/src/runtime/attachment-utils.ts +96 -0
  92. package/src/runtime/composer-draft-store.ts +270 -0
  93. package/src/runtime/config.tsx +186 -0
  94. package/src/runtime/conversation-store.ts +49 -0
  95. package/src/runtime/conversations.ts +557 -0
  96. package/src/runtime/error-utils.ts +104 -0
  97. package/src/runtime/host-tools.ts +140 -0
  98. package/src/runtime/index.ts +113 -0
  99. package/src/runtime/message-history.ts +124 -0
  100. package/src/runtime/model.ts +39 -0
  101. package/src/runtime/oxy-files.ts +37 -0
  102. package/src/runtime/power-levels.ts +66 -0
  103. package/src/runtime/query-keys.ts +34 -0
  104. package/src/runtime/random-uuid.ts +25 -0
  105. package/src/runtime/routes.ts +51 -0
  106. package/src/runtime/sharing.ts +165 -0
  107. package/src/runtime/sse-frame-reader.ts +88 -0
  108. package/src/runtime/stream-events.ts +146 -0
  109. package/src/runtime/stream-outcome.ts +21 -0
  110. package/src/runtime/thought-store.ts +103 -0
  111. package/src/runtime/thread/agent-activity.ts +261 -0
  112. package/src/runtime/thread/automation-labels.ts +79 -0
  113. package/src/runtime/thread/conversation-export.ts +135 -0
  114. package/src/runtime/thread/message-days.ts +120 -0
  115. package/src/runtime/thread/message-processor.ts +93 -0
  116. package/src/runtime/thread/task-utils.ts +11 -0
  117. package/src/runtime/thread/thought-utils.ts +752 -0
  118. package/src/runtime/thread/thread-history.ts +139 -0
  119. package/src/runtime/thread/timeline.ts +202 -0
  120. package/src/runtime/thread/title-tags.ts +8 -0
  121. package/src/runtime/thread/tool-cards.ts +383 -0
  122. package/src/runtime/turn-failure.ts +108 -0
  123. package/src/runtime/types.ts +148 -0
  124. package/src/runtime/usage-limit-error.ts +44 -0
  125. package/src/runtime/use-agent-activity.ts +80 -0
  126. package/src/runtime/use-conversation-stream.ts +1427 -0
  127. package/src/runtime/use-conversation-thread.ts +556 -0
  128. package/src/runtime/use-start-conversation.ts +107 -0
  129. package/src/runtime/use-thread-history.ts +64 -0
  130. package/src/runtime/use-thread-search.ts +95 -0
  131. package/src/runtime/use-turn-edit.ts +99 -0
  132. package/src/tools.ts +2 -2
  133. package/src/types.ts +18 -47
  134. package/src/components/AliaChatMessageList.tsx +0 -532
  135. package/src/components/ThinkingBlock.tsx +0 -43
  136. package/src/hooks/useAliaChat.ts +0 -573
  137. package/src/lib/catalogue.ts +0 -236
  138. package/src/lib/chat-stream.ts +0 -655
  139. package/src/lib/chat-transport.ts +0 -98
  140. package/src/lib/delegation.ts +0 -34
  141. package/src/lib/work-log-copy.ts +0 -61
@@ -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 { englishWorkLog, type Translate } from './work-log-copy';
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 `work-log-copy.ts`). Default: English. */
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 ?? englishWorkLog;
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 ?? englishWorkLog;
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 ?? englishWorkLog;
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,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
+ }