@alia.onl/sdk 12.0.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.
@@ -54,3 +54,62 @@ export function extractCitationSources(
54
54
 
55
55
  return [...byId.values()].sort((a, b) => a.id - b.id);
56
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,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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -48,6 +48,7 @@ function resultOf(inv: Pick<ToolInvocation, 'result'>): Record<string, unknown>
48
48
 
49
49
  /** The research the live stream reports before the answer is persisted. */
50
50
  export interface LiveResearch {
51
+ isComplete?: boolean;
51
52
  currentQuery?: string;
52
53
  sourcesFound?: number;
53
54
  sources?: Array<{ id?: unknown; url?: unknown; title?: unknown }> | null;
@@ -266,7 +267,7 @@ export function webSearchLog(
266
267
  mapped.push({
267
268
  arrived: true,
268
269
  step: {
269
- label: t('chat.bloom.researching'),
270
+ label: t(research.isComplete ? 'chat.bloom.researched' : 'chat.bloom.researching'),
270
271
  query: str(research.currentQuery),
271
272
  icon: RiBookOpenLine,
272
273
  ...(found > 0 ? { meta: t('chat.bloom.sourceCount', { count: found }) } : {}),
package/src/tools.ts CHANGED
@@ -2,7 +2,12 @@
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';
6
11
  export { domainOf } from './lib/domain';
7
12
  export type { CitationSource } from './lib/citations';
8
13
  export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
@@ -11,6 +16,7 @@ export {
11
16
  askedQuestions,
12
17
  formatAnswers,
13
18
  isQuestionInvocation,
19
+ pendingQuestions,
14
20
  } from './lib/questions';
15
21
  export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
16
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 {