@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.
Files changed (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -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/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -1,6 +1,11 @@
1
1
  import { createContext, type ReactNode, useContext, useMemo } from 'react';
2
2
  import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from './speech-messages';
3
- import { englishWorkLog, type Translate } from './work-log-copy';
3
+ import { englishCopy, type Translate } from './copy';
4
+ import { VOICE_CONTROLS_LABELS, type VoiceControlsLabels } from './voice-controls-labels';
5
+ import type { VoiceCloseReason } from './voice-protocol';
6
+
7
+ /** Why a call could not be opened; the host's `canStartVoice` answers with one. */
8
+ export type VoiceBlockReason = 'sign-in' | 'plan-required' | 'credits-exhausted';
4
9
 
5
10
  /** Every string the chat UI draws itself. Bloom's own copy follows `BloomProvider locale`. */
6
11
  export interface AliaChatLabels {
@@ -11,11 +16,21 @@ export interface AliaChatLabels {
11
16
  more: string;
12
17
  chatActions: string;
13
18
  // Composer
14
- placeholder: string;
19
+ /** The empty composer's prompt, naming the conversation's lead ("Ask Sindi..."). */
20
+ placeholder: (leadName: string) => string;
15
21
  addMenuGroup: string;
16
22
  addPhotos: string;
23
+ /** The add menu's row for documents, where the lead takes them. */
24
+ addDocuments: string;
17
25
  startVoice: string;
18
- voiceFailed: string;
26
+ /** The call bar's words (status, mute, end). */
27
+ voiceControls: VoiceControlsLabels;
28
+ /** Why a call ended without failing; `null` for a hang-up (the person knows). */
29
+ voiceEnded: (reason: VoiceCloseReason) => string | null;
30
+ /** Said once, and shown in the bar, when the call is about to reach its time limit. */
31
+ voiceEndingSoon: string;
32
+ /** Why a call was not opened, told instead of a button that silently does nothing. */
33
+ voiceBlocked: (reason: VoiceBlockReason) => string;
19
34
  /** The field's accessible name. */
20
35
  composerMessage: string;
21
36
  /** The add button's accessible name. */
@@ -61,30 +76,61 @@ export interface AliaChatLabels {
61
76
  stopReading: string;
62
77
  // Plan
63
78
  planTitle: string;
64
- planApprove: string;
65
- planCancel: string;
66
- planApproved: string;
67
- planCancelled: string;
68
79
  // Reasoning
69
80
  reasoning: string;
70
81
  thoughtFor: (seconds: number) => string;
71
- // The turn's work log
72
- /** The words of the tool log, keyed as the Alia app keys them (see `work-log-copy.ts`). */
73
- workLog: Translate;
82
+ // The words that are not one fixed field
83
+ /** The words that are not one fixed field (the thread, its cards, the work log), keyed as the Alia app keys them (see `copy.ts`). */
84
+ t: Translate;
85
+ /** The language `t` speaks, as a BCP 47 tag: dates, days and prices in the thread are formatted in it. */
86
+ locale: string;
87
+ // Conversations (the runtime's own words: toasts and the errors it raises)
88
+ /** A conversation's title until it has one. */
89
+ newChat: string;
90
+ /** A turn could not be sent and the composer got its text back. */
91
+ sendFailed: string;
92
+ /** The session ended mid-request. */
93
+ signInRequired: string;
94
+ conversationSaveFailed: string;
95
+ conversationDeleteFailed: string;
96
+ conversationCreateFailed: string;
97
+ conversationClearFailed: string;
98
+ /** An attachment of a kind the chat API does not carry was left out of the turn. */
99
+ attachmentNotSent: (name: string) => string;
100
+ /** A photo whose bytes could not be read was left out of the turn. */
101
+ attachmentUnreadable: (name: string) => string;
74
102
  // Research
75
103
  }
76
104
 
105
+ const ENDED_ENGLISH: Partial<Record<VoiceCloseReason, string>> = {
106
+ 'call-moved-elsewhere': 'The call continues on your other device',
107
+ 'max-duration': 'The call reached its time limit',
108
+ 'idle-timeout': 'The call ended after two minutes of silence',
109
+ 'credits-exhausted': 'The call ended: you ran out of credits',
110
+ 'server-shutdown': 'The call ended while Alia was updating — start a new one',
111
+ };
112
+
113
+ const BLOCKED_ENGLISH: Record<VoiceBlockReason, string> = {
114
+ 'sign-in': 'Sign in to use voice',
115
+ 'plan-required': 'Voice mode needs a paid plan',
116
+ 'credits-exhausted': "You've run out of credits",
117
+ };
118
+
77
119
  export const DEFAULT_LABELS: AliaChatLabels = {
78
120
  clear: 'Clear',
79
121
  back: 'Back',
80
122
  share: 'Share',
81
123
  more: 'More',
82
124
  chatActions: 'Chat actions',
83
- placeholder: 'Ask Alia...',
125
+ placeholder: (leadName) => `Ask ${leadName}...`,
84
126
  addMenuGroup: 'Add',
85
127
  addPhotos: 'Photos',
128
+ addDocuments: 'Files',
86
129
  startVoice: 'Start voice',
87
- voiceFailed: "Couldn't start the voice call",
130
+ voiceControls: VOICE_CONTROLS_LABELS,
131
+ voiceEnded: (reason) => ENDED_ENGLISH[reason] ?? null,
132
+ voiceEndingSoon: 'One minute left in this call',
133
+ voiceBlocked: (reason) => BLOCKED_ENGLISH[reason],
88
134
  composerMessage: 'Message',
89
135
  composerAddMenu: 'Add to chat',
90
136
  composerModes: 'Power level',
@@ -114,13 +160,19 @@ export const DEFAULT_LABELS: AliaChatLabels = {
114
160
  readAloud: 'Read aloud',
115
161
  stopReading: 'Stop reading',
116
162
  planTitle: "Here's my plan",
117
- planApprove: 'Approve',
118
- planCancel: 'Cancel',
119
- planApproved: 'Plan approved',
120
- planCancelled: 'Plan cancelled',
121
163
  reasoning: 'Reasoning',
122
164
  thoughtFor: (seconds) => `Thought for ${seconds} seconds`,
123
- workLog: englishWorkLog,
165
+ t: englishCopy,
166
+ locale: 'en',
167
+ newChat: 'New chat',
168
+ sendFailed: "Your message couldn't be sent",
169
+ signInRequired: 'Sign in to keep chatting',
170
+ conversationSaveFailed: 'Failed to save conversation',
171
+ conversationDeleteFailed: 'Failed to delete conversation',
172
+ conversationCreateFailed: 'Failed to create conversation',
173
+ conversationClearFailed: "The conversation couldn't be cleared",
174
+ attachmentNotSent: (name) => `${name} wasn't sent — only photos can be sent`,
175
+ attachmentUnreadable: (name) => `${name} couldn't be read and wasn't sent`,
124
176
  };
125
177
 
126
178
  const LabelsContext = createContext<AliaChatLabels>(DEFAULT_LABELS);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The two origins the SDK talks to, defined once. Every URL it builds starts from one of them,
3
+ * and `@alia.onl/sdk/csp` (`src/csp.ts`) derives the host's CSP from these same values, so an
4
+ * origin the SDK uses is an origin it declares.
5
+ */
6
+
7
+ /** Alia's API: `EXPO_PUBLIC_ALIA_API_URL`, then production. Chat, dictation, speech, voice. */
8
+ export const ALIA_API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
9
+
10
+ /**
11
+ * Alia's web app, which serves the files the SDK shares with every host: Bloom's character
12
+ * runtime (`/bloom-character/`) and the voice call's worklets (`/sdk/voice-worklets/`), both
13
+ * with CORS open (`packages/app/worker/index.js`).
14
+ */
15
+ export const ALIA_WEB_ORIGIN = 'https://alia.onl';
@@ -0,0 +1,86 @@
1
+ import type { AvatarCharacter } from '@oxy.so/bloom/agent-avatar';
2
+ import type { AliaAgentProfile } from '../runtime/agents';
3
+ import type { Message } from '../runtime/types';
4
+ import { readAgentCharacter } from './agent-character';
5
+ import { ALIA_AVATAR } from './alia-character';
6
+
7
+ /** Somebody in the conversation who answers: its lead, or an agent the lead consulted. */
8
+ export interface AliaParticipant {
9
+ id: string;
10
+ name: string;
11
+ handle?: string;
12
+ /** The 3D character it is drawn as. */
13
+ character: AvatarCharacter;
14
+ color?: string;
15
+ }
16
+
17
+ /** The conversation's lead: who greets, whose name is on the composer, who the header shows first. */
18
+ export interface AliaLead extends AliaParticipant {
19
+ /** Alia herself, rather than an agent the chat was opened on. */
20
+ isAlia: boolean;
21
+ /** The agent's own greeting; Alia greets with the chat's labels. */
22
+ greeting?: string;
23
+ /** The agent's own starters, offered over the composer of an empty chat. */
24
+ starters: Array<{ title: string; prompt: string }>;
25
+ }
26
+
27
+ /** Who is in the conversation, as the header draws it: the lead, then whoever has joined. */
28
+ export interface AliaChatParticipants {
29
+ lead: AliaLead;
30
+ /** The consulted agents that have joined the header, in the order they joined. */
31
+ joined: AliaParticipant[];
32
+ }
33
+
34
+ /** Alia as a lead. */
35
+ export const ALIA_LEAD: AliaLead = {
36
+ id: 'alia',
37
+ name: 'Alia',
38
+ handle: 'alia',
39
+ character: ALIA_AVATAR,
40
+ isAlia: true,
41
+ starters: [],
42
+ };
43
+
44
+ /** The lead of a chat: the agent it was opened on (`agent={{ id }}`), or Alia. */
45
+ export function leadOf(profile: AliaAgentProfile | null | undefined): AliaLead {
46
+ if (!profile) return ALIA_LEAD;
47
+ return {
48
+ id: profile.id,
49
+ name: profile.name,
50
+ handle: profile.handle,
51
+ character: readAgentCharacter(profile.character),
52
+ color: profile.color,
53
+ isAlia: false,
54
+ greeting: profile.greeting,
55
+ starters: profile.starters,
56
+ };
57
+ }
58
+
59
+ /** A consulted agent as a message carries it (`agentInfo`). */
60
+ export function participantOf(agentInfo: NonNullable<Message['agentInfo']>): AliaParticipant {
61
+ return {
62
+ id: agentInfo.id,
63
+ name: agentInfo.name,
64
+ handle: agentInfo.handle,
65
+ character: readAgentCharacter(agentInfo.character),
66
+ color: agentInfo.color,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The agents that answered in `messages`, besides the lead, in the order they first did — each
72
+ * with whether its first answer is the one being streamed now (it is joining) or one already
73
+ * written (it was there when the chat opened).
74
+ */
75
+ export function consultedAgents(
76
+ messages: readonly Message[],
77
+ leadId: string,
78
+ ): Array<{ participant: AliaParticipant; live: boolean }> {
79
+ const seen = new Map<string, { participant: AliaParticipant; live: boolean }>();
80
+ for (const message of messages) {
81
+ const info = message.agentInfo;
82
+ if (!info || info.id === leadId || seen.has(info.id)) continue;
83
+ seen.set(info.id, { participant: participantOf(info), live: message.isStreaming === true });
84
+ }
85
+ return [...seen.values()];
86
+ }
@@ -0,0 +1,66 @@
1
+ import type { ComposerPanelPermissionOption } from '@oxy.so/bloom/composer-panel';
2
+ import { RiComputerLine } from '@oxy.so/bloom/icons/RiComputerLine';
3
+ import { RiFireLine } from '@oxy.so/bloom/icons/RiFireLine';
4
+ import { RiFlashlightLine } from '@oxy.so/bloom/icons/RiFlashlightLine';
5
+ import { RiLightbulbLine } from '@oxy.so/bloom/icons/RiLightbulbLine';
6
+ import { RiRocket2Line } from '@oxy.so/bloom/icons/RiRocket2Line';
7
+ import { RiSparkling2Line } from '@oxy.so/bloom/icons/RiSparkling2Line';
8
+ import { RiSpeedUpLine } from '@oxy.so/bloom/icons/RiSpeedUpLine';
9
+ import { RiVipCrownLine } from '@oxy.so/bloom/icons/RiVipCrownLine';
10
+ import { POWER_LEVELS, type PowerLevel } from '../runtime/power-levels';
11
+ import type { Translate } from './copy';
12
+
13
+ /**
14
+ * The power-level selector, in the shape Bloom's composer mode selector takes.
15
+ *
16
+ * Alia's users choose how much power a turn gets, never which hosted model answers it: Oxy picks
17
+ * the model of the chosen level for every request. The selector is `ComposerPill`'s mode picker —
18
+ * Bloom's own radio menu of a label, a short description and an icon per mode — so the seven
19
+ * levels read the same way every Bloom mode selector does. Models running on the person's OWN
20
+ * devices follow the levels: that is their machine, not a model Alia offers, and which devices
21
+ * are connected is the host's to say.
22
+ */
23
+
24
+ type IconComponent = ComposerPanelPermissionOption['icon'];
25
+
26
+ /** One glyph per level, rising with the power it stands for. */
27
+ export const POWER_LEVEL_ICONS: Record<PowerLevel, IconComponent> = {
28
+ auto: RiSparkling2Line,
29
+ instant: RiFlashlightLine,
30
+ medium: RiSpeedUpLine,
31
+ high: RiLightbulbLine,
32
+ xhigh: RiFireLine,
33
+ pro: RiRocket2Line,
34
+ ultra: RiVipCrownLine,
35
+ };
36
+
37
+ /** A model running on one of the person's own devices, offered after the levels. */
38
+ export interface DeviceModel {
39
+ /** What a request carries as `model` (`local/<runtime>/<model>`). */
40
+ id: string;
41
+ /** The tag the person knows it by, e.g. `llama3.1:8b`. */
42
+ name: string;
43
+ /** Which of their devices is running it. */
44
+ deviceLabel: string;
45
+ }
46
+
47
+ /** The selector's options: the seven levels, then the person's device models. */
48
+ export function buildPowerLevelOptions(
49
+ t: Translate,
50
+ deviceModels: readonly DeviceModel[],
51
+ ): ComposerPanelPermissionOption[] {
52
+ return [
53
+ ...POWER_LEVELS.map((level) => ({
54
+ id: level,
55
+ label: t(`powerLevels.${level}.label`),
56
+ description: t(`powerLevels.${level}.description`),
57
+ icon: POWER_LEVEL_ICONS[level],
58
+ })),
59
+ ...deviceModels.map((model) => ({
60
+ id: model.id,
61
+ label: model.name,
62
+ description: t('powerLevels.onDevice', { device: model.deviceLabel }),
63
+ icon: RiComputerLine,
64
+ })),
65
+ ];
66
+ }
@@ -4,7 +4,7 @@
4
4
  // call: it is stored with the message, survives a reload, and is answered by the person's
5
5
  // next message. Pure functions, no React Native, so a host's models and tests can use them.
6
6
 
7
- import type { ChatMessage, ToolInvocation } from '../types';
7
+ import type { Message, ToolInvocation } from '../runtime/types';
8
8
 
9
9
  /** The tool's name on the wire. */
10
10
  export const ASK_USER_QUESTION_TOOL = 'askUserQuestion';
@@ -83,7 +83,7 @@ export function formatAnswers(questions: AskedQuestion[], answers: QuestionAnswe
83
83
  * is the answer, so nothing is pending then.
84
84
  */
85
85
  export function pendingQuestions(
86
- messages: readonly Pick<ChatMessage, 'role' | 'toolInvocations'>[],
86
+ messages: readonly Pick<Message, 'role' | 'toolInvocations'>[],
87
87
  isStreaming: boolean,
88
88
  ): AskedQuestion[] {
89
89
  const last = messages.at(-1);
@@ -1,13 +1,13 @@
1
- import { englishWorkLog, type Translate } from './work-log-copy';
1
+ import { englishCopy, type Translate } from './copy';
2
2
 
3
3
  /** Whether the catalogue names this tool itself; otherwise the label is made from its id. */
4
4
  function isNamed(kind: 'pill' | 'done', toolName: string): boolean {
5
5
  const key = `tasks.tool.${kind}.${toolName}`;
6
- return englishWorkLog(key) !== key;
6
+ return englishCopy(key) !== key;
7
7
  }
8
8
 
9
9
  /** A tool's short label, in the reader's language when a translator is given. */
10
- export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog): string {
10
+ export function getToolPillLabel(toolName: string, t: Translate = englishCopy): string {
11
11
  if (isNamed('pill', toolName)) return t(`tasks.tool.pill.${toolName}`);
12
12
 
13
13
  // Oxy service tools: oxy_serviceName__toolName → "ServiceName"
@@ -28,7 +28,7 @@ export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog
28
28
  * `runningTitle` for this once the task's steps land). Labels that are already names
29
29
  * ("Telegram", "Email") read the same either way, so they fall back to the running label.
30
30
  */
31
- export function getToolDoneLabel(toolName: string, t: Translate = englishWorkLog): string {
31
+ export function getToolDoneLabel(toolName: string, t: Translate = englishCopy): string {
32
32
  return isNamed('done', toolName)
33
33
  ? t(`tasks.tool.done.${toolName}`)
34
34
  : getToolPillLabel(toolName, t);
@@ -1,4 +1,4 @@
1
- import type { ToolInvocation } from '../types';
1
+ import type { ToolInvocation } from '../runtime/types';
2
2
 
3
3
  /**
4
4
  * Where a tool call stands:
@@ -0,0 +1,36 @@
1
+ import type { BloomIconComponent } from '@oxy.so/bloom/icons';
2
+ import { RiCalendarLine } from '@oxy.so/bloom/icons/RiCalendarLine';
3
+ import { RiChat3Line } from '@oxy.so/bloom/icons/RiChat3Line';
4
+ import { RiDatabase2Line } from '@oxy.so/bloom/icons/RiDatabase2Line';
5
+ import { RiFileTextLine } from '@oxy.so/bloom/icons/RiFileTextLine';
6
+ import { RiGlobalLine } from '@oxy.so/bloom/icons/RiGlobalLine';
7
+ import { RiLightbulbFlashLine } from '@oxy.so/bloom/icons/RiLightbulbFlashLine';
8
+ import { RiLink } from '@oxy.so/bloom/icons/RiLink';
9
+ import { RiSearchLine } from '@oxy.so/bloom/icons/RiSearchLine';
10
+ import { RiSendPlaneLine } from '@oxy.so/bloom/icons/RiSendPlaneLine';
11
+ import { RiSettings3Line } from '@oxy.so/bloom/icons/RiSettings3Line';
12
+ import { RiUserLine } from '@oxy.so/bloom/icons/RiUserLine';
13
+
14
+ /** The glyph a tool's step carries in the thought panel, by tool name. */
15
+ const TOOL_ICONS: ReadonlyMap<string, BloomIconComponent> = new Map([
16
+ ['webSearch', RiSearchLine],
17
+ ['scrapeURL', RiLink],
18
+ ['getTimeline', RiCalendarLine],
19
+ ['searchKnowledgeBase', RiDatabase2Line],
20
+ ['webScraper', RiGlobalLine],
21
+ ['browse', RiGlobalLine],
22
+ ['sendWhatsAppMessage', RiChat3Line],
23
+ ['getWhatsAppChats', RiChat3Line],
24
+ ['getWhatsAppMessages', RiChat3Line],
25
+ ['sendTelegramMessage', RiSendPlaneLine],
26
+ ['getCurrentDate', RiCalendarLine],
27
+ ['generateFile', RiFileTextLine],
28
+ ['saveUserMemory', RiLightbulbFlashLine],
29
+ ['updateUserPreferences', RiSettings3Line],
30
+ ['updateUserContext', RiUserLine],
31
+ ]);
32
+
33
+ /** A tool's glyph; a tool with none of its own reads as the web it reached. */
34
+ export function getToolIcon(toolName: string): BloomIconComponent {
35
+ return TOOL_ICONS.get(toolName) ?? RiGlobalLine;
36
+ }
@@ -0,0 +1,48 @@
1
+ import { createContext, type ReactNode, useContext } from 'react';
2
+
3
+ /**
4
+ * A tool's result as a host's renderer receives it (`docs/agent-embedding.md` §3): the app and the
5
+ * tool its canonical name names, what it was called with and what it returned.
6
+ */
7
+ export interface AliaToolResult {
8
+ /** The app of `oxy_<app>__<tool>` / `self_<app>__<tool>`. */
9
+ app: string;
10
+ tool: string;
11
+ toolCallId: string;
12
+ input?: Record<string, unknown>;
13
+ output: unknown;
14
+ /** The consulted agent that called it; absent for the lead's own calls. */
15
+ agentId?: string;
16
+ }
17
+
18
+ /**
19
+ * The host's own drawings of catalogue tools' results, keyed `<app>:<tool>` — `homiio:searchListings`
20
+ * for `oxy_homiio__searchListings`. Drawn before the generic tool card, for the lead's calls and a
21
+ * consulted agent's alike.
22
+ */
23
+ export type AliaToolRenderers = Readonly<Record<string, (result: AliaToolResult) => ReactNode>>;
24
+
25
+ /**
26
+ * `oxy_<app>__<tool>` or `self_<app>__<tool>`: the only names a renderer is keyed from. The app
27
+ * part is an Oxy app slug; the tool part is whatever follows the separator.
28
+ */
29
+ const CANONICAL_TOOL = /^(?:oxy|self)_([a-z][a-z0-9-]{0,63})__([A-Za-z0-9_-]+)$/;
30
+
31
+ /** The app and tool of a canonical catalogue tool name, or `null` for any other name. */
32
+ export function parseCatalogueTool(toolName: string): { app: string; tool: string } | null {
33
+ const match = CANONICAL_TOOL.exec(toolName);
34
+ return match ? { app: match[1], tool: match[2] } : null;
35
+ }
36
+
37
+ /** The renderer key of a tool name (`<app>:<tool>`), or `null` when it is not a catalogue tool. */
38
+ export function toolRendererKey(toolName: string): string | null {
39
+ const parsed = parseCatalogueTool(toolName);
40
+ return parsed ? `${parsed.app}:${parsed.tool}` : null;
41
+ }
42
+
43
+ const RenderersContext = createContext<AliaToolRenderers>({});
44
+
45
+ export const AliaToolRenderersProvider = RenderersContext.Provider;
46
+
47
+ /** The host's renderers, `{}` when it brought none. */
48
+ export const useToolRenderers = () => useContext(RenderersContext);
@@ -1,9 +1,9 @@
1
- import type { ChatMessage } from '../types';
1
+ import type { Message } from '../runtime/types';
2
2
  import type { AliaAvatarState } from '../components/AliaAvatar';
3
3
 
4
4
  /** What a chat turn is doing, from its messages: the one reading the header mark and every button share. */
5
5
  export function turnActivityState(
6
- messages: ReadonlyArray<ChatMessage>,
6
+ messages: ReadonlyArray<Message>,
7
7
  isStreaming: boolean,
8
8
  ): AliaAvatarState {
9
9
  if (!isStreaming) return 'idle';
@@ -11,12 +11,14 @@
11
11
  * playback through a local `RTCPeerConnection` loopback) is not built until the
12
12
  * measurement says it is needed.
13
13
  *
14
- * The worklets are inline source turned into a Blob URL: the SDK ships raw
15
- * source and a host bundler would not emit a separate worklet file.
14
+ * The worklets are one module served by Alia's web app (`./voice-worklets.ts`),
15
+ * never a `blob:` URL a host's CSP would have to allow as script. Their numbers
16
+ * (stream rate, frame size, prebuffer) are passed as `processorOptions`.
16
17
  */
17
18
 
18
19
  import { levelOfPcm16, pcm16ToFloat } from './pcm';
19
20
  import { VOICE_FRAME_SAMPLES, VOICE_SAMPLE_RATE } from './voice-protocol';
21
+ import { VOICE_WORKLETS_URL } from './voice-worklets';
20
22
  import {
21
23
  VoiceAudioEngineError,
22
24
  type VoiceAudioEngine,
@@ -30,155 +32,6 @@ const PREBUFFER_MS = 80;
30
32
  /** A burst shorter than the prebuffer still plays once nothing more arrives for this long. */
31
33
  const PREBUFFER_IDLE_MS = 100;
32
34
 
33
- const CAPTURE_PROCESSOR = `
34
- class AliaVoiceCapture extends AudioWorkletProcessor {
35
- constructor() {
36
- super();
37
- this.ratio = sampleRate / ${VOICE_SAMPLE_RATE};
38
- this.t = 0;
39
- this.prev = 0;
40
- this.frame = new Int16Array(${VOICE_FRAME_SAMPLES});
41
- this.filled = 0;
42
- }
43
- process(inputs) {
44
- const input = inputs[0] && inputs[0][0];
45
- if (!input || input.length === 0) return true;
46
- const n = input.length;
47
- while (this.t < n - 1) {
48
- const i = Math.floor(this.t);
49
- const f = this.t - i;
50
- const a = i < 0 ? this.prev : input[i];
51
- const b = input[i + 1];
52
- let s = a + (b - a) * f;
53
- s = s < -1 ? -1 : s > 1 ? 1 : s;
54
- this.frame[this.filled++] = s < 0 ? s * 0x8000 : s * 0x7fff;
55
- if (this.filled === this.frame.length) {
56
- const out = this.frame;
57
- this.port.postMessage(out.buffer, [out.buffer]);
58
- this.frame = new Int16Array(${VOICE_FRAME_SAMPLES});
59
- this.filled = 0;
60
- }
61
- this.t += this.ratio;
62
- }
63
- this.t -= n;
64
- this.prev = input[n - 1];
65
- return true;
66
- }
67
- }
68
- registerProcessor('alia-voice-capture', AliaVoiceCapture);
69
- `;
70
-
71
- const PLAYBACK_PROCESSOR = `
72
- class AliaVoicePlayback extends AudioWorkletProcessor {
73
- constructor() {
74
- super();
75
- this.step = ${VOICE_SAMPLE_RATE} / sampleRate;
76
- this.queue = [];
77
- this.queued = 0;
78
- this.pos = 0;
79
- this.buffering = true;
80
- this.lastChunkAt = 0;
81
- this.item = null;
82
- this.played = 0;
83
- this.sinceReport = 0;
84
- this.sumSquares = 0;
85
- this.count = 0;
86
- this.port.onmessage = (event) => {
87
- const message = event.data;
88
- if (message.type === 'chunk') {
89
- this.queue.push({ itemId: message.itemId, samples: message.samples });
90
- this.queued += message.samples.length;
91
- this.lastChunkAt = currentTime;
92
- } else if (message.type === 'flush') {
93
- const playedMs = this.item === null ? null : (this.played / ${VOICE_SAMPLE_RATE}) * 1000;
94
- this.port.postMessage({ type: 'flushed', itemId: this.item, playedMs });
95
- this.queue = [];
96
- this.queued = 0;
97
- this.pos = 0;
98
- this.buffering = true;
99
- this.item = null;
100
- this.played = 0;
101
- }
102
- };
103
- }
104
- report(force) {
105
- if (!force && this.sinceReport < sampleRate / 20) return;
106
- const rms = this.count === 0 ? 0 : Math.sqrt(this.sumSquares / this.count);
107
- this.port.postMessage({
108
- type: 'progress',
109
- itemId: this.item,
110
- playedMs: (this.played / ${VOICE_SAMPLE_RATE}) * 1000,
111
- rms,
112
- });
113
- this.sinceReport = 0;
114
- this.sumSquares = 0;
115
- this.count = 0;
116
- }
117
- process(_inputs, outputs) {
118
- const out = outputs[0][0];
119
- if (this.buffering) {
120
- const ready =
121
- this.queued >= ${(VOICE_SAMPLE_RATE * PREBUFFER_MS) / 1000} ||
122
- (this.queued > 0 && currentTime - this.lastChunkAt > ${PREBUFFER_IDLE_MS / 1000});
123
- if (!ready) {
124
- out.fill(0);
125
- for (let c = 1; c < outputs[0].length; c++) outputs[0][c].fill(0);
126
- return true;
127
- }
128
- this.buffering = false;
129
- }
130
- for (let k = 0; k < out.length; k++) {
131
- const head = this.queue[0];
132
- if (!head) {
133
- out[k] = 0;
134
- if (!this.buffering) {
135
- this.buffering = true;
136
- this.report(true);
137
- this.port.postMessage({ type: 'idle' });
138
- }
139
- continue;
140
- }
141
- if (head.itemId !== this.item) {
142
- this.item = head.itemId;
143
- this.played = 0;
144
- this.port.postMessage({ type: 'started', itemId: head.itemId });
145
- }
146
- const i = Math.floor(this.pos);
147
- const f = this.pos - i;
148
- const a = head.samples[i];
149
- const b = i + 1 < head.samples.length ? head.samples[i + 1] : a;
150
- const s = a + (b - a) * f;
151
- out[k] = s;
152
- this.sumSquares += s * s;
153
- this.count++;
154
- this.pos += this.step;
155
- const consumed = Math.floor(this.pos);
156
- if (consumed > 0) {
157
- const taken = Math.min(consumed, head.samples.length);
158
- this.played += taken;
159
- this.queued -= taken;
160
- if (consumed >= head.samples.length) {
161
- this.queue.shift();
162
- this.pos = 0;
163
- } else {
164
- head.samples = head.samples.subarray(consumed);
165
- this.pos -= consumed;
166
- }
167
- }
168
- }
169
- for (let c = 1; c < outputs[0].length; c++) outputs[0][c].set(out);
170
- this.sinceReport += out.length;
171
- this.report(false);
172
- return true;
173
- }
174
- }
175
- registerProcessor('alia-voice-playback', AliaVoicePlayback);
176
- `;
177
-
178
- function moduleUrl(source: string): string {
179
- return URL.createObjectURL(new Blob([source], { type: 'application/javascript' }));
180
- }
181
-
182
35
  function deviceError(caught: unknown): VoiceAudioEngineError {
183
36
  const name = (caught as { name?: unknown } | null)?.name;
184
37
  if (name === 'NotAllowedError' || name === 'SecurityError' || name === 'PermissionDeniedError')
@@ -229,16 +82,10 @@ export function createVoiceAudioEngine(): VoiceAudioEngine {
229
82
  }
230
83
 
231
84
  context = new AudioContextCtor({ latencyHint: 'interactive' });
232
- const captureUrl = moduleUrl(CAPTURE_PROCESSOR);
233
- const playbackUrl = moduleUrl(PLAYBACK_PROCESSOR);
234
85
  try {
235
- await context.audioWorklet.addModule(captureUrl);
236
- await context.audioWorklet.addModule(playbackUrl);
86
+ await context.audioWorklet.addModule(VOICE_WORKLETS_URL);
237
87
  } catch {
238
88
  throw new VoiceAudioEngineError('voice-unavailable');
239
- } finally {
240
- URL.revokeObjectURL(captureUrl);
241
- URL.revokeObjectURL(playbackUrl);
242
89
  }
243
90
  await context.resume().catch(() => undefined);
244
91
 
@@ -247,6 +94,10 @@ export function createVoiceAudioEngine(): VoiceAudioEngine {
247
94
  numberOfInputs: 1,
248
95
  numberOfOutputs: 0,
249
96
  channelCount: 1,
97
+ processorOptions: {
98
+ streamSampleRate: VOICE_SAMPLE_RATE,
99
+ frameSamples: VOICE_FRAME_SAMPLES,
100
+ },
250
101
  });
251
102
  capture.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
252
103
  if (stopped) return;
@@ -260,6 +111,11 @@ export function createVoiceAudioEngine(): VoiceAudioEngine {
260
111
  numberOfInputs: 0,
261
112
  numberOfOutputs: 1,
262
113
  outputChannelCount: [context.destination.channelCount || 2],
114
+ processorOptions: {
115
+ streamSampleRate: VOICE_SAMPLE_RATE,
116
+ prebufferMs: PREBUFFER_MS,
117
+ prebufferIdleMs: PREBUFFER_IDLE_MS,
118
+ },
263
119
  });
264
120
  playback.port.onmessage = (event: MessageEvent) => {
265
121
  const message = event.data as