@alia.onl/sdk 19.1.0 → 20.1.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 +41 -0
- package/README.md +7 -1
- package/package.json +2 -3
- package/src/components/AliaAskProvider.tsx +40 -12
- package/src/components/AliaChatContent.tsx +8 -2
- package/src/components/AliaChatSheet.tsx +33 -4
- package/src/components/AliaParticipants.tsx +31 -135
- package/src/components/AliaParticipantsHeader.tsx +8 -41
- package/src/components/AliaRecentConversations.tsx +58 -0
- package/src/components/ReplyMarkdown.tsx +32 -15
- package/src/components/thread/ThreadRow.tsx +1 -0
- package/src/index.ts +2 -3
- package/src/lib/labels.tsx +3 -0
- package/src/components/Markdown.tsx +0 -515
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 20.1.0
|
|
4
|
+
|
|
5
|
+
The one sheet is the whole surface an app needs for an agent: it picks a conversation up again and
|
|
6
|
+
the app can close it.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`useAskAgent().resume(agentKey, conversationId)`** opens the sheet of that agent on a stored
|
|
11
|
+
conversation, where the person left it (`AliaChatSheetRef.resume`).
|
|
12
|
+
- **`useAskAgent().dismiss()`**, and `AliaAskProvider` forwards a ref to the same `AliaAsk`, for
|
|
13
|
+
the code that configures the sheet and so sits above it — a host-tool executor that changed the
|
|
14
|
+
page under the sheet closes it once the turn is written.
|
|
15
|
+
- **The sheet's recent conversations.** Under the greeting, the person's three latest conversations
|
|
16
|
+
with the lead (`AliaRecentConversations`; Alia's sheet lists any). `recentConversations={false}`
|
|
17
|
+
turns it off. New label `recentConversations` (`'Recent'`).
|
|
18
|
+
- **`AliaChatContent` `welcomeAccessory`**, drawn under the empty chat's greeting.
|
|
19
|
+
|
|
20
|
+
## 20.0.0
|
|
21
|
+
|
|
22
|
+
Generic chat UI is Bloom's; the SDK composes it and keeps only what is Alia's. Two copies the SDK
|
|
23
|
+
carried of things Bloom already had are deleted (a clean cut: no aliases).
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- **`AliaMarkdown`, `AliaMarkdownProps` and `RenderCodeBlock`.** Replies render with Bloom's
|
|
28
|
+
`Markdown` (`@oxy.so/bloom/markdown`, `size="lg"`), which streams (an unfinished trailing
|
|
29
|
+
construct is closed instead of showing its markers, and only the last block is re-read), draws
|
|
30
|
+
accessible links (`role="link"`, an anchor on web) and vets every URL. `react-native-markdown-display`
|
|
31
|
+
is no longer a dependency. A Markdown image (an exclamation mark before a link) now reads as a link carrying its alt
|
|
32
|
+
text and is not fetched; Alia's own images arrive as `[ALIA_IMAGE]` blocks and are unchanged.
|
|
33
|
+
- **`joinFlights`.** The join choreography is Bloom's `useAvatarJoin` / `avatarJoinFlights`
|
|
34
|
+
(`@oxy.so/bloom/avatar-flight`), the same one Bloom's `MultiAgentChat` uses; `useParticipants`
|
|
35
|
+
wires the conversation's consulted agents into it, with the same behaviour.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **`ReplyMarkdown`** takes `streaming`, and `onCitationPress` now also receives an inline `[n]`
|
|
40
|
+
marker that names a source (Bloom's `onLinkPress`), not only the references list.
|
|
41
|
+
- **`AliaParticipantsHeader`** is Bloom's `AiChatHeader` around `AliaParticipants`.
|
|
42
|
+
- **Peer `@oxy.so/bloom` >= 10.17.0**, the first release with `useAvatarJoin` and `AiChatHeader`.
|
|
43
|
+
|
|
3
44
|
## 19.1.0
|
|
4
45
|
|
|
5
46
|
An app's own agent may be confined to that app by Oxy (Alia ADR 0021, Oxy ADRs 0044 and 0045).
|
package/README.md
CHANGED
|
@@ -163,10 +163,16 @@ import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk';
|
|
|
163
163
|
{children}
|
|
164
164
|
</AliaAskProvider>;
|
|
165
165
|
|
|
166
|
-
const { ask } = useAskAgent();
|
|
166
|
+
const { ask, resume, dismiss } = useAskAgent();
|
|
167
167
|
ask('sindi', { text: 'Is this flat a good price?' }); // signed out: Oxy's sign-in instead
|
|
168
|
+
resume('sindi', conversationId); // a stored conversation, where the person left it
|
|
169
|
+
dismiss(); // e.g. after a host tool changed the page under the sheet
|
|
168
170
|
```
|
|
169
171
|
|
|
172
|
+
The sheet offers the person's latest conversations with its lead under the greeting
|
|
173
|
+
(`recentConversations={false}` turns it off). The code that configures the sheet sits above it, so
|
|
174
|
+
`AliaAskProvider` forwards a ref to the same `AliaAsk` for it.
|
|
175
|
+
|
|
170
176
|
- **The lead.** `agent={{ id }}` (on `AliaChatSheet`, `AliaChatScreen` and `AliaChatContent`) makes
|
|
171
177
|
that agent the conversation's lead: its greeting, name, character and starters
|
|
172
178
|
(`GET /v1/agents/:id/profile`) and the header's first avatar. Omitted, Alia.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alia.onl/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "20.1.0",
|
|
4
4
|
"description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"clsx": "^2.1.1",
|
|
46
|
-
"react-native-markdown-display": "^7.0.2",
|
|
47
46
|
"socket.io-client": "^4.8.3",
|
|
48
47
|
"tailwind-merge": "^3.7.0",
|
|
49
48
|
"zustand": "^5.0.15"
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
"react": "^19.2.3",
|
|
53
52
|
"react-native": "^0.86.0",
|
|
54
53
|
"@expo/vector-icons": "^15.1.1",
|
|
55
|
-
"@oxy.so/bloom": ">=10.
|
|
54
|
+
"@oxy.so/bloom": ">=10.17.0",
|
|
56
55
|
"@oxy.so/core": ">=3.0.0",
|
|
57
56
|
"@oxy.so/services": ">=11.12.0",
|
|
58
57
|
"@tanstack/react-query": "^5.101",
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { useAuth } from '@oxy.so/services/ui/client';
|
|
11
11
|
import {
|
|
12
12
|
createContext,
|
|
13
|
+
forwardRef,
|
|
13
14
|
type ReactNode,
|
|
14
15
|
useCallback,
|
|
15
16
|
useContext,
|
|
16
17
|
useEffect,
|
|
18
|
+
useImperativeHandle,
|
|
17
19
|
useMemo,
|
|
18
20
|
useRef,
|
|
19
21
|
useState,
|
|
@@ -38,6 +40,7 @@ export interface AliaAskProviderProps
|
|
|
38
40
|
| 'apiUrl'
|
|
39
41
|
| 'characterRuntimeUrl'
|
|
40
42
|
| 'headerMenu'
|
|
43
|
+
| 'recentConversations'
|
|
41
44
|
> {
|
|
42
45
|
/** The agents `ask` opens, by key. Omitted, the sheet only ever opens on Alia. */
|
|
43
46
|
agents?: AliaAskAgents;
|
|
@@ -52,6 +55,13 @@ export interface AliaAsk {
|
|
|
52
55
|
* ("ask Sindi about this listing"). Signed out, opens Oxy's sign-in instead.
|
|
53
56
|
*/
|
|
54
57
|
ask: (agentKey?: string, turn?: AliaTurn) => void;
|
|
58
|
+
/** Open the sheet on the agent `agentKey` (omitted, Alia), on its stored conversation `conversationId`. */
|
|
59
|
+
resume: (agentKey: string | undefined, conversationId: string) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Close the sheet — a host tool that changed the page under it shows the person the result, once
|
|
62
|
+
* the turn has been written.
|
|
63
|
+
*/
|
|
64
|
+
dismiss: () => void;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
const AskContext = createContext<AliaAsk | null>(null);
|
|
@@ -67,16 +77,20 @@ export function useAskAgent(): AliaAsk {
|
|
|
67
77
|
interface Opening {
|
|
68
78
|
key: string | undefined;
|
|
69
79
|
turn: AliaTurn | undefined;
|
|
80
|
+
/** A stored conversation to open instead of the welcome. */
|
|
81
|
+
conversationId?: string;
|
|
70
82
|
/** Changes on every `ask`, so asking the same agent twice opens twice. */
|
|
71
83
|
serial: number;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Mounts the app's one sheet. Its ref is the same `AliaAsk` as `useAskAgent()`, for the code that
|
|
88
|
+
* configures the sheet and so sits above it — a host-tool executor that closes it.
|
|
89
|
+
*/
|
|
90
|
+
export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(function AliaAskProvider(
|
|
91
|
+
{ agents, welcomeSuggestions, children, ...sheet },
|
|
92
|
+
ref,
|
|
93
|
+
) {
|
|
80
94
|
const { canUsePrivateApi, signIn } = useAuth();
|
|
81
95
|
const sheetRef = useRef<AliaChatSheetRef>(null);
|
|
82
96
|
const [opening, setOpening] = useState<Opening | null>(null);
|
|
@@ -84,8 +98,8 @@ export function AliaAskProvider({
|
|
|
84
98
|
const agentsRef = useRef(agents);
|
|
85
99
|
agentsRef.current = agents;
|
|
86
100
|
|
|
87
|
-
const
|
|
88
|
-
(agentKey
|
|
101
|
+
const open = useCallback(
|
|
102
|
+
(agentKey: string | undefined, turn?: AliaTurn, conversationId?: string) => {
|
|
89
103
|
if (agentKey !== undefined && agentsRef.current?.[agentKey] === undefined) {
|
|
90
104
|
throw new Error(`AliaAskProvider has no agent "${agentKey}"`);
|
|
91
105
|
}
|
|
@@ -94,21 +108,35 @@ export function AliaAskProvider({
|
|
|
94
108
|
return;
|
|
95
109
|
}
|
|
96
110
|
setCurrent(agentKey);
|
|
97
|
-
setOpening((previous) => ({
|
|
111
|
+
setOpening((previous) => ({
|
|
112
|
+
key: agentKey,
|
|
113
|
+
turn,
|
|
114
|
+
...(conversationId === undefined ? {} : { conversationId }),
|
|
115
|
+
serial: (previous?.serial ?? 0) + 1,
|
|
116
|
+
}));
|
|
98
117
|
},
|
|
99
118
|
[canUsePrivateApi, signIn],
|
|
100
119
|
);
|
|
120
|
+
const ask = useCallback((agentKey?: string, turn?: AliaTurn) => open(agentKey, turn), [open]);
|
|
121
|
+
const resume = useCallback(
|
|
122
|
+
(agentKey: string | undefined, conversationId: string) =>
|
|
123
|
+
open(agentKey, undefined, conversationId),
|
|
124
|
+
[open],
|
|
125
|
+
);
|
|
126
|
+
const dismiss = useCallback(() => sheetRef.current?.dismiss(), []);
|
|
101
127
|
|
|
102
128
|
// Presented once the sheet for that agent is the one mounted (a different agent is a new sheet).
|
|
103
129
|
useEffect(() => {
|
|
104
130
|
if (opening === null || opening.key !== current) return;
|
|
105
|
-
sheetRef.current?.
|
|
131
|
+
if (opening.conversationId !== undefined) sheetRef.current?.resume(opening.conversationId);
|
|
132
|
+
else sheetRef.current?.present(opening.turn);
|
|
106
133
|
setOpening(null);
|
|
107
134
|
}, [opening, current]);
|
|
108
135
|
|
|
109
136
|
const agentId = current === undefined ? undefined : agents?.[current]?.id;
|
|
110
137
|
const agent = useMemo(() => (agentId === undefined ? undefined : { id: agentId }), [agentId]);
|
|
111
|
-
const value = useMemo<AliaAsk>(() => ({ ask }), [ask]);
|
|
138
|
+
const value = useMemo<AliaAsk>(() => ({ ask, resume, dismiss }), [ask, resume, dismiss]);
|
|
139
|
+
useImperativeHandle(ref, () => value, [value]);
|
|
112
140
|
|
|
113
141
|
return (
|
|
114
142
|
<AskContext.Provider value={value}>
|
|
@@ -124,4 +152,4 @@ export function AliaAskProvider({
|
|
|
124
152
|
) : null}
|
|
125
153
|
</AskContext.Provider>
|
|
126
154
|
);
|
|
127
|
-
}
|
|
155
|
+
});
|
|
@@ -170,6 +170,8 @@ export interface AliaChatContentProps {
|
|
|
170
170
|
welcomeSubtitle?: string;
|
|
171
171
|
/** The lines offered over the composer on an empty chat; omitted, the agent's starters. */
|
|
172
172
|
welcomeSuggestions?: AliaSuggestion[];
|
|
173
|
+
/** Drawn under the empty chat's greeting — the sheet's recent conversations with its lead. */
|
|
174
|
+
welcomeAccessory?: ReactNode;
|
|
173
175
|
/** What the host found for the typed text (see `onSuggestionQuery`). */
|
|
174
176
|
suggestionMatches?: AliaSuggestion[];
|
|
175
177
|
/** The typed text, once typing has settled, for the host to search suggestions with. */
|
|
@@ -279,6 +281,7 @@ function AliaConversationView({
|
|
|
279
281
|
welcomeGreeting,
|
|
280
282
|
welcomeSubtitle,
|
|
281
283
|
welcomeSuggestions,
|
|
284
|
+
welcomeAccessory,
|
|
282
285
|
suggestionMatches,
|
|
283
286
|
onSuggestionQuery,
|
|
284
287
|
onSuggestionPicked,
|
|
@@ -471,9 +474,12 @@ function AliaConversationView({
|
|
|
471
474
|
const welcome = useMemo(
|
|
472
475
|
() =>
|
|
473
476
|
profileLoading ? null : (
|
|
474
|
-
<
|
|
477
|
+
<View className="items-center gap-8">
|
|
478
|
+
<AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} lead={lead} />
|
|
479
|
+
{welcomeAccessory}
|
|
480
|
+
</View>
|
|
475
481
|
),
|
|
476
|
-
[profileLoading, greeting, welcomeSubtitle, lead],
|
|
482
|
+
[profileLoading, greeting, welcomeSubtitle, lead, welcomeAccessory],
|
|
477
483
|
);
|
|
478
484
|
|
|
479
485
|
// ── The agent's offer to start the next stretch fresh ──
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type AliaChatContentProps,
|
|
31
31
|
} from './AliaChatContent';
|
|
32
32
|
import { AliaChatHeader } from './AliaChatHeader';
|
|
33
|
+
import { AliaRecentConversations } from './AliaRecentConversations';
|
|
33
34
|
import { ThoughtPanel } from './thought/ThoughtPanel';
|
|
34
35
|
import type { AliaSuggestion } from '../types';
|
|
35
36
|
|
|
@@ -88,6 +89,8 @@ export interface AliaChatSheetProps {
|
|
|
88
89
|
onShare?: (share: AliaConversationShare) => void;
|
|
89
90
|
/** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
|
|
90
91
|
headerMenu?: React.ReactNode;
|
|
92
|
+
/** The person's latest conversations with the lead, under the greeting. Default `true`. */
|
|
93
|
+
recentConversations?: boolean;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export interface AliaChatSheetRef {
|
|
@@ -97,6 +100,9 @@ export interface AliaChatSheetRef {
|
|
|
97
100
|
* opens on its welcome, with the composer focused.
|
|
98
101
|
*/
|
|
99
102
|
present: (turn?: AliaTurn) => void;
|
|
103
|
+
/** Opens the sheet on a stored conversation with its lead, where the person left it. */
|
|
104
|
+
resume: (conversationId: string) => void;
|
|
105
|
+
/** Closes the sheet and what is stacked on it, stopping a turn still being written. */
|
|
100
106
|
dismiss: () => void;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -121,6 +127,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
121
127
|
characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
|
|
122
128
|
onShare,
|
|
123
129
|
headerMenu,
|
|
130
|
+
recentConversations = true,
|
|
124
131
|
},
|
|
125
132
|
ref,
|
|
126
133
|
) => {
|
|
@@ -135,9 +142,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
135
142
|
const mainStopRef = useRef<(() => void) | null>(null);
|
|
136
143
|
const conversationStopRef = useRef<(() => void) | null>(null);
|
|
137
144
|
|
|
138
|
-
// The conversation sheet, stacked over the main one while a chat is open
|
|
139
|
-
// every
|
|
140
|
-
const [conversation, setConversation] = useState<{
|
|
145
|
+
// The conversation sheet, stacked over the main one while a chat is open: begun by a first
|
|
146
|
+
// turn, or a stored one picked up again. The key makes every opening a fresh chat.
|
|
147
|
+
const [conversation, setConversation] = useState<{
|
|
148
|
+
key: number;
|
|
149
|
+
turn?: AliaTurn;
|
|
150
|
+
conversationId?: string;
|
|
151
|
+
} | null>(null);
|
|
141
152
|
|
|
142
153
|
const registerMainStop = useCallback((stop: (() => void) | null) => {
|
|
143
154
|
mainStopRef.current = stop;
|
|
@@ -149,6 +160,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
149
160
|
const startConversation = useCallback((turn: AliaTurn) => {
|
|
150
161
|
setConversation((current) => ({ key: (current?.key ?? 0) + 1, turn }));
|
|
151
162
|
}, []);
|
|
163
|
+
const resumeConversation = useCallback((conversationId: string) => {
|
|
164
|
+
setConversation((current) => ({ key: (current?.key ?? 0) + 1, conversationId }));
|
|
165
|
+
}, []);
|
|
166
|
+
// The chat inside follows its conversation (a first turn creates it, "start over" leaves it).
|
|
167
|
+
const followConversation = useCallback((conversationId: string | undefined) => {
|
|
168
|
+
setConversation((current) => (current ? { ...current, conversationId } : current));
|
|
169
|
+
}, []);
|
|
152
170
|
useEffect(() => {
|
|
153
171
|
if (conversation) conversationRef.current?.present();
|
|
154
172
|
}, [conversation]);
|
|
@@ -197,9 +215,13 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
197
215
|
mainRef.current?.present();
|
|
198
216
|
if (turn) startConversation(turn);
|
|
199
217
|
},
|
|
218
|
+
resume: (conversationId) => {
|
|
219
|
+
mainRef.current?.present();
|
|
220
|
+
resumeConversation(conversationId);
|
|
221
|
+
},
|
|
200
222
|
dismiss: handleDismiss,
|
|
201
223
|
}),
|
|
202
|
-
[handleDismiss, startConversation],
|
|
224
|
+
[handleDismiss, startConversation, resumeConversation],
|
|
203
225
|
);
|
|
204
226
|
|
|
205
227
|
// Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
|
|
@@ -282,6 +304,11 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
282
304
|
welcomeSuggestions={welcomeSuggestions}
|
|
283
305
|
suggestionMatches={suggestionMatches}
|
|
284
306
|
onSuggestionQuery={onSuggestionQuery}
|
|
307
|
+
welcomeAccessory={
|
|
308
|
+
recentConversations ? (
|
|
309
|
+
<AliaRecentConversations agentId={agent?.id} onOpen={resumeConversation} />
|
|
310
|
+
) : undefined
|
|
311
|
+
}
|
|
285
312
|
presentedProgress={mainProgress}
|
|
286
313
|
onStopReady={registerMainStop}
|
|
287
314
|
onStart={startConversation}
|
|
@@ -303,6 +330,8 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
303
330
|
<AliaChatContent
|
|
304
331
|
{...shared}
|
|
305
332
|
initialTurn={conversation.turn}
|
|
333
|
+
conversationId={conversation.conversationId}
|
|
334
|
+
onConversationChange={followConversation}
|
|
306
335
|
presentedProgress={conversationProgress}
|
|
307
336
|
onStopReady={registerConversationStop}
|
|
308
337
|
chrome={conversationChrome}
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The conversation's participants: Bloom's `AgentProfile` with `[lead, ...joined]`, and the
|
|
3
|
-
*
|
|
3
|
+
* agents joining it (ADR 0020 §7).
|
|
4
4
|
*
|
|
5
5
|
* When the lead consults another agent (`alia.agent_start`), the agent's reply appears in the
|
|
6
6
|
* thread under its own identity (anchor `reply:<agentId>`), and that avatar flies into the header
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* when there is nothing to measure, it gains it at once. An agent already in
|
|
10
|
-
* when the chat opens is simply there.
|
|
7
|
+
* through Bloom's `useAvatarJoin`: one choreography for every multi-agent conversation, Bloom's
|
|
8
|
+
* own `MultiAgentChat` included. The header gains the agent only when its flight lands; with
|
|
9
|
+
* reduced motion, or when there is nothing to measure, it gains it at once. An agent already in
|
|
10
|
+
* the conversation when the chat opens is simply there.
|
|
11
11
|
*
|
|
12
|
-
* This is the ONLY file that draws `AgentProfile` or
|
|
13
|
-
*
|
|
14
|
-
* one header.
|
|
12
|
+
* This is the ONLY file that draws `AgentProfile` or joins agents to it (`one-thread.test.tsx`
|
|
13
|
+
* holds it), so the header of the sheet, the screen and the Alia app are one header.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { AgentProfile } from '@oxy.so/bloom/agent-reply';
|
|
18
|
-
import { type AvatarFlights,
|
|
19
|
-
import { memo,
|
|
17
|
+
import { type AvatarFlights, useAvatarJoin } from '@oxy.so/bloom/avatar-flight';
|
|
18
|
+
import { memo, useMemo } from 'react';
|
|
20
19
|
import { ANCHORS, useAliaFlights } from '../lib/flights';
|
|
21
20
|
import {
|
|
22
21
|
type AliaChatParticipants,
|
|
@@ -26,45 +25,6 @@ import {
|
|
|
26
25
|
} from '../lib/participants';
|
|
27
26
|
import type { Message } from '../runtime/types';
|
|
28
27
|
|
|
29
|
-
/** As Bloom's multi-agent chat flies a waiting agent into its profile. */
|
|
30
|
-
const JOIN_DURATION = 950;
|
|
31
|
-
const JOIN_STAGGER = 120;
|
|
32
|
-
const JOIN_LANDING_SIZE = 14;
|
|
33
|
-
|
|
34
|
-
/** The flights of agents joining the header: from each one's reply, merging into the profile. */
|
|
35
|
-
export function joinFlights(agents: readonly AliaParticipant[]) {
|
|
36
|
-
return agents.map((agent, index) => ({
|
|
37
|
-
key: `join:${agent.id}`,
|
|
38
|
-
character: agent.character,
|
|
39
|
-
from: ANCHORS.reply(agent.id),
|
|
40
|
-
to: ANCHORS.profile,
|
|
41
|
-
landing: centeredLanding(JOIN_LANDING_SIZE),
|
|
42
|
-
duration: JOIN_DURATION,
|
|
43
|
-
delay: index * JOIN_STAGGER,
|
|
44
|
-
merge: true,
|
|
45
|
-
}));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Two frames: the reply that carries the avatar mounts, then lays out, before it is measured. */
|
|
49
|
-
function afterLayout(run: () => void): () => void {
|
|
50
|
-
const raf =
|
|
51
|
-
typeof requestAnimationFrame === 'function'
|
|
52
|
-
? requestAnimationFrame
|
|
53
|
-
: (callback: () => void) => setTimeout(callback, 16) as unknown as number;
|
|
54
|
-
const cancelRaf =
|
|
55
|
-
typeof cancelAnimationFrame === 'function'
|
|
56
|
-
? cancelAnimationFrame
|
|
57
|
-
: (id: number) => clearTimeout(id as unknown as ReturnType<typeof setTimeout>);
|
|
58
|
-
let second: number | undefined;
|
|
59
|
-
const first = raf(() => {
|
|
60
|
-
second = raf(run);
|
|
61
|
-
});
|
|
62
|
-
return () => {
|
|
63
|
-
cancelRaf(first);
|
|
64
|
-
if (second !== undefined) cancelRaf(second);
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
28
|
export interface UseParticipantsOptions {
|
|
69
29
|
lead: AliaLead;
|
|
70
30
|
messages: readonly Message[];
|
|
@@ -72,97 +32,33 @@ export interface UseParticipantsOptions {
|
|
|
72
32
|
flights: AvatarFlights | null;
|
|
73
33
|
}
|
|
74
34
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* welcome flight) is in the air wait for it to land, then fly together, staggered.
|
|
80
|
-
*/
|
|
35
|
+
const fromReply = (agent: AliaParticipant) => ANCHORS.reply(agent.id);
|
|
36
|
+
const characterOf = (agent: AliaParticipant) => agent.character;
|
|
37
|
+
|
|
38
|
+
/** Who the header shows: the lead, then the agents it consulted as each one's avatar lands. */
|
|
81
39
|
export function useParticipants({
|
|
82
40
|
lead,
|
|
83
41
|
messages,
|
|
84
42
|
flights,
|
|
85
43
|
}: UseParticipantsOptions): AliaChatParticipants {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
const known = new Set([
|
|
106
|
-
...joined.map((agent) => agent.id),
|
|
107
|
-
...queue.current.map((agent) => agent.id),
|
|
108
|
-
...(batch?.agents.map((agent) => agent.id) ?? []),
|
|
109
|
-
]);
|
|
110
|
-
const already: AliaParticipant[] = [];
|
|
111
|
-
let waiting = false;
|
|
112
|
-
for (const { participant, live } of consulted) {
|
|
113
|
-
if (known.has(participant.id)) continue;
|
|
114
|
-
if (live && flightsRef.current !== null) {
|
|
115
|
-
queue.current = [...queue.current, participant];
|
|
116
|
-
waiting = true;
|
|
117
|
-
} else {
|
|
118
|
-
already.push(participant);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (already.length > 0) setJoined((current) => [...current, ...already]);
|
|
122
|
-
if (waiting) setQueued((count) => count + 1);
|
|
123
|
-
}, [consulted, joined, batch]);
|
|
124
|
-
|
|
125
|
-
// A batch takes off once nothing else is landing on the header.
|
|
126
|
-
const landing = flights?.isLanding(ANCHORS.profile) ?? false;
|
|
127
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `queued` signals the queue (a ref) grew
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
if (batch !== null || landing || queue.current.length === 0) return;
|
|
130
|
-
const agents = queue.current;
|
|
131
|
-
queue.current = [];
|
|
132
|
-
setBatch({ agents, launched: false });
|
|
133
|
-
}, [batch, landing, queued]);
|
|
134
|
-
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
if (batch === null || batch.launched) return;
|
|
137
|
-
let live = true;
|
|
138
|
-
const cancel = afterLayout(() => {
|
|
139
|
-
const current = flightsRef.current;
|
|
140
|
-
void (current ? current.launch(joinFlights(batch.agents)) : Promise.resolve(false)).then(
|
|
141
|
-
(flying) => {
|
|
142
|
-
if (!live) return;
|
|
143
|
-
if (flying) setBatch({ agents: batch.agents, launched: true });
|
|
144
|
-
else {
|
|
145
|
-
// Reduced motion, nothing measurable, or superseded: they join without flying.
|
|
146
|
-
setJoined((current) => [...current, ...batch.agents]);
|
|
147
|
-
setBatch(null);
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
return () => {
|
|
153
|
-
live = false;
|
|
154
|
-
cancel();
|
|
155
|
-
};
|
|
156
|
-
}, [batch]);
|
|
157
|
-
|
|
158
|
-
// The batch is in: the header gains its agents as their avatars land.
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
if (batch === null || !batch.launched || landing) return;
|
|
161
|
-
setJoined((current) => [...current, ...batch.agents]);
|
|
162
|
-
setBatch(null);
|
|
163
|
-
}, [batch, landing]);
|
|
164
|
-
|
|
165
|
-
return useMemo(() => ({ lead, joined }), [lead, joined]);
|
|
44
|
+
const arrivals = useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
consultedAgents(messages, lead.id).map(({ participant, live }) => ({
|
|
47
|
+
agent: participant,
|
|
48
|
+
live,
|
|
49
|
+
})),
|
|
50
|
+
[messages, lead.id],
|
|
51
|
+
);
|
|
52
|
+
const joined = useAvatarJoin({
|
|
53
|
+
arrivals,
|
|
54
|
+
flights,
|
|
55
|
+
from: fromReply,
|
|
56
|
+
to: ANCHORS.profile,
|
|
57
|
+
character: characterOf,
|
|
58
|
+
// A new lead is a new conversation: nobody has joined it.
|
|
59
|
+
resetKey: lead.id,
|
|
60
|
+
});
|
|
61
|
+
return useMemo(() => ({ lead, joined: [...joined] }), [lead, joined]);
|
|
166
62
|
}
|
|
167
63
|
|
|
168
64
|
export interface AliaParticipantsProps {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button } from '@oxy.so/bloom/button';
|
|
3
|
-
import { RiMenuLine } from '@oxy.so/bloom/icons/RiMenuLine';
|
|
1
|
+
import { AiChatHeader } from '@oxy.so/bloom/ai-chat';
|
|
4
2
|
import { memo } from 'react';
|
|
5
|
-
import { View } from 'react-native';
|
|
6
3
|
import type { AliaChatParticipants } from '../lib/participants';
|
|
7
4
|
import { AliaParticipants } from './AliaParticipants';
|
|
8
5
|
|
|
@@ -13,48 +10,18 @@ export interface AliaParticipantsHeaderProps {
|
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
|
-
* The conversation's header inside Bloom's AI chat shell: the participants as its title,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* of its own. Outside a shell it is the participants alone.
|
|
13
|
+
* The conversation's header inside Bloom's AI chat shell: the participants as its title, in
|
|
14
|
+
* Bloom's `AiChatHeader`, between the shell's own navigation and panel buttons on a compact
|
|
15
|
+
* screen. A host puts this in `chrome.header` instead of drawing a title of its own. Outside a
|
|
16
|
+
* shell it is the participants alone.
|
|
21
17
|
*/
|
|
22
18
|
export const AliaParticipantsHeader = memo(function AliaParticipantsHeader({
|
|
23
19
|
participants,
|
|
24
20
|
onPress,
|
|
25
21
|
}: AliaParticipantsHeaderProps) {
|
|
26
|
-
const shell = useAiChatShell();
|
|
27
|
-
const nav = shell?.compact === true && shell.navCollapsed && shell.hasNav;
|
|
28
|
-
const panel = shell?.compact === true && shell.hasPanel;
|
|
29
22
|
return (
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
<Button
|
|
34
|
-
size="md"
|
|
35
|
-
icon={RiMenuLine}
|
|
36
|
-
accessibilityLabel={shell.labels.openNavigation}
|
|
37
|
-
onPress={shell.openNav}
|
|
38
|
-
appearance="plain"
|
|
39
|
-
tone="neutral"
|
|
40
|
-
/>
|
|
41
|
-
) : null}
|
|
42
|
-
</View>
|
|
43
|
-
<View className="min-w-0 shrink items-center">
|
|
44
|
-
<AliaParticipants participants={participants} onPress={onPress} />
|
|
45
|
-
</View>
|
|
46
|
-
<View className="w-10 items-end">
|
|
47
|
-
{panel && shell ? (
|
|
48
|
-
<Button
|
|
49
|
-
size="md"
|
|
50
|
-
icon={shell.panelIcon}
|
|
51
|
-
accessibilityLabel={shell.labels.openPanel(shell.panelLabel)}
|
|
52
|
-
onPress={shell.openPanel}
|
|
53
|
-
appearance="plain"
|
|
54
|
-
tone="neutral"
|
|
55
|
-
/>
|
|
56
|
-
) : null}
|
|
57
|
-
</View>
|
|
58
|
-
</View>
|
|
23
|
+
<AiChatHeader>
|
|
24
|
+
<AliaParticipants participants={participants} onPress={onPress} />
|
|
25
|
+
</AiChatHeader>
|
|
59
26
|
);
|
|
60
27
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Item } from '@oxy.so/bloom/item';
|
|
2
|
+
import { Text } from '@oxy.so/bloom/typography';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { View } from 'react-native';
|
|
5
|
+
import { useLabels } from '../lib/labels';
|
|
6
|
+
import { useConversations } from '../runtime/conversations';
|
|
7
|
+
|
|
8
|
+
/** How many of the lead's conversations the sheet offers to pick up again. */
|
|
9
|
+
export const RECENT_CONVERSATIONS_SHOWN = 3;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The person's latest conversations with the sheet's lead, under its greeting: "Ask Sindi" picks
|
|
13
|
+
* up where it left off instead of every opening being a blank page. Alia's own sheet lists the
|
|
14
|
+
* person's latest conversations of any kind. Nothing while loading or when there are none.
|
|
15
|
+
*/
|
|
16
|
+
export function AliaRecentConversations({
|
|
17
|
+
agentId,
|
|
18
|
+
onOpen,
|
|
19
|
+
}: {
|
|
20
|
+
/** The lead; omitted, Alia. */
|
|
21
|
+
agentId?: string;
|
|
22
|
+
onOpen: (conversationId: string) => void;
|
|
23
|
+
}) {
|
|
24
|
+
const labels = useLabels();
|
|
25
|
+
const { data } = useConversations(agentId === undefined ? {} : { agentId });
|
|
26
|
+
const recent = useMemo(
|
|
27
|
+
() => data?.pages[0]?.conversations.slice(0, RECENT_CONVERSATIONS_SHOWN) ?? [],
|
|
28
|
+
[data],
|
|
29
|
+
);
|
|
30
|
+
const when = useMemo(
|
|
31
|
+
() => new Intl.DateTimeFormat(labels.locale, { day: 'numeric', month: 'short' }),
|
|
32
|
+
[labels.locale],
|
|
33
|
+
);
|
|
34
|
+
if (recent.length === 0) return null;
|
|
35
|
+
return (
|
|
36
|
+
<View accessibilityRole="list" className="w-full max-w-[480px] self-center gap-1">
|
|
37
|
+
<Text className="px-3 text-sm text-text-secondary">{labels.recentConversations}</Text>
|
|
38
|
+
{recent.map((conversation) => {
|
|
39
|
+
const title = conversation.title || labels.newChat;
|
|
40
|
+
return (
|
|
41
|
+
<Item
|
|
42
|
+
key={conversation.id}
|
|
43
|
+
density="compact"
|
|
44
|
+
accessibilityRole="button"
|
|
45
|
+
accessibilityLabel={title}
|
|
46
|
+
onPress={() => onOpen(conversation.id)}
|
|
47
|
+
title={<Text numberOfLines={1}>{title}</Text>}
|
|
48
|
+
trailing={
|
|
49
|
+
<Text className="text-xs text-text-tertiary">
|
|
50
|
+
{when.format(new Date(conversation.updatedAt))}
|
|
51
|
+
</Text>
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CodeBlock } from '@oxy.so/bloom/code';
|
|
2
|
+
import { Markdown } from '@oxy.so/bloom/markdown';
|
|
2
3
|
import { Text } from '@oxy.so/bloom/typography';
|
|
3
4
|
import * as Clipboard from 'expo-clipboard';
|
|
4
5
|
import * as WebBrowser from 'expo-web-browser';
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
} from '../lib/citations';
|
|
14
15
|
import { useLabels } from '../lib/labels';
|
|
15
16
|
import type { ToolInvocation } from '../runtime/types';
|
|
16
|
-
import { AliaMarkdown, type RenderCodeBlock } from './Markdown';
|
|
17
17
|
import { Banner, CompactList, Comparison, Credibility, RichImage, Timeline } from './RichBlocks';
|
|
18
18
|
|
|
19
19
|
// Cheap pre-check: plain markdown (the overwhelmingly common case) skips the
|
|
@@ -227,13 +227,11 @@ function openSource(url: string): void {
|
|
|
227
227
|
/**
|
|
228
228
|
* The references section of a research answer, as links.
|
|
229
229
|
*
|
|
230
|
-
* The API
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* Tab reaches it and Enter opens it — named "Source n: title". Older answers,
|
|
236
|
-
* whose entries were a title over a bare URL, split the same way.
|
|
230
|
+
* The API writes each entry as a Markdown link. The section is cut off the
|
|
231
|
+
* Markdown and drawn here as a list of sources rather than running text: one
|
|
232
|
+
* `accessibilityRole="link"` per entry — an `<a href>` on web, so Tab reaches
|
|
233
|
+
* it and Enter opens it — named "Source n: title", with its domain. Older
|
|
234
|
+
* answers, whose entries were a title over a bare URL, split the same way.
|
|
237
235
|
*/
|
|
238
236
|
function ReferenceList({
|
|
239
237
|
entries,
|
|
@@ -300,10 +298,10 @@ const copyCode = async (code: string) => {
|
|
|
300
298
|
|
|
301
299
|
/**
|
|
302
300
|
* Fenced code as Bloom's code card: the language chip, a copy button and
|
|
303
|
-
* numbered lines. Module-level so its identity is stable
|
|
304
|
-
*
|
|
301
|
+
* numbered lines. Module-level so its identity is stable, as Bloom's
|
|
302
|
+
* `Markdown` asks of `renderCodeBlock`.
|
|
305
303
|
*/
|
|
306
|
-
const renderCodeBlock
|
|
304
|
+
const renderCodeBlock = (code: string, language?: string) => (
|
|
307
305
|
<CodeBlock code={code} language={language} onCopy={copyCode} />
|
|
308
306
|
);
|
|
309
307
|
|
|
@@ -318,20 +316,23 @@ const renderCodeBlock: RenderCodeBlock = (code, language) => (
|
|
|
318
316
|
* are normalised first. A message with neither prop renders exactly as
|
|
319
317
|
* before.
|
|
320
318
|
*
|
|
321
|
-
* `onCitationPress` replaces opening
|
|
322
|
-
*
|
|
323
|
-
*
|
|
319
|
+
* `onCitationPress` replaces opening a source's URL, for the references list
|
|
320
|
+
* and for an inline marker alike; any other link opens as Bloom's `Markdown`
|
|
321
|
+
* opens it. `streaming` is the reply still arriving: Bloom's `Markdown` closes
|
|
322
|
+
* an unfinished trailing construct instead of showing its raw markers.
|
|
324
323
|
*/
|
|
325
324
|
export function ReplyMarkdown({
|
|
326
325
|
content,
|
|
327
326
|
toolInvocations,
|
|
328
327
|
researchSources,
|
|
329
328
|
onCitationPress,
|
|
329
|
+
streaming = false,
|
|
330
330
|
}: {
|
|
331
331
|
content: string;
|
|
332
332
|
toolInvocations?: ToolInvocation[];
|
|
333
333
|
researchSources?: Array<{ id: number; url: string; title: string }> | null;
|
|
334
334
|
onCitationPress?: (source: CitationSource) => void;
|
|
335
|
+
streaming?: boolean;
|
|
335
336
|
}) {
|
|
336
337
|
const sources = useMemo(
|
|
337
338
|
() => extractCitationSources(toolInvocations, researchSources),
|
|
@@ -340,13 +341,29 @@ export function ReplyMarkdown({
|
|
|
340
341
|
const { body, references } = useMemo(() => splitReferences(content, sources), [content, sources]);
|
|
341
342
|
const linked = useMemo(() => linkifyCitations(body, sources), [body, sources]);
|
|
342
343
|
const blocks = useMemo(() => parseSpecialBlocks(linked), [linked]);
|
|
344
|
+
const onLinkPress = useMemo(() => {
|
|
345
|
+
if (!onCitationPress) return undefined;
|
|
346
|
+
return (url: string) => {
|
|
347
|
+
const source = sources.find((entry) => entry.url === url);
|
|
348
|
+
if (source) onCitationPress(source);
|
|
349
|
+
else openSource(url);
|
|
350
|
+
};
|
|
351
|
+
}, [onCitationPress, sources]);
|
|
343
352
|
|
|
344
353
|
return (
|
|
345
354
|
<View>
|
|
346
355
|
{blocks.map((block, idx) => {
|
|
347
356
|
if (block.type === 'text') {
|
|
348
357
|
return (
|
|
349
|
-
<
|
|
358
|
+
<Markdown
|
|
359
|
+
key={idx}
|
|
360
|
+
size="lg"
|
|
361
|
+
streaming={streaming && idx === blocks.length - 1}
|
|
362
|
+
renderCodeBlock={renderCodeBlock}
|
|
363
|
+
onLinkPress={onLinkPress}
|
|
364
|
+
>
|
|
365
|
+
{block.content}
|
|
366
|
+
</Markdown>
|
|
350
367
|
);
|
|
351
368
|
} else if (block.type === 'block' && block.blockType) {
|
|
352
369
|
return renderBlock(block.blockType, block.data, idx);
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type {
|
|
|
10
10
|
AliaChatContentProps,
|
|
11
11
|
AliaChatState,
|
|
12
12
|
} from './components/AliaChatContent';
|
|
13
|
-
export { AliaParticipants,
|
|
13
|
+
export { AliaParticipants, useParticipants } from './components/AliaParticipants';
|
|
14
14
|
export type { AliaParticipantsProps, UseParticipantsOptions } from './components/AliaParticipants';
|
|
15
15
|
export { AliaParticipantsHeader } from './components/AliaParticipantsHeader';
|
|
16
16
|
export type { AliaParticipantsHeaderProps } from './components/AliaParticipantsHeader';
|
|
@@ -27,6 +27,7 @@ export type { AliaAddMenu } from './lib/add-menu';
|
|
|
27
27
|
export type { AliaVoiceOptions } from './hooks/useChatVoice';
|
|
28
28
|
export { AliaChatSheet } from './components/AliaChatSheet';
|
|
29
29
|
export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
|
|
30
|
+
export { AliaRecentConversations } from './components/AliaRecentConversations';
|
|
30
31
|
export type { AliaTurn } from './hooks/useChatComposer';
|
|
31
32
|
export { AliaChatScreen } from './components/AliaChatScreen';
|
|
32
33
|
export type { AliaChatScreenProps } from './components/AliaChatScreen';
|
|
@@ -47,8 +48,6 @@ export { useAliaActivity } from './hooks/useAliaActivity';
|
|
|
47
48
|
export type { UseAliaActivityOptions } from './hooks/useAliaActivity';
|
|
48
49
|
export { IdentityMark } from './components/IdentityMark';
|
|
49
50
|
export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
|
|
50
|
-
export { AliaMarkdown } from './components/Markdown';
|
|
51
|
-
export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
|
|
52
51
|
export { ReplyMarkdown } from './components/ReplyMarkdown';
|
|
53
52
|
export { AliaThread } from './components/thread/AliaThread';
|
|
54
53
|
export type { AliaThreadProps, NewConversationOfferProps } from './components/thread/AliaThread';
|
package/src/lib/labels.tsx
CHANGED
|
@@ -87,6 +87,8 @@ export interface AliaChatLabels {
|
|
|
87
87
|
// Conversations (the runtime's own words: toasts and the errors it raises)
|
|
88
88
|
/** A conversation's title until it has one. */
|
|
89
89
|
newChat: string;
|
|
90
|
+
/** Over the sheet's recent conversations with its lead. */
|
|
91
|
+
recentConversations: string;
|
|
90
92
|
/** A turn could not be sent and the composer got its text back. */
|
|
91
93
|
sendFailed: string;
|
|
92
94
|
/** The session ended mid-request. */
|
|
@@ -165,6 +167,7 @@ export const DEFAULT_LABELS: AliaChatLabels = {
|
|
|
165
167
|
t: englishCopy,
|
|
166
168
|
locale: 'en',
|
|
167
169
|
newChat: 'New chat',
|
|
170
|
+
recentConversations: 'Recent',
|
|
168
171
|
sendFailed: "Your message couldn't be sent",
|
|
169
172
|
signInRequired: 'Sign in to keep chatting',
|
|
170
173
|
conversationSaveFailed: 'Failed to save conversation',
|
|
@@ -1,515 +0,0 @@
|
|
|
1
|
-
import React, { useMemo, type ReactNode } from 'react';
|
|
2
|
-
import { View, Text, Platform, Linking } from 'react-native';
|
|
3
|
-
import { useTheme } from '@oxy.so/bloom/theme';
|
|
4
|
-
import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
|
|
5
|
-
import { CodeBlock } from '@oxy.so/bloom/code';
|
|
6
|
-
import { fontFamilies } from '@oxy.so/bloom/fonts';
|
|
7
|
-
import type { AliaColors } from '../theme';
|
|
8
|
-
|
|
9
|
-
// Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
|
|
10
|
-
// The main app always passes resolved colors via the `colors` prop.
|
|
11
|
-
const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
|
|
12
|
-
const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
|
|
13
|
-
const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
|
|
14
|
-
|
|
15
|
-
function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
|
|
16
|
-
const { text: textColor, muted, border, primary, mutedForeground } = colors;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Fenced and indented code: Bloom's `CodeBlock` unless the host brings a
|
|
20
|
-
* code card of its own.
|
|
21
|
-
*/
|
|
22
|
-
const drawCodeBlock: RenderCodeBlock =
|
|
23
|
-
renderCodeBlock ?? ((code, language) => <CodeBlock code={code} language={language} />);
|
|
24
|
-
const codeRules = {
|
|
25
|
-
fence: (node: ASTNode) => (
|
|
26
|
-
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
27
|
-
{drawCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
|
|
28
|
-
</View>
|
|
29
|
-
),
|
|
30
|
-
code_block: (node: ASTNode) => (
|
|
31
|
-
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
32
|
-
{drawCodeBlock(node.content.replace(/\n$/, ''), undefined)}
|
|
33
|
-
</View>
|
|
34
|
-
),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
...codeRules,
|
|
39
|
-
heading1: (node: ASTNode, children: ReactNode[]) => (
|
|
40
|
-
<Text
|
|
41
|
-
key={node.key}
|
|
42
|
-
style={{
|
|
43
|
-
marginBottom: 8,
|
|
44
|
-
marginTop: 12,
|
|
45
|
-
fontSize: 18,
|
|
46
|
-
fontWeight: '600',
|
|
47
|
-
lineHeight: 24,
|
|
48
|
-
color: textColor,
|
|
49
|
-
}}
|
|
50
|
-
>
|
|
51
|
-
{children}
|
|
52
|
-
</Text>
|
|
53
|
-
),
|
|
54
|
-
heading2: (node: ASTNode, children: ReactNode[]) => (
|
|
55
|
-
<Text
|
|
56
|
-
key={node.key}
|
|
57
|
-
style={{
|
|
58
|
-
marginBottom: 8,
|
|
59
|
-
marginTop: 12,
|
|
60
|
-
...HEADING_TEXT,
|
|
61
|
-
fontWeight: '600',
|
|
62
|
-
color: textColor,
|
|
63
|
-
}}
|
|
64
|
-
>
|
|
65
|
-
{children}
|
|
66
|
-
</Text>
|
|
67
|
-
),
|
|
68
|
-
heading3: (node: ASTNode, children: ReactNode[]) => (
|
|
69
|
-
<Text
|
|
70
|
-
key={node.key}
|
|
71
|
-
style={{
|
|
72
|
-
marginBottom: 6,
|
|
73
|
-
marginTop: 8,
|
|
74
|
-
...HEADING_TEXT,
|
|
75
|
-
fontWeight: '600',
|
|
76
|
-
color: textColor,
|
|
77
|
-
}}
|
|
78
|
-
>
|
|
79
|
-
{children}
|
|
80
|
-
</Text>
|
|
81
|
-
),
|
|
82
|
-
heading4: (node: ASTNode, children: ReactNode[]) => (
|
|
83
|
-
<Text
|
|
84
|
-
key={node.key}
|
|
85
|
-
style={{
|
|
86
|
-
marginBottom: 6,
|
|
87
|
-
marginTop: 8,
|
|
88
|
-
...HEADING_TEXT,
|
|
89
|
-
fontWeight: '500',
|
|
90
|
-
color: textColor,
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
93
|
-
{children}
|
|
94
|
-
</Text>
|
|
95
|
-
),
|
|
96
|
-
heading5: (node: ASTNode, children: ReactNode[]) => (
|
|
97
|
-
<Text
|
|
98
|
-
key={node.key}
|
|
99
|
-
style={{
|
|
100
|
-
marginBottom: 4,
|
|
101
|
-
marginTop: 8,
|
|
102
|
-
...HEADING_TEXT,
|
|
103
|
-
fontWeight: '500',
|
|
104
|
-
color: textColor,
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{children}
|
|
108
|
-
</Text>
|
|
109
|
-
),
|
|
110
|
-
heading6: (node: ASTNode, children: ReactNode[]) => (
|
|
111
|
-
<Text
|
|
112
|
-
key={node.key}
|
|
113
|
-
style={{
|
|
114
|
-
marginBottom: 4,
|
|
115
|
-
marginTop: 8,
|
|
116
|
-
...HEADING_TEXT,
|
|
117
|
-
fontWeight: '500',
|
|
118
|
-
color: textColor,
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
{children}
|
|
122
|
-
</Text>
|
|
123
|
-
),
|
|
124
|
-
code: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
|
|
125
|
-
return parent.length > 1 ? (
|
|
126
|
-
<View
|
|
127
|
-
key={node.key}
|
|
128
|
-
style={{
|
|
129
|
-
marginVertical: 8,
|
|
130
|
-
overflow: 'hidden',
|
|
131
|
-
borderRadius: 6,
|
|
132
|
-
backgroundColor: muted,
|
|
133
|
-
borderWidth: 1,
|
|
134
|
-
borderColor: border,
|
|
135
|
-
padding: 12,
|
|
136
|
-
}}
|
|
137
|
-
>
|
|
138
|
-
<Text style={{ fontSize: 13, fontFamily: MONO_FONT }}>{children}</Text>
|
|
139
|
-
</View>
|
|
140
|
-
) : (
|
|
141
|
-
<Text
|
|
142
|
-
key={node.key}
|
|
143
|
-
style={{
|
|
144
|
-
backgroundColor: muted,
|
|
145
|
-
paddingHorizontal: 4,
|
|
146
|
-
paddingVertical: 1,
|
|
147
|
-
fontSize: 13,
|
|
148
|
-
fontFamily: MONO_FONT,
|
|
149
|
-
borderRadius: 3,
|
|
150
|
-
}}
|
|
151
|
-
>
|
|
152
|
-
{children}
|
|
153
|
-
</Text>
|
|
154
|
-
);
|
|
155
|
-
},
|
|
156
|
-
list_item: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
|
|
157
|
-
const isOrdered = parent[parent.length - 1]?.type === 'ordered_list';
|
|
158
|
-
const bullet = isOrdered
|
|
159
|
-
? `${parent[parent.length - 1].children.indexOf(node) + 1}.`
|
|
160
|
-
: '\u2022';
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<View key={node.key} style={{ flexDirection: 'row', paddingVertical: 2, paddingLeft: 16 }}>
|
|
164
|
-
<Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
|
|
165
|
-
{bullet}
|
|
166
|
-
</Text>
|
|
167
|
-
{/* A View, not a Text: a nested list is a View, and inside a Text it runs inline. */}
|
|
168
|
-
<View style={{ flex: 1 }}>{children}</View>
|
|
169
|
-
</View>
|
|
170
|
-
);
|
|
171
|
-
},
|
|
172
|
-
ordered_list: (node: ASTNode, children: ReactNode[]) => (
|
|
173
|
-
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
174
|
-
{children}
|
|
175
|
-
</View>
|
|
176
|
-
),
|
|
177
|
-
unordered_list: (node: ASTNode, children: ReactNode[]) => (
|
|
178
|
-
<View key={node.key} style={{ marginVertical: 8 }}>
|
|
179
|
-
{children}
|
|
180
|
-
</View>
|
|
181
|
-
),
|
|
182
|
-
strong: (node: ASTNode, children: ReactNode[]) => (
|
|
183
|
-
<Text key={node.key} style={{ fontWeight: '600', ...BODY_TEXT, color: textColor }}>
|
|
184
|
-
{children}
|
|
185
|
-
</Text>
|
|
186
|
-
),
|
|
187
|
-
link: (node: ASTNode, children: ReactNode[]) => (
|
|
188
|
-
<Text
|
|
189
|
-
key={node.key}
|
|
190
|
-
style={{ color: primary, textDecorationLine: 'underline', ...BODY_TEXT }}
|
|
191
|
-
onPress={() => {
|
|
192
|
-
if (node.attributes?.href) Linking.openURL(node.attributes.href);
|
|
193
|
-
}}
|
|
194
|
-
>
|
|
195
|
-
{children}
|
|
196
|
-
</Text>
|
|
197
|
-
),
|
|
198
|
-
paragraph: (node: ASTNode, children: ReactNode[]) => (
|
|
199
|
-
<Text key={node.key} style={{ marginBottom: 8, ...BODY_TEXT, color: textColor }}>
|
|
200
|
-
{children}
|
|
201
|
-
</Text>
|
|
202
|
-
),
|
|
203
|
-
blockquote: (node: ASTNode, children: ReactNode[]) => (
|
|
204
|
-
<View
|
|
205
|
-
key={node.key}
|
|
206
|
-
style={{
|
|
207
|
-
marginVertical: 8,
|
|
208
|
-
borderLeftWidth: 4,
|
|
209
|
-
borderLeftColor: border,
|
|
210
|
-
backgroundColor: muted,
|
|
211
|
-
paddingHorizontal: 12,
|
|
212
|
-
paddingVertical: 4,
|
|
213
|
-
borderTopRightRadius: 6,
|
|
214
|
-
borderBottomRightRadius: 6,
|
|
215
|
-
}}
|
|
216
|
-
>
|
|
217
|
-
{children}
|
|
218
|
-
</View>
|
|
219
|
-
),
|
|
220
|
-
hr: (node: ASTNode) => (
|
|
221
|
-
<View
|
|
222
|
-
key={node.key}
|
|
223
|
-
style={{
|
|
224
|
-
marginVertical: 16,
|
|
225
|
-
height: 1,
|
|
226
|
-
backgroundColor: border,
|
|
227
|
-
}}
|
|
228
|
-
/>
|
|
229
|
-
),
|
|
230
|
-
body: (node: ASTNode, children: ReactNode[]) => {
|
|
231
|
-
return (
|
|
232
|
-
<View key={node.key} style={{ marginBottom: -8 }}>
|
|
233
|
-
{children}
|
|
234
|
-
</View>
|
|
235
|
-
);
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function createStyles(colors: AliaColors, fontFamily: string | undefined) {
|
|
241
|
-
const { text: textColor, muted, border, primary, mutedForeground } = colors;
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
// On WEB the family arrives through the cascade down to `html`, so naming
|
|
245
|
-
// one here would override Bloom rather than complement it.
|
|
246
|
-
//
|
|
247
|
-
// On NATIVE nothing arrives. Bloom's only app-wide mechanism is the
|
|
248
|
-
// `Text.defaultProps.style` mutation in its `FontLoader.native.tsx`, and
|
|
249
|
-
// **React 19 drops `defaultProps` under the automatic JSX runtime** that
|
|
250
|
-
// Expo compiles to — measured on react@19.2.3: `createElement(Foo, {})`
|
|
251
|
-
// yields `{style:{fontFamily}}` while `jsx(Foo, {})` yields `{}`, for
|
|
252
|
-
// function AND class components alike. So that mutation reaches no `<Text>`
|
|
253
|
-
// in any Expo consumer, and this renderer cannot inherit a face.
|
|
254
|
-
//
|
|
255
|
-
// `fontFamily` therefore comes from the caller, which is the only party
|
|
256
|
-
// that both knows the family and is allowed to name it: react-native-markdown-display
|
|
257
|
-
// takes RN style objects, so there is no className path to NativeWind's token.
|
|
258
|
-
body: { ...BODY_TEXT, color: textColor, fontFamily },
|
|
259
|
-
text: { ...BODY_TEXT, color: textColor, fontFamily },
|
|
260
|
-
paragraph: { marginTop: 0, marginBottom: 8 },
|
|
261
|
-
strong: { fontWeight: '600' as const },
|
|
262
|
-
em: { fontStyle: 'italic' as const },
|
|
263
|
-
s: { textDecorationLine: 'line-through' as const },
|
|
264
|
-
bullet_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
|
|
265
|
-
ordered_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
|
|
266
|
-
bullet_list_content: { flex: 1 },
|
|
267
|
-
ordered_list_content: { flex: 1 },
|
|
268
|
-
code_inline: {
|
|
269
|
-
fontSize: 13,
|
|
270
|
-
fontFamily: MONO_FONT,
|
|
271
|
-
backgroundColor: muted,
|
|
272
|
-
borderWidth: 0,
|
|
273
|
-
borderRadius: 3,
|
|
274
|
-
paddingHorizontal: 4,
|
|
275
|
-
paddingVertical: 1,
|
|
276
|
-
},
|
|
277
|
-
code_block: {
|
|
278
|
-
fontSize: 13,
|
|
279
|
-
fontFamily: MONO_FONT,
|
|
280
|
-
backgroundColor: muted,
|
|
281
|
-
borderWidth: 1,
|
|
282
|
-
borderColor: border,
|
|
283
|
-
borderRadius: 6,
|
|
284
|
-
padding: 12,
|
|
285
|
-
},
|
|
286
|
-
fence: {
|
|
287
|
-
fontSize: 13,
|
|
288
|
-
fontFamily: MONO_FONT,
|
|
289
|
-
backgroundColor: muted,
|
|
290
|
-
borderWidth: 1,
|
|
291
|
-
borderColor: border,
|
|
292
|
-
borderRadius: 6,
|
|
293
|
-
padding: 12,
|
|
294
|
-
},
|
|
295
|
-
table: {
|
|
296
|
-
borderWidth: 1,
|
|
297
|
-
borderColor: border,
|
|
298
|
-
borderRadius: 12,
|
|
299
|
-
marginVertical: 8,
|
|
300
|
-
overflow: 'hidden' as const,
|
|
301
|
-
},
|
|
302
|
-
thead: { backgroundColor: muted },
|
|
303
|
-
th: { flex: 1, padding: 8, fontWeight: '600' as const, ...BODY_TEXT, color: textColor },
|
|
304
|
-
td: { flex: 1, padding: 8, ...BODY_TEXT, color: textColor },
|
|
305
|
-
tr: { borderBottomWidth: 1, borderColor: border, flexDirection: 'row' as const },
|
|
306
|
-
link: { textDecorationLine: 'underline' as const, color: primary, ...BODY_TEXT },
|
|
307
|
-
hr: { backgroundColor: border, height: 1, marginVertical: 16 },
|
|
308
|
-
heading1: { fontSize: 18, fontWeight: '600' as const },
|
|
309
|
-
heading2: { ...HEADING_TEXT, fontWeight: '600' as const },
|
|
310
|
-
heading3: { ...HEADING_TEXT, fontWeight: '600' as const },
|
|
311
|
-
heading4: { ...HEADING_TEXT, fontWeight: '500' as const },
|
|
312
|
-
heading5: { ...HEADING_TEXT, fontWeight: '500' as const },
|
|
313
|
-
heading6: { ...HEADING_TEXT, fontWeight: '500' as const },
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Draws one block of code: its source, and the fence's language when it named one. */
|
|
318
|
-
export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
|
|
319
|
-
|
|
320
|
-
/** The fence's info string up to the first space (```ts title=x → `ts`). */
|
|
321
|
-
function codeLanguage(node: ASTNode): string | undefined {
|
|
322
|
-
const info = (node as ASTNode & { sourceInfo?: string }).sourceInfo?.trim() ?? '';
|
|
323
|
-
const language = info.split(/\s+/)[0];
|
|
324
|
-
return language === '' ? undefined : language;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* The body face, which `react-native-markdown-display` cannot inherit on native.
|
|
329
|
-
*
|
|
330
|
-
* It is driven by RN STYLE OBJECTS and supports no `className`, so NativeWind's `--font-sans`
|
|
331
|
-
* cannot reach the `<Text>` elements it renders. And Bloom's app-wide native default — the
|
|
332
|
-
* `Text.defaultProps` mutation in its `FontLoader.native.tsx` — does not apply: React 19 drops
|
|
333
|
-
* `defaultProps` under the automatic JSX runtime Expo compiles to. So the family is handed over
|
|
334
|
-
* explicitly, taken from Bloom's own token so it has one owner. `fontFamilies.sans` is a CSS
|
|
335
|
-
* STACK and RN's `fontFamily` takes a single family, hence the leading entry. Web is left
|
|
336
|
-
* undefined on purpose: there the cascade from `html` already carries `--bloom-font-sans`, and
|
|
337
|
-
* naming a family would override Bloom instead of complementing it.
|
|
338
|
-
*/
|
|
339
|
-
const BODY_FONT = Platform.OS === 'web' ? undefined : fontFamilies.sans.split(',')[0].trim();
|
|
340
|
-
|
|
341
|
-
export interface AliaMarkdownProps {
|
|
342
|
-
/**
|
|
343
|
-
* The sans family for body text, as a SINGLE family name — RN's `fontFamily` takes one, not a
|
|
344
|
-
* CSS stack. Omitted, Bloom's own (see `BODY_FONT`).
|
|
345
|
-
*/
|
|
346
|
-
fontFamily?: string;
|
|
347
|
-
content: string;
|
|
348
|
-
colors?: Partial<AliaColors>;
|
|
349
|
-
/**
|
|
350
|
-
* Draw fenced code with the host's own code card. Keep the function's
|
|
351
|
-
* identity stable (a module-level function or a `useCallback`): it is part of
|
|
352
|
-
* the rules' memo, and a new one per render re-parses every block.
|
|
353
|
-
*/
|
|
354
|
-
renderCodeBlock?: RenderCodeBlock;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
358
|
-
const LIST_OR_TABLE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\|)/;
|
|
359
|
-
// Link reference definitions ([1]: https://…) resolve per-parse, so a document
|
|
360
|
-
// using them cannot be split into independently parsed blocks.
|
|
361
|
-
const LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s/m;
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Split markdown into top-level blocks at blank-line boundaries, keeping
|
|
365
|
-
* fenced code, list runs, and tables intact. During streaming only the last
|
|
366
|
-
* block's text changes, so every completed block hits React.memo and skips
|
|
367
|
-
* its markdown-it re-parse — the per-flush cost drops from O(document) to
|
|
368
|
-
* O(trailing block).
|
|
369
|
-
*/
|
|
370
|
-
function splitMarkdownBlocks(content: string): string[] {
|
|
371
|
-
if (LINK_REF_DEF_RE.test(content)) return [content];
|
|
372
|
-
|
|
373
|
-
const lines = content.split('\n');
|
|
374
|
-
const blocks: string[] = [];
|
|
375
|
-
let current: string[] = [];
|
|
376
|
-
let lastNonBlank = '';
|
|
377
|
-
let inFence = false;
|
|
378
|
-
let i = 0;
|
|
379
|
-
|
|
380
|
-
while (i < lines.length) {
|
|
381
|
-
const line = lines[i];
|
|
382
|
-
if (FENCE_RE.test(line)) {
|
|
383
|
-
inFence = !inFence;
|
|
384
|
-
current.push(line);
|
|
385
|
-
lastNonBlank = line;
|
|
386
|
-
i += 1;
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
if (!inFence && line.trim() === '') {
|
|
390
|
-
let j = i;
|
|
391
|
-
while (j < lines.length && lines[j].trim() === '') j += 1;
|
|
392
|
-
const next = j < lines.length ? lines[j] : '';
|
|
393
|
-
// Splitting a list or table at a blank line would restart ordered-list
|
|
394
|
-
// numbering and break table parsing — keep those runs in one block.
|
|
395
|
-
const keepTogether = LIST_OR_TABLE_RE.test(lastNonBlank) || LIST_OR_TABLE_RE.test(next);
|
|
396
|
-
if (keepTogether || current.length === 0) {
|
|
397
|
-
for (; i < j; i += 1) current.push(lines[i]);
|
|
398
|
-
} else {
|
|
399
|
-
blocks.push(current.join('\n'));
|
|
400
|
-
current = [];
|
|
401
|
-
i = j;
|
|
402
|
-
}
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
current.push(line);
|
|
406
|
-
lastNonBlank = line;
|
|
407
|
-
i += 1;
|
|
408
|
-
}
|
|
409
|
-
if (current.length > 0) blocks.push(current.join('\n'));
|
|
410
|
-
return blocks.length > 0 ? blocks : [content];
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
type MarkdownBlockProps = {
|
|
414
|
-
content: string;
|
|
415
|
-
rules: ReturnType<typeof createRules>;
|
|
416
|
-
styles: ReturnType<typeof createStyles>;
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* `react-native-markdown-display` gives three of its props DEFAULT VALUES that
|
|
421
|
-
* are new objects on every render: `markdownit = MarkdownIt({ typographer:
|
|
422
|
-
* true })`, `allowedImageHandlers = [...]` and `topLevelMaxExceededItem =
|
|
423
|
-
* <Text>`. A default parameter is evaluated per call, so each render of each
|
|
424
|
-
* `<Markdown>` built a parser (compiling linkify-it's regexes), and the two
|
|
425
|
-
* others sit in the deps of its renderer's `useMemo`, so the renderer — and
|
|
426
|
-
* the `StyleSheet.create` of every style inside it — was rebuilt every render
|
|
427
|
-
* too. A streamed answer re-renders its last block per token, and opening a
|
|
428
|
-
* thread renders every block once: both paid for it. Passing the defaults'
|
|
429
|
-
* own values, created once, makes both memos hold. The parser is shared:
|
|
430
|
-
* `parse` keeps no state between calls.
|
|
431
|
-
*
|
|
432
|
-
* The typings omit the last two props, which the component does take.
|
|
433
|
-
*/
|
|
434
|
-
const PARSER = MarkdownIt({ typographer: true });
|
|
435
|
-
const IMAGE_HANDLERS = [
|
|
436
|
-
'data:image/png;base64',
|
|
437
|
-
'data:image/gif;base64',
|
|
438
|
-
'data:image/jpeg;base64',
|
|
439
|
-
'https://',
|
|
440
|
-
'http://',
|
|
441
|
-
];
|
|
442
|
-
const TOP_LEVEL_EXCEEDED = <Text key="dotdotdot">...</Text>;
|
|
443
|
-
const StableMarkdown = Markdown as React.ComponentType<
|
|
444
|
-
React.ComponentProps<typeof Markdown> & {
|
|
445
|
-
allowedImageHandlers?: string[];
|
|
446
|
-
topLevelMaxExceededItem?: ReactNode;
|
|
447
|
-
}
|
|
448
|
-
>;
|
|
449
|
-
|
|
450
|
-
function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
|
|
451
|
-
return (
|
|
452
|
-
<StableMarkdown
|
|
453
|
-
rules={rules}
|
|
454
|
-
style={styles}
|
|
455
|
-
markdownit={PARSER}
|
|
456
|
-
allowedImageHandlers={IMAGE_HANDLERS}
|
|
457
|
-
topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
|
|
458
|
-
>
|
|
459
|
-
{content}
|
|
460
|
-
</StableMarkdown>
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
const MarkdownBlock = React.memo(ParsedMarkdown);
|
|
465
|
-
|
|
466
|
-
export function AliaMarkdown({
|
|
467
|
-
content,
|
|
468
|
-
colors: colorOverrides,
|
|
469
|
-
fontFamily = BODY_FONT,
|
|
470
|
-
renderCodeBlock,
|
|
471
|
-
}: AliaMarkdownProps) {
|
|
472
|
-
// Bloom's theme, which follows the app's chosen mode, not the system's scheme: a person who
|
|
473
|
-
// picked light on a dark OS gets dark text on a light surface, not the reverse.
|
|
474
|
-
const { colors: theme } = useTheme();
|
|
475
|
-
const colors = {
|
|
476
|
-
text: theme.text,
|
|
477
|
-
muted: theme.backgroundSecondary,
|
|
478
|
-
border: theme.border,
|
|
479
|
-
primary: theme.primary,
|
|
480
|
-
mutedForeground: theme.textSecondary,
|
|
481
|
-
...colorOverrides,
|
|
482
|
-
} as AliaColors;
|
|
483
|
-
|
|
484
|
-
const customRules = useMemo(
|
|
485
|
-
() => createRules(colors, renderCodeBlock),
|
|
486
|
-
[
|
|
487
|
-
colors.text,
|
|
488
|
-
colors.muted,
|
|
489
|
-
colors.border,
|
|
490
|
-
colors.primary,
|
|
491
|
-
colors.mutedForeground,
|
|
492
|
-
renderCodeBlock,
|
|
493
|
-
],
|
|
494
|
-
);
|
|
495
|
-
const markdownStyles = useMemo(
|
|
496
|
-
() => createStyles(colors, fontFamily),
|
|
497
|
-
[colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily],
|
|
498
|
-
);
|
|
499
|
-
const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
|
|
500
|
-
|
|
501
|
-
if (blocks.length === 1) {
|
|
502
|
-
return <ParsedMarkdown content={blocks[0]} rules={customRules} styles={markdownStyles} />;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Each block's `body` rule cancels its own trailing paragraph margin
|
|
506
|
-
// (marginBottom: -8), so rowGap restores the exact inter-paragraph spacing
|
|
507
|
-
// a single continuous parse would produce.
|
|
508
|
-
return (
|
|
509
|
-
<View style={{ rowGap: 8 }}>
|
|
510
|
-
{blocks.map((block, i) => (
|
|
511
|
-
<MarkdownBlock key={i} content={block} rules={customRules} styles={markdownStyles} />
|
|
512
|
-
))}
|
|
513
|
-
</View>
|
|
514
|
-
);
|
|
515
|
-
}
|