@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.
- package/CHANGELOG.md +134 -0
- package/package.json +5 -4
- package/src/components/AliaAvatar.tsx +16 -20
- package/src/components/AliaChatContent.tsx +234 -96
- package/src/components/AliaChatHeader.tsx +102 -76
- package/src/components/AliaChatMessageList.tsx +174 -134
- package/src/components/AliaChatScreen.tsx +45 -23
- package/src/components/AliaChatSheet.tsx +151 -50
- package/src/components/AliaWelcomeMessage.tsx +26 -53
- package/src/components/AmbientField.tsx +19 -16
- package/src/components/ComposerSuggestions.tsx +45 -0
- package/src/components/IdentityMark.tsx +3 -1
- package/src/components/Markdown.tsx +18 -24
- package/src/components/QuestionCard.tsx +35 -33
- package/src/components/ThinkingBlock.tsx +9 -13
- package/src/components/VoiceModeIcon.tsx +15 -7
- package/src/components/voice/VoiceControls.tsx +17 -9
- package/src/hooks/useAliaChat.ts +124 -5
- package/src/hooks/useComposerSuggestions.ts +84 -0
- package/src/index.ts +10 -10
- package/src/lib/agent-character.ts +28 -0
- package/src/lib/chat-stream.ts +25 -3
- package/src/lib/citations.ts +62 -10
- package/src/lib/delegation.ts +34 -0
- package/src/lib/domain.ts +12 -0
- package/src/lib/flights.ts +24 -0
- package/src/lib/greeting.ts +15 -0
- package/src/lib/image-data-url.ts +26 -5
- package/src/lib/labels.tsx +10 -2
- package/src/lib/questions.ts +15 -1
- package/src/lib/suggestions.ts +53 -0
- package/src/lib/task-labels.ts +7 -39
- package/src/lib/use-after-delay.ts +19 -0
- package/src/lib/work-log.ts +3 -9
- package/src/tools.ts +8 -1
- package/src/types.ts +6 -5
package/src/lib/chat-stream.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
[
|
|
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
|
-
|
|
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) {
|
package/src/lib/citations.ts
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 = () =>
|
|
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
|
}
|
package/src/lib/labels.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
package/src/lib/questions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/task-labels.ts
CHANGED
|
@@ -1,46 +1,14 @@
|
|
|
1
1
|
import { englishWorkLog, type Translate } from './work-log-copy';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/src/lib/work-log.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
// ──
|
|
4
|
+
// ── Suggestion Types ──
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|