@alia.onl/sdk 11.1.0 → 13.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 (36) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/package.json +5 -4
  3. package/src/components/AliaAvatar.tsx +16 -20
  4. package/src/components/AliaChatContent.tsx +234 -96
  5. package/src/components/AliaChatHeader.tsx +102 -76
  6. package/src/components/AliaChatMessageList.tsx +174 -134
  7. package/src/components/AliaChatScreen.tsx +45 -23
  8. package/src/components/AliaChatSheet.tsx +151 -50
  9. package/src/components/AliaWelcomeMessage.tsx +26 -53
  10. package/src/components/AmbientField.tsx +19 -16
  11. package/src/components/ComposerSuggestions.tsx +45 -0
  12. package/src/components/IdentityMark.tsx +3 -1
  13. package/src/components/Markdown.tsx +18 -24
  14. package/src/components/QuestionCard.tsx +35 -33
  15. package/src/components/ThinkingBlock.tsx +9 -13
  16. package/src/components/VoiceModeIcon.tsx +15 -7
  17. package/src/components/voice/VoiceControls.tsx +17 -9
  18. package/src/hooks/useAliaChat.ts +124 -5
  19. package/src/hooks/useComposerSuggestions.ts +84 -0
  20. package/src/index.ts +10 -10
  21. package/src/lib/agent-character.ts +28 -0
  22. package/src/lib/chat-stream.ts +25 -3
  23. package/src/lib/citations.ts +62 -10
  24. package/src/lib/delegation.ts +34 -0
  25. package/src/lib/domain.ts +12 -0
  26. package/src/lib/flights.ts +24 -0
  27. package/src/lib/greeting.ts +15 -0
  28. package/src/lib/image-data-url.ts +26 -5
  29. package/src/lib/labels.tsx +10 -2
  30. package/src/lib/questions.ts +15 -1
  31. package/src/lib/suggestions.ts +53 -0
  32. package/src/lib/task-labels.ts +7 -39
  33. package/src/lib/use-after-delay.ts +19 -0
  34. package/src/lib/work-log.ts +3 -9
  35. package/src/tools.ts +8 -1
  36. package/src/types.ts +6 -5
@@ -1,4 +1,5 @@
1
1
  import type { PlanStep, ResearchProgress, ResearchSource } from '../types';
2
+ import { ASK_USER_QUESTION_TOOL } from './questions';
2
3
 
3
4
  const MAX_STREAM_BYTES = 8 * 1024 * 1024;
4
5
  const MAX_STREAM_LINES = 65_536;
@@ -30,7 +31,12 @@ export type AliaChatStreamEvent =
30
31
  | {
31
32
  readonly kind: 'agent_answer';
32
33
  readonly content: string;
33
- readonly agent: { readonly id: string; readonly name: string; readonly avatar: null };
34
+ readonly agent: {
35
+ readonly id: string;
36
+ readonly name: string;
37
+ /** The agent's look as the server stores it (JSON); absent from a server that does not send it. */
38
+ readonly character?: unknown;
39
+ };
34
40
  };
35
41
 
36
42
  export interface AliaChatStreamResult {
@@ -323,7 +329,15 @@ function parseNamedEvent(eventName: string, payload: unknown): AliaChatStreamEve
323
329
  case 'alia.agent': {
324
330
  assertOnlyKeys(
325
331
  body,
326
- ['eventVersion', 'agentId', 'agentName', 'agentHandle', 'agentColor', 'content'],
332
+ [
333
+ 'eventVersion',
334
+ 'agentId',
335
+ 'agentName',
336
+ 'agentHandle',
337
+ 'agentColor',
338
+ 'agentCharacter',
339
+ 'content',
340
+ ],
327
341
  eventName,
328
342
  );
329
343
  if (body.agentColor !== null) asString(body.agentColor, `${eventName} agentColor`);
@@ -334,7 +348,11 @@ function parseNamedEvent(eventName: string, payload: unknown): AliaChatStreamEve
334
348
  agent: {
335
349
  id: asString(body.agentId, `${eventName} agentId`),
336
350
  name: asString(body.agentName, `${eventName} agentName`),
337
- avatar: null,
351
+ // As the server sent it, parsed where it is drawn (`readAgentCharacter`). Absent from an
352
+ // older server, null for an agent that could not be read: the default look then.
353
+ ...(body.agentCharacter === undefined || body.agentCharacter === null
354
+ ? {}
355
+ : { character: body.agentCharacter }),
338
356
  },
339
357
  };
340
358
  }
@@ -579,6 +597,10 @@ export async function consumeAliaChatStream(
579
597
  }
580
598
  for (const event of parsed.events) {
581
599
  if (event.kind === 'content' && event.content.trim().length > 0) answerReceived = true;
600
+ // A turn that ends at a question card is answered by the card, even with no sentence before it.
601
+ if (event.kind === 'tool_call' && event.toolName === ASK_USER_QUESTION_TOOL) {
602
+ answerReceived = true;
603
+ }
582
604
  onEvent(event);
583
605
  }
584
606
  if (parsed.finishReason !== null) {
@@ -1,4 +1,5 @@
1
1
  import type { ToolInvocation } from '../types';
2
+ import { domainOf } from './domain';
2
3
 
3
4
  export interface CitationSource {
4
5
  /** The citation number, as it appears in `[n]`. */
@@ -14,14 +15,6 @@ interface PersistedResearchSource {
14
15
  title?: unknown;
15
16
  }
16
17
 
17
- function domainOf(url: string): string {
18
- try {
19
- return new URL(url).hostname.replace(/^www\./, '');
20
- } catch {
21
- return url;
22
- }
23
- }
24
-
25
18
  /**
26
19
  * The numbered sources of a research answer.
27
20
  *
@@ -43,8 +36,8 @@ export function extractCitationSources(
43
36
  const title =
44
37
  typeof raw.title === 'string' && raw.title.trim().length > 0
45
38
  ? raw.title.trim()
46
- : domainOf(url);
47
- byId.set(id, { id, url, title, domain: domainOf(url) });
39
+ : domainOf(url, { stripWww: true });
40
+ byId.set(id, { id, url, title, domain: domainOf(url, { stripWww: true }) });
48
41
  };
49
42
 
50
43
  for (const inv of toolInvocations ?? []) {
@@ -61,3 +54,62 @@ export function extractCitationSources(
61
54
 
62
55
  return [...byId.values()].sort((a, b) => a.id - b.id);
63
56
  }
57
+
58
+ const FULLWIDTH_MARKER_RE = /【\s*(\d{1,3})\s*(?:†[^】]*)?】/g;
59
+ const ASCII_DAGGER_MARKER_RE = /\[(\d{1,3})†[^\]]*\]/g;
60
+ const COMMA_LIST_RE = /\[(\d{1,3}(?:\s*,\s*\d{1,3})+)\](?![(:])/g;
61
+ const SINGLE_MARKER_RE = /\[(\d{1,3})\](?![(:])/g;
62
+
63
+ /**
64
+ * Rewrite every citation marker into the `[n]` form; with `knownIds`, drop
65
+ * the ones that name no source. See the API twin for the reasoning.
66
+ */
67
+ export function normalizeCitationMarkers(text: string, knownIds?: Iterable<number>): string {
68
+ const known = knownIds ? new Set(knownIds) : null;
69
+
70
+ let out = text
71
+ .replace(FULLWIDTH_MARKER_RE, '[$1]')
72
+ .replace(ASCII_DAGGER_MARKER_RE, '[$1]')
73
+ .replace(COMMA_LIST_RE, (_m, list: string) =>
74
+ list
75
+ .split(',')
76
+ .map((n) => `[${n.trim()}]`)
77
+ .join(''),
78
+ );
79
+
80
+ if (known && known.size > 0) {
81
+ out = out.replace(SINGLE_MARKER_RE, (m, n: string) => (known.has(Number(n)) ? m : ''));
82
+ out = out.replace(/[ \t]+([.,;:!?])/g, '$1').replace(/[ \t]{2,}/g, ' ');
83
+ }
84
+
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * The accessible name of one citation: what a screen reader says for the
90
+ * link, and what `CustomMarkdown` puts in the link's title.
91
+ */
92
+ export function citationLabel(source: CitationSource): string {
93
+ return `Source ${source.id}: ${source.title}`;
94
+ }
95
+
96
+ /**
97
+ * `[n]` markers that name a known source become Markdown links to it, so the
98
+ * SDK's Markdown renderer makes them pressable. Markers with no source are
99
+ * left as text: dropping them here would hide that the model cited something
100
+ * the reader cannot check, and the API already drops them for new answers.
101
+ *
102
+ * Runs on the normalised text, so legacy `【n†…】` markers link too. The
103
+ * link title carries the accessible name; a title containing quotes would end
104
+ * it early, so those are folded to apostrophes.
105
+ */
106
+ export function linkifyCitations(text: string, sources: CitationSource[]): string {
107
+ if (sources.length === 0) return normalizeCitationMarkers(text);
108
+ const byId = new Map(sources.map((s) => [s.id, s]));
109
+ return normalizeCitationMarkers(text).replace(SINGLE_MARKER_RE, (m, n: string) => {
110
+ const source = byId.get(Number(n));
111
+ if (!source) return m;
112
+ const title = citationLabel(source).replace(/"/g, "'");
113
+ return `[${n}](${source.url} "${title}")`;
114
+ });
115
+ }
@@ -0,0 +1,34 @@
1
+ import type { ToolInvocation } from '../types';
2
+
3
+ /** The tools that hand a question to another agent. */
4
+ const DELEGATION_TOOLS = new Set(['delegateToAgent', 'askAgent']);
5
+
6
+ export interface WorkingAgent {
7
+ id: string;
8
+ name: string;
9
+ }
10
+
11
+ const text = (value: unknown): string | undefined =>
12
+ typeof value === 'string' && value.trim() !== '' ? value : undefined;
13
+
14
+ /**
15
+ * The agent a delegation call is for, from its arguments: an id (`agentId`) and a name
16
+ * (`agentName`, or `agent` when it is a name). Whichever is missing falls back on the other, so
17
+ * the placeholder and the answer that follows agree on who it is as long as the call names them.
18
+ */
19
+ export function delegatedAgent(invocation: ToolInvocation): WorkingAgent | null {
20
+ if (!DELEGATION_TOOLS.has(invocation.toolName)) return null;
21
+ const args = invocation.args ?? {};
22
+ const id = text(args.agentId) ?? text(args.agent_id) ?? text(args.agent) ?? text(args.name);
23
+ const name = text(args.agentName) ?? text(args.agent) ?? text(args.name) ?? id;
24
+ if (!id || !name) return null;
25
+ return { id, name };
26
+ }
27
+
28
+ /**
29
+ * The id of an agent's message in a turn. A delegation call creates it empty (the agent is
30
+ * thinking) and the agent's answer fills the same message, so one element carries the avatar from
31
+ * thinking to answered.
32
+ */
33
+ export const agentMessageId = (assistantId: string, agentId: string) =>
34
+ `${assistantId}-agent-${agentId}`;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The host of a URL, or the string itself when it is not one. Citations drop a leading `www.`
3
+ * (the number in `[3]` names a site); a source row in the work log keeps the host as served.
4
+ */
5
+ export function domainOf(url: string, { stripWww = false }: { stripWww?: boolean } = {}): string {
6
+ try {
7
+ const host = new URL(url).hostname;
8
+ return stripWww ? host.replace(/^www\./, '') : host;
9
+ } catch {
10
+ return url;
11
+ }
12
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { AvatarFlights, AvatarFlightSpec } from '@oxy.so/bloom/avatar-flight';
3
+
4
+ import { ALIA_AVATAR } from '../components/AliaAvatar';
5
+
6
+ /** Where Alia's avatar is in the chat: large in the greeting, then small in the header's profile. */
7
+ export const ALIA_ANCHORS = { welcome: 'alia:welcome', profile: 'alia:profile' } as const;
8
+
9
+ /** The greeting's avatar flies up into the header's profile as the conversation begins. */
10
+ export const ALIA_TO_PROFILE: AvatarFlightSpec = {
11
+ key: 'alia',
12
+ character: ALIA_AVATAR,
13
+ from: ALIA_ANCHORS.welcome,
14
+ to: ALIA_ANCHORS.profile,
15
+ duration: 800,
16
+ };
17
+
18
+ const FlightsContext = createContext<AvatarFlights | null>(null);
19
+
20
+ /** The chat's avatar flights, for the greeting and the header that share them. */
21
+ export const AliaFlightsProvider = FlightsContext.Provider;
22
+
23
+ /** `null` outside a chat: the greeting and the header then draw their avatars without flying. */
24
+ export const useAliaFlights = () => useContext(FlightsContext);
@@ -0,0 +1,15 @@
1
+ /** Commas of the languages the apps greet in: Latin, fullwidth, ideographic and Arabic. */
2
+ const COMMA = /[,,、،]/;
3
+
4
+ /**
5
+ * A greeting in two lines: the address ("Hi Nate,") over the rest ("what's on your mind?"). It
6
+ * breaks after the first comma, which keeps the greeting one string a translator writes once; a
7
+ * greeting without one (or with nothing after it) stays a single line.
8
+ */
9
+ export function greetingLines(greeting: string): [string] | [string, string] {
10
+ const at = greeting.search(COMMA);
11
+ if (at < 0) return [greeting];
12
+ const first = greeting.slice(0, at + 1);
13
+ const rest = greeting.slice(at + 1).trim();
14
+ return rest === '' ? [greeting] : [first, rest];
15
+ }
@@ -1,15 +1,36 @@
1
+ import * as FileSystem from 'expo-file-system';
2
+ import { Platform } from 'react-native';
3
+
1
4
  /**
2
- * Reads a picked image (a `file://` URI on native, a `blob:` URL on web) into a
3
- * `data:` URL, the one form the chat API accepts as an `image_url` part without
4
- * the server having to reach the device.
5
+ * Reads a picked image into a `data:` URL, the one form the chat API accepts as an `image_url`
6
+ * part without the server having to reach the device. The picker's URI means nothing off the
7
+ * device: on web it is a `blob:` URL (a handle into this tab's memory), on native a `file://`
8
+ * path, and sent as it is either arrives as a string the model cannot open.
9
+ *
10
+ * Web reads the blob back; native reads the file with `expo-file-system`, as the Alia app does.
5
11
  */
6
- export async function imageUriToDataUrl(uri: string): Promise<string> {
12
+ export async function imageUriToDataUrl(uri: string, mimeType?: string): Promise<string> {
7
13
  if (uri.startsWith('data:')) return uri;
14
+ if (Platform.OS !== 'web') {
15
+ const base64 = await FileSystem.readAsStringAsync(uri, {
16
+ encoding: FileSystem.EncodingType.Base64,
17
+ });
18
+ return `data:${mimeType || 'image/jpeg'};base64,${base64}`;
19
+ }
8
20
  const blob = await (await fetch(uri)).blob();
9
21
  return await new Promise<string>((resolve, reject) => {
10
22
  const reader = new FileReader();
11
23
  reader.onerror = () => reject(reader.error ?? new Error('Could not read the image'));
12
- reader.onload = () => resolve(String(reader.result));
24
+ reader.onload = () => {
25
+ const result = String(reader.result);
26
+ // `readAsDataURL` stamps the blob's own type; it is only octet-stream when the blob has
27
+ // none, and then the picker's type is the better guess.
28
+ resolve(
29
+ mimeType && result.startsWith('data:application/octet-stream')
30
+ ? result.replace('data:application/octet-stream', `data:${mimeType}`)
31
+ : result,
32
+ );
33
+ };
13
34
  reader.readAsDataURL(blob);
14
35
  });
15
36
  }
@@ -5,7 +5,9 @@ import { englishWorkLog, type Translate } from './work-log-copy';
5
5
  export interface AliaChatLabels {
6
6
  // Header
7
7
  clear: string;
8
- close: string;
8
+ back: string;
9
+ share: string;
10
+ more: string;
9
11
  chatActions: string;
10
12
  // Composer
11
13
  placeholder: string;
@@ -15,6 +17,8 @@ export interface AliaChatLabels {
15
17
  voiceFailed: string;
16
18
  // Messages
17
19
  copy: string;
20
+ helpful: string;
21
+ unhelpful: string;
18
22
  copied: string;
19
23
  edit: string;
20
24
  save: string;
@@ -38,7 +42,9 @@ export interface AliaChatLabels {
38
42
 
39
43
  export const DEFAULT_LABELS: AliaChatLabels = {
40
44
  clear: 'Clear',
41
- close: 'Close',
45
+ back: 'Back',
46
+ share: 'Share',
47
+ more: 'More',
42
48
  chatActions: 'Chat actions',
43
49
  placeholder: 'Ask Alia...',
44
50
  addMenuGroup: 'Add',
@@ -46,6 +52,8 @@ export const DEFAULT_LABELS: AliaChatLabels = {
46
52
  startVoice: 'Start voice',
47
53
  voiceFailed: "Couldn't start the voice call",
48
54
  copy: 'Copy',
55
+ helpful: 'Helpful response',
56
+ unhelpful: 'Unhelpful response',
49
57
  copied: 'Copied',
50
58
  edit: 'Edit',
51
59
  save: 'Save',
@@ -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 { ToolInvocation } from '../types';
7
+ import type { ChatMessage, ToolInvocation } from '../types';
8
8
 
9
9
  /** The tool's name on the wire. */
10
10
  export const ASK_USER_QUESTION_TOOL = 'askUserQuestion';
@@ -76,3 +76,17 @@ export function formatAnswers(questions: AskedQuestion[], answers: QuestionAnswe
76
76
  })
77
77
  .join('\n\n');
78
78
  }
79
+
80
+ /**
81
+ * What Alia asked in her last turn and the person has not answered yet: the questions of the
82
+ * last message when it is hers and finished. A message after them, typed or sent from the card,
83
+ * is the answer, so nothing is pending then.
84
+ */
85
+ export function pendingQuestions(
86
+ messages: readonly Pick<ChatMessage, 'role' | 'toolInvocations'>[],
87
+ isStreaming: boolean,
88
+ ): AskedQuestion[] {
89
+ const last = messages.at(-1);
90
+ if (isStreaming || last?.role !== 'assistant') return [];
91
+ return (last.toolInvocations ?? []).flatMap((invocation) => askedQuestions(invocation) ?? []);
92
+ }
@@ -0,0 +1,53 @@
1
+ import type { AliaSuggestion } from '../types';
2
+
3
+ /**
4
+ * Which suggestions a draft gets, shared by the Alia app and the SDK chat so they offer the
5
+ * same rows: the welcome suggestions while the draft is short, and from two characters the
6
+ * search's matches with the typed part located inside each.
7
+ */
8
+
9
+ /** Up to this many rows. */
10
+ export const MAX_ROWS = 6;
11
+ /** The draft length at which the search takes over from the welcome list. */
12
+ export const SEARCH_FROM = 2;
13
+ /** How long typing settles before the search is asked. */
14
+ export const SEARCH_DEBOUNCE_MS = 200;
15
+
16
+ export interface Completion<T extends AliaSuggestion = AliaSuggestion> {
17
+ suggestion: T;
18
+ /** The typed text's range inside `suggestion.text`, or an empty range. */
19
+ matchStart: number;
20
+ matchEnd: number;
21
+ }
22
+
23
+ /** Which rows to show for a draft: welcome while short, matches from two characters. */
24
+ export function composerCompletions<T extends AliaSuggestion>(
25
+ draft: string,
26
+ welcome: readonly T[] | undefined,
27
+ matches: readonly T[] | undefined,
28
+ ): Completion<T>[] {
29
+ const query = draft.trim();
30
+ if (query.length < SEARCH_FROM) {
31
+ return (welcome ?? []).slice(0, MAX_ROWS).map((suggestion) => ({
32
+ suggestion,
33
+ matchStart: 0,
34
+ matchEnd: 0,
35
+ }));
36
+ }
37
+ const lower = query.toLowerCase();
38
+ const seen = new Set<string>();
39
+ const rows: Completion<T>[] = [];
40
+ for (const suggestion of matches ?? []) {
41
+ if (rows.length >= MAX_ROWS) break;
42
+ const text = suggestion.text.toLowerCase();
43
+ if (seen.has(text)) continue;
44
+ seen.add(text);
45
+ const start = text.indexOf(lower);
46
+ rows.push({
47
+ suggestion,
48
+ matchStart: start === -1 ? 0 : start,
49
+ matchEnd: start === -1 ? 0 : start + query.length,
50
+ });
51
+ }
52
+ return rows;
53
+ }
@@ -1,46 +1,14 @@
1
1
  import { englishWorkLog, type Translate } from './work-log-copy';
2
2
 
3
- /** Tools that have a name of their own in the catalogue. Others are named from their id. */
4
- const NAMED_PILL = new Set([
5
- 'browse',
6
- 'webSearch',
7
- 'webScraper',
8
- 'generateFile',
9
- 'sendTelegramMessage',
10
- 'sendSlack',
11
- 'sendDiscord',
12
- 'sendSignal',
13
- 'sendEmail',
14
- 'delegateToAgent',
15
- 'askAgent',
16
- // The API's app router: the model opening a connected app whose tools did not all fit in
17
- // one request.
18
- 'useApps',
19
- 'agentSearch',
20
- 'userMemory',
21
- 'shellExec',
22
- 'fileEdit',
23
- 'codeInterpreter',
24
- ]);
25
-
26
- /** Tools whose finished label differs from the running one; the rest read the same. */
27
- const NAMED_DONE = new Set([
28
- 'browse',
29
- 'webScraper',
30
- 'generateFile',
31
- 'delegateToAgent',
32
- 'askAgent',
33
- 'useApps',
34
- 'agentSearch',
35
- 'userMemory',
36
- 'shellExec',
37
- 'fileEdit',
38
- 'codeInterpreter',
39
- ]);
3
+ /** Whether the catalogue names this tool itself; otherwise the label is made from its id. */
4
+ function isNamed(kind: 'pill' | 'done', toolName: string): boolean {
5
+ const key = `tasks.tool.${kind}.${toolName}`;
6
+ return englishWorkLog(key) !== key;
7
+ }
40
8
 
41
9
  /** A tool's short label, in the reader's language when a translator is given. */
42
10
  export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog): string {
43
- if (NAMED_PILL.has(toolName)) return t(`tasks.tool.pill.${toolName}`);
11
+ if (isNamed('pill', toolName)) return t(`tasks.tool.pill.${toolName}`);
44
12
 
45
13
  // Oxy service tools: oxy_serviceName__toolName → "ServiceName"
46
14
  if (toolName.startsWith('oxy_')) {
@@ -61,7 +29,7 @@ export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog
61
29
  * ("Telegram", "Email") read the same either way, so they fall back to the running label.
62
30
  */
63
31
  export function getToolDoneLabel(toolName: string, t: Translate = englishWorkLog): string {
64
- return NAMED_DONE.has(toolName)
32
+ return isNamed('done', toolName)
65
33
  ? t(`tasks.tool.done.${toolName}`)
66
34
  : getToolPillLabel(toolName, t);
67
35
  }
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * `active`, but only once it has held for `delay` ms; false again the moment it stops. For an
5
+ * indicator that must not flash for a state that ends at once (Alia "thinking" for the instant
6
+ * before she hands the turn to an agent, where the agent is the one that thinks).
7
+ */
8
+ export function useAfterDelay(active: boolean, delay: number): boolean {
9
+ const [settled, setSettled] = useState(false);
10
+ useEffect(() => {
11
+ if (!active) {
12
+ setSettled(false);
13
+ return;
14
+ }
15
+ const timer = setTimeout(() => setSettled(true), delay);
16
+ return () => clearTimeout(timer);
17
+ }, [active, delay]);
18
+ return settled;
19
+ }
@@ -10,6 +10,7 @@ import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/tas
10
10
  import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
11
11
  import type { ToolInvocation } from '../types';
12
12
  import { extractCitationSources } from './citations';
13
+ import { domainOf } from './domain';
13
14
  import { getToolDoneLabel, getToolPillLabel } from './task-labels';
14
15
  import { toolCallStatus } from './tool-call-status';
15
16
  import { englishWorkLog, type Translate } from './work-log-copy';
@@ -47,6 +48,7 @@ function resultOf(inv: Pick<ToolInvocation, 'result'>): Record<string, unknown>
47
48
 
48
49
  /** The research the live stream reports before the answer is persisted. */
49
50
  export interface LiveResearch {
51
+ isComplete?: boolean;
50
52
  currentQuery?: string;
51
53
  sourcesFound?: number;
52
54
  sources?: Array<{ id?: unknown; url?: unknown; title?: unknown }> | null;
@@ -63,14 +65,6 @@ function str(value: unknown): string | undefined {
63
65
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
64
66
  }
65
67
 
66
- function domainOf(url: string): string {
67
- try {
68
- return new URL(url).hostname;
69
- } catch {
70
- return url;
71
- }
72
- }
73
-
74
68
  /** The site marks Bloom draws, by registrable domain. Anything else is a quiet dot. */
75
69
  const BRANDS: Array<[string, WebSearchBrand]> = [
76
70
  ['github.com', 'github'],
@@ -273,7 +267,7 @@ export function webSearchLog(
273
267
  mapped.push({
274
268
  arrived: true,
275
269
  step: {
276
- label: t('chat.bloom.researching'),
270
+ label: t(research.isComplete ? 'chat.bloom.researched' : 'chat.bloom.researching'),
277
271
  query: str(research.currentQuery),
278
272
  icon: RiBookOpenLine,
279
273
  ...(found > 0 ? { meta: t('chat.bloom.sourceCount', { count: found }) } : {}),
package/src/tools.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  // Pure functions: the status of a call, the numbered sources of a research answer, the
3
3
  // words for a tool. They load no React Native, so a host's models and tests can use them.
4
4
 
5
- export { extractCitationSources } from './lib/citations';
5
+ export {
6
+ citationLabel,
7
+ extractCitationSources,
8
+ linkifyCitations,
9
+ normalizeCitationMarkers,
10
+ } from './lib/citations';
11
+ export { domainOf } from './lib/domain';
6
12
  export type { CitationSource } from './lib/citations';
7
13
  export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
8
14
  export {
@@ -10,6 +16,7 @@ export {
10
16
  askedQuestions,
11
17
  formatAnswers,
12
18
  isQuestionInvocation,
19
+ pendingQuestions,
13
20
  } from './lib/questions';
14
21
  export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
15
22
  export { toolCallStatus } from './lib/tool-call-status';
package/src/types.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { SharedValue } from 'react-native-reanimated';
3
3
 
4
- // ── Welcome / Suggestion Types ──
4
+ // ── Suggestion Types ──
5
5
 
6
- export interface WelcomeSuggestion {
6
+ /** A line the person can pick: `text` is what is shown and what is sent. */
7
+ export interface AliaSuggestion {
7
8
  id: string;
8
- title: string;
9
- description: string;
9
+ text: string;
10
10
  }
11
11
 
12
12
  // ── Chat Message Types ──
@@ -63,7 +63,8 @@ export interface ChatMessage {
63
63
  /** Cached TTS audio URL for read-aloud */
64
64
  audioUrl?: string;
65
65
  /** Delegated agent identity */
66
- agentInfo?: { id: string; name: string; avatar: string | null };
66
+ /** `character` is the agent's look as the server stores it (JSON), parsed where it is drawn. */
67
+ agentInfo?: { id: string; name: string; character?: unknown };
67
68
  }
68
69
 
69
70
  export interface ToolInvocation {