@alia.onl/sdk 15.7.0 → 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +255 -12
- package/README.md +241 -155
- package/package.json +17 -5
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/csp.ts +50 -0
- package/src/hooks/useAliaActivity.ts +3 -3
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDictation.ts +2 -3
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useTTS.ts +2 -3
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +2 -6
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/character-runtime.ts +6 -4
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/origins.ts +15 -0
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-audio-engine.ts +14 -158
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/voice-worklets.ts +19 -0
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1427 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/worklets/voice-processors.js +152 -0
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/api-url.ts +0 -2
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { AgentProgress } from '@oxy.so/bloom/agent-progress';
|
|
2
|
+
import { AgentReplyIdentity } from '@oxy.so/bloom/agent-reply';
|
|
3
|
+
import {
|
|
4
|
+
AiChatAssistantMessage,
|
|
5
|
+
AiChatUserMessage,
|
|
6
|
+
type AiChatTurnAction,
|
|
7
|
+
} from '@oxy.so/bloom/ai-chat';
|
|
8
|
+
import { RiEditLine } from '@oxy.so/bloom/icons/RiEditLine';
|
|
9
|
+
import { RiFileCopyLine } from '@oxy.so/bloom/icons/RiFileCopyLine';
|
|
10
|
+
import { RiRefreshLine } from '@oxy.so/bloom/icons/RiRefreshLine';
|
|
11
|
+
import { RiStopFill } from '@oxy.so/bloom/icons/RiStopFill';
|
|
12
|
+
import { RiThumbDownLine } from '@oxy.so/bloom/icons/RiThumbDownLine';
|
|
13
|
+
import { RiThumbUpLine } from '@oxy.so/bloom/icons/RiThumbUpLine';
|
|
14
|
+
import { RiVolumeUpLine } from '@oxy.so/bloom/icons/RiVolumeUpLine';
|
|
15
|
+
import { TaskList } from '@oxy.so/bloom/task-list';
|
|
16
|
+
import { Text } from '@oxy.so/bloom/typography';
|
|
17
|
+
import { WebSearch } from '@oxy.so/bloom/web-search';
|
|
18
|
+
import { memo } from 'react';
|
|
19
|
+
import { type LayoutChangeEvent, View } from 'react-native';
|
|
20
|
+
import Animated from 'react-native-reanimated';
|
|
21
|
+
import type { ReadAloudState } from '../../hooks/useReadAloud';
|
|
22
|
+
import type { Vote } from '../../hooks/useMessageVote';
|
|
23
|
+
import { readAgentCharacter } from '../../lib/agent-character';
|
|
24
|
+
import { getImagesFromContent, messageText } from '../../lib/content-utils';
|
|
25
|
+
import { ANCHORS, useAliaFlights } from '../../lib/flights';
|
|
26
|
+
import { useLabels } from '../../lib/labels';
|
|
27
|
+
import { askedQuestions, isQuestionInvocation } from '../../lib/questions';
|
|
28
|
+
import { parseCatalogueTool, useToolRenderers } from '../../lib/tool-renderers';
|
|
29
|
+
import { isWebInvocation, taskListLog, webSearchLog } from '../../lib/work-log';
|
|
30
|
+
import { useAliaRuntime } from '../../runtime/config';
|
|
31
|
+
import { type Message, cardOf, formatElapsed, getToolPillLabel } from '../../runtime/model';
|
|
32
|
+
import type { OpenThought } from '../../hooks/useOpenThought';
|
|
33
|
+
import { Image } from '../Image';
|
|
34
|
+
import { PlanPreviewCard } from '../PlanPreviewCard';
|
|
35
|
+
import { AskedQuestions } from '../QuestionCard';
|
|
36
|
+
import { ReplyMarkdown } from '../ReplyMarkdown';
|
|
37
|
+
import { MessageBlockBoundary } from './MessageBlockBoundary';
|
|
38
|
+
import { ToolResultCard } from './ToolResultCard';
|
|
39
|
+
import { TurnMenu } from './TurnMenu';
|
|
40
|
+
import { TurnStatusLine } from './TurnStatusLine';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a row can do, built once by the thread so every memoised row sees the same object: the
|
|
44
|
+
* thread re-renders per streamed token, and a function made per row would re-render them all.
|
|
45
|
+
*/
|
|
46
|
+
export interface ThreadActions {
|
|
47
|
+
/** Copy a turn's words; resolves whether they really reached the clipboard. */
|
|
48
|
+
copy: (text: string) => Promise<boolean>;
|
|
49
|
+
vote: (messageId: string, vote: Vote, conversationId: string | undefined) => void;
|
|
50
|
+
readAloud: (messageId: string, text: string, conversationId: string | undefined) => void;
|
|
51
|
+
/** Absent where the host has no thought panel: the "worked for" line is then plain text. */
|
|
52
|
+
openThought?: OpenThought;
|
|
53
|
+
/** A thread read by somebody it was shared with: words to copy, nothing to vote on or play. */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ThreadRowProps {
|
|
58
|
+
message: Message;
|
|
59
|
+
/** The row only for a message that arrived while the conversation was open; history never animates. */
|
|
60
|
+
isNew: boolean;
|
|
61
|
+
/** The last of Alia's own replies: the one that may be streaming. */
|
|
62
|
+
isLastReply: boolean;
|
|
63
|
+
/** A turn is in flight. Given only to the last reply, so a turn starting re-renders one row. */
|
|
64
|
+
isLoading: boolean;
|
|
65
|
+
/** The conversation that holds this message, or `undefined` while none does (`Message.unsaved` also withholds it). */
|
|
66
|
+
conversationId: string | undefined;
|
|
67
|
+
/** Where this row ended up, for the one row a jump is aimed at. */
|
|
68
|
+
onLayout?: (event: LayoutChangeEvent) => void;
|
|
69
|
+
actions: ThreadActions;
|
|
70
|
+
/** This reply's read-aloud, as a primitive so only its own row re-renders. */
|
|
71
|
+
readAloudState: ReadAloudState;
|
|
72
|
+
/** A call or a dictation holds the audio. */
|
|
73
|
+
readAloudBlocked: boolean;
|
|
74
|
+
/** Given only to the one reply that may be regenerated. */
|
|
75
|
+
onRegenerate?: (assistantMessageId: string) => void;
|
|
76
|
+
/** Given only to a question that may be edited. */
|
|
77
|
+
onStartEdit?: (userMessageId: string) => void;
|
|
78
|
+
/** The turn's timing for its work summary, as epoch ms, or `null` for a turn the server has not stamped. */
|
|
79
|
+
workStartedAt: number | null;
|
|
80
|
+
workEndedAt: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isPresent = <T,>(value: T | null): value is T => value !== null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One turn of the thread: a question in Bloom's bubble, or a reply with its work summary, cards
|
|
87
|
+
* and Markdown.
|
|
88
|
+
*
|
|
89
|
+
* The turn's own actions are each offered only where they can be done — read aloud and copy need
|
|
90
|
+
* words, regenerate a finished reply in a thread not busy with another turn — and the row under
|
|
91
|
+
* the turn and the long-press menu are the same list.
|
|
92
|
+
*/
|
|
93
|
+
export const ThreadRow = memo(function ThreadRow({
|
|
94
|
+
message,
|
|
95
|
+
isNew,
|
|
96
|
+
isLastReply,
|
|
97
|
+
isLoading,
|
|
98
|
+
conversationId,
|
|
99
|
+
onLayout,
|
|
100
|
+
actions,
|
|
101
|
+
readAloudState,
|
|
102
|
+
readAloudBlocked,
|
|
103
|
+
onRegenerate,
|
|
104
|
+
onStartEdit,
|
|
105
|
+
workStartedAt,
|
|
106
|
+
workEndedAt,
|
|
107
|
+
}: ThreadRowProps) {
|
|
108
|
+
const { t } = useLabels();
|
|
109
|
+
const text = messageText(message);
|
|
110
|
+
const images = message.content ? getImagesFromContent(message.content) : [];
|
|
111
|
+
const isReply = message.role === 'assistant';
|
|
112
|
+
const storedIn = message.unsaved === true ? undefined : conversationId;
|
|
113
|
+
const hasText = text.length > 0;
|
|
114
|
+
|
|
115
|
+
const copyAction: AiChatTurnAction | null = hasText
|
|
116
|
+
? {
|
|
117
|
+
key: 'copy',
|
|
118
|
+
label: t('chat.copy'),
|
|
119
|
+
icon: RiFileCopyLine,
|
|
120
|
+
onPress: () => void actions.copy(text),
|
|
121
|
+
}
|
|
122
|
+
: null;
|
|
123
|
+
|
|
124
|
+
if (!isReply) {
|
|
125
|
+
const editAction: AiChatTurnAction | null =
|
|
126
|
+
onStartEdit === undefined
|
|
127
|
+
? null
|
|
128
|
+
: {
|
|
129
|
+
key: 'edit',
|
|
130
|
+
label: t('chat.edit'),
|
|
131
|
+
icon: RiEditLine,
|
|
132
|
+
onPress: () => onStartEdit(message.id),
|
|
133
|
+
};
|
|
134
|
+
const questionActions = [copyAction, editAction].filter(isPresent);
|
|
135
|
+
return (
|
|
136
|
+
<Animated.View onLayout={onLayout}>
|
|
137
|
+
{text.length === 0 && images.length === 0 ? null : (
|
|
138
|
+
// Bloom's user turn, with the breathing room above it the thread has always had.
|
|
139
|
+
<View className="mt-2 w-full flex-col items-end">
|
|
140
|
+
<TurnMenu actions={questionActions} label={t('chat.turnActions')}>
|
|
141
|
+
<AiChatUserMessage animate={isNew} actions={questionActions}>
|
|
142
|
+
{images.length > 0 && (
|
|
143
|
+
<View className="flex-row flex-wrap gap-2">
|
|
144
|
+
{images.map((url) => (
|
|
145
|
+
<View key={url} className="size-[120px] overflow-hidden rounded-xl">
|
|
146
|
+
<Image source={{ uri: url }} className="h-full w-full" contentFit="cover" />
|
|
147
|
+
</View>
|
|
148
|
+
))}
|
|
149
|
+
</View>
|
|
150
|
+
)}
|
|
151
|
+
{text}
|
|
152
|
+
</AiChatUserMessage>
|
|
153
|
+
</TurnMenu>
|
|
154
|
+
</View>
|
|
155
|
+
)}
|
|
156
|
+
</Animated.View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<Animated.View onLayout={onLayout}>
|
|
162
|
+
<ReplyTurn
|
|
163
|
+
message={message}
|
|
164
|
+
text={text}
|
|
165
|
+
isNew={isNew}
|
|
166
|
+
isLastReply={isLastReply}
|
|
167
|
+
isLoading={isLoading}
|
|
168
|
+
storedIn={storedIn}
|
|
169
|
+
actions={actions}
|
|
170
|
+
copyAction={copyAction}
|
|
171
|
+
readAloudState={readAloudState}
|
|
172
|
+
readAloudBlocked={readAloudBlocked}
|
|
173
|
+
onRegenerate={onRegenerate}
|
|
174
|
+
workStartedAt={workStartedAt}
|
|
175
|
+
workEndedAt={workEndedAt}
|
|
176
|
+
/>
|
|
177
|
+
</Animated.View>
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
interface ReplyTurnProps {
|
|
182
|
+
message: Message;
|
|
183
|
+
text: string;
|
|
184
|
+
isNew: boolean;
|
|
185
|
+
isLastReply: boolean;
|
|
186
|
+
isLoading: boolean;
|
|
187
|
+
storedIn: string | undefined;
|
|
188
|
+
actions: ThreadActions;
|
|
189
|
+
copyAction: AiChatTurnAction | null;
|
|
190
|
+
readAloudState: ReadAloudState;
|
|
191
|
+
readAloudBlocked: boolean;
|
|
192
|
+
onRegenerate?: (assistantMessageId: string) => void;
|
|
193
|
+
workStartedAt: number | null;
|
|
194
|
+
workEndedAt: number | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ReplyTurn({
|
|
198
|
+
message,
|
|
199
|
+
text,
|
|
200
|
+
isNew,
|
|
201
|
+
isLastReply,
|
|
202
|
+
isLoading,
|
|
203
|
+
storedIn,
|
|
204
|
+
actions,
|
|
205
|
+
copyAction,
|
|
206
|
+
readAloudState,
|
|
207
|
+
readAloudBlocked,
|
|
208
|
+
onRegenerate,
|
|
209
|
+
workStartedAt,
|
|
210
|
+
workEndedAt,
|
|
211
|
+
}: ReplyTurnProps) {
|
|
212
|
+
const { t } = useLabels();
|
|
213
|
+
const { faviconUrl } = useAliaRuntime().host;
|
|
214
|
+
const renderers = useToolRenderers();
|
|
215
|
+
const flights = useAliaFlights();
|
|
216
|
+
const toolInvocations = message.toolInvocations ?? [];
|
|
217
|
+
const plan = message.plan;
|
|
218
|
+
const agent = message.agentInfo;
|
|
219
|
+
const { readOnly = false } = actions;
|
|
220
|
+
|
|
221
|
+
// A host's own drawing of a catalogue tool's result, before any generic card: keyed by the
|
|
222
|
+
// tool's canonical name (`oxy_<app>__<tool>`), never by anything the model wrote.
|
|
223
|
+
const rendered = toolInvocations.flatMap((call, index) => {
|
|
224
|
+
if (call.state !== 'result') return [];
|
|
225
|
+
const parsed = parseCatalogueTool(call.toolName);
|
|
226
|
+
const render = parsed ? renderers[`${parsed.app}:${parsed.tool}`] : undefined;
|
|
227
|
+
if (!parsed || render === undefined) return [];
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
key: call.toolCallId || `rendered-${message.id}-${index}`,
|
|
231
|
+
call,
|
|
232
|
+
node: render({
|
|
233
|
+
app: parsed.app,
|
|
234
|
+
tool: parsed.tool,
|
|
235
|
+
toolCallId: call.toolCallId,
|
|
236
|
+
input: call.args,
|
|
237
|
+
output: call.result,
|
|
238
|
+
...(agent ? { agentId: agent.id } : {}),
|
|
239
|
+
}),
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
});
|
|
243
|
+
const renderedCalls = new Set(rendered.map(({ call }) => call));
|
|
244
|
+
|
|
245
|
+
// The calls the work summary lists: every one that did not draw its own card. Read on each
|
|
246
|
+
// render — the list changes per streamed tool event and the row is memoised on `message`.
|
|
247
|
+
const workInvocations = toolInvocations.filter(
|
|
248
|
+
(call) => cardOf(call) === null && !isQuestionInvocation(call) && !renderedCalls.has(call),
|
|
249
|
+
);
|
|
250
|
+
const questions = toolInvocations.flatMap((call) => askedQuestions(call) ?? []);
|
|
251
|
+
const toolCards = toolInvocations.flatMap((call, index) => {
|
|
252
|
+
if (renderedCalls.has(call)) return [];
|
|
253
|
+
const card = cardOf(call);
|
|
254
|
+
return card === null ? [] : [{ key: call.toolCallId || `tool-${message.id}-${index}`, card }];
|
|
255
|
+
});
|
|
256
|
+
/** The template swaps the steps for the reply once the turn is done. */
|
|
257
|
+
const turnWorking = isLoading && isLastReply && message.isStreaming === true;
|
|
258
|
+
|
|
259
|
+
// Where the turn went — searches, page visits, research — as a `WebSearch` trail with its
|
|
260
|
+
// sources, and everything else it did as a `TaskList`. Both are driven by `revealed` from the
|
|
261
|
+
// calls that have really arrived, never by their own demo ticker.
|
|
262
|
+
const webLog = webSearchLog(workInvocations, message.researchProgress, { t, faviconUrl });
|
|
263
|
+
const taskInvocations = workInvocations.filter((call) => !isWebInvocation(call));
|
|
264
|
+
const taskLog =
|
|
265
|
+
taskInvocations.length === 0 || turnWorking ? null : taskListLog(taskInvocations, false, { t });
|
|
266
|
+
const hasWorkLog = webLog.steps.length > 0 || (workInvocations.length > 0 && !turnWorking);
|
|
267
|
+
|
|
268
|
+
const reading =
|
|
269
|
+
readAloudState === 'loading' || readAloudState === 'playing' || readAloudState === 'paused';
|
|
270
|
+
const readAloudAction: AiChatTurnAction | null =
|
|
271
|
+
!text || readOnly
|
|
272
|
+
? null
|
|
273
|
+
: {
|
|
274
|
+
key: 'read-aloud',
|
|
275
|
+
label: reading ? t('chat.stopReading') : t('chat.readAloud'),
|
|
276
|
+
icon: reading ? RiStopFill : RiVolumeUpLine,
|
|
277
|
+
active: reading,
|
|
278
|
+
disabled: readAloudBlocked && !reading,
|
|
279
|
+
onPress: () => actions.readAloud(message.id, text, storedIn),
|
|
280
|
+
};
|
|
281
|
+
const regenerateAction: AiChatTurnAction | null =
|
|
282
|
+
onRegenerate === undefined || message.isStreaming || isLoading || readOnly
|
|
283
|
+
? null
|
|
284
|
+
: {
|
|
285
|
+
key: 'regenerate',
|
|
286
|
+
label: t('chat.regenerate'),
|
|
287
|
+
icon: RiRefreshLine,
|
|
288
|
+
onPress: () => onRegenerate(message.id),
|
|
289
|
+
};
|
|
290
|
+
/** Under a reply, after Bloom's like / dislike / copy. */
|
|
291
|
+
const replyActions = [readAloudAction, regenerateAction].filter(isPresent);
|
|
292
|
+
const hasFeedback = !message.isStreaming && text.length > 0 && !readOnly;
|
|
293
|
+
/** The long-press menu: everything the rows offer, likes included. */
|
|
294
|
+
const menuActions: AiChatTurnAction[] = hasFeedback
|
|
295
|
+
? [
|
|
296
|
+
readAloudAction,
|
|
297
|
+
copyAction,
|
|
298
|
+
regenerateAction,
|
|
299
|
+
{
|
|
300
|
+
key: 'like',
|
|
301
|
+
label: t('chat.like'),
|
|
302
|
+
icon: RiThumbUpLine,
|
|
303
|
+
onPress: () => actions.vote(message.id, 'up', storedIn),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
key: 'dislike',
|
|
307
|
+
label: t('chat.dislike'),
|
|
308
|
+
icon: RiThumbDownLine,
|
|
309
|
+
onPress: () => actions.vote(message.id, 'down', storedIn),
|
|
310
|
+
},
|
|
311
|
+
].filter(isPresent)
|
|
312
|
+
: [];
|
|
313
|
+
|
|
314
|
+
const { openThought } = actions;
|
|
315
|
+
const viewDetails = openThought && (() => openThought(message.id, 'steps'));
|
|
316
|
+
/** A consulted agent's reply is drawn from its first frame: its identity is where it joins from. */
|
|
317
|
+
const showsContent =
|
|
318
|
+
agent !== undefined ||
|
|
319
|
+
text.length > 0 ||
|
|
320
|
+
hasWorkLog ||
|
|
321
|
+
toolCards.length > 0 ||
|
|
322
|
+
rendered.length > 0 ||
|
|
323
|
+
(message.isStreaming === true && message.source === 'voice');
|
|
324
|
+
/** Its avatar is the source of the flight into the header while it is answering. */
|
|
325
|
+
const agentLive = agent !== undefined && message.isStreaming === true;
|
|
326
|
+
const agentAnchor = agent ? ANCHORS.reply(agent.id) : undefined;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<>
|
|
330
|
+
{/* Plan Preview — shown before tool execution */}
|
|
331
|
+
{plan && (
|
|
332
|
+
<MessageBlockBoundary>
|
|
333
|
+
<PlanPreviewCard steps={plan.steps} />
|
|
334
|
+
</MessageBlockBoundary>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
{/* The template's steps: Bloom's AgentProgress, driven by the tool calls as the runtime
|
|
338
|
+
reports them. Web calls are left to the WebSearch trail inside the turn, which streams
|
|
339
|
+
them itself. */}
|
|
340
|
+
{taskInvocations.length === 0 || !turnWorking ? null : (
|
|
341
|
+
<AgentProgress
|
|
342
|
+
steps={taskInvocations.map((call) => getToolPillLabel(call.toolName, t))}
|
|
343
|
+
completedCount={taskInvocations.filter((call) => call.state === 'result').length}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* `isStreaming` opens the block for VOICE only: a voice row shows its cursor before it has
|
|
348
|
+
words, while a text turn's placeholder — stamped `isStreaming` too — is the thinking
|
|
349
|
+
indicator's until its first token arrives. */}
|
|
350
|
+
{showsContent && (
|
|
351
|
+
<View className="w-full flex-col">
|
|
352
|
+
{agent && agentAnchor ? (
|
|
353
|
+
<View className="mb-0.5">
|
|
354
|
+
<AgentReplyIdentity
|
|
355
|
+
character={readAgentCharacter(agent.character)}
|
|
356
|
+
name={agent.name}
|
|
357
|
+
thinking={agentLive && text.length === 0}
|
|
358
|
+
workingKey={agent.id}
|
|
359
|
+
avatarRef={agentLive && flights ? flights.anchor(agentAnchor) : undefined}
|
|
360
|
+
avatarHidden={agentLive && flights ? flights.isLeaving(agentAnchor) : false}
|
|
361
|
+
/>
|
|
362
|
+
</View>
|
|
363
|
+
) : null}
|
|
364
|
+
{/* Bloom's reply: its reveal and its own feedback row (like / dislike / copy), then read
|
|
365
|
+
aloud and regenerate. Copy confirms only what the clipboard did. */}
|
|
366
|
+
<TurnMenu actions={menuActions} label={t('chat.turnActions')}>
|
|
367
|
+
<AiChatAssistantMessage
|
|
368
|
+
animate={isNew}
|
|
369
|
+
feedback={hasFeedback}
|
|
370
|
+
feedbackProps={{
|
|
371
|
+
onLike: () => actions.vote(message.id, 'up', storedIn),
|
|
372
|
+
onDislike: () => actions.vote(message.id, 'down', storedIn),
|
|
373
|
+
onCopy: () => actions.copy(text),
|
|
374
|
+
actions: replyActions,
|
|
375
|
+
labels: {
|
|
376
|
+
like: t('chat.like'),
|
|
377
|
+
dislike: t('chat.dislike'),
|
|
378
|
+
copy: t('chat.copy'),
|
|
379
|
+
copied: t('chat.copied'),
|
|
380
|
+
},
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
{workInvocations.length === 0 || turnWorking ? null : (
|
|
384
|
+
<TurnStatusLine
|
|
385
|
+
label={
|
|
386
|
+
workStartedAt !== null && workEndedAt !== null
|
|
387
|
+
? t('thought.workedFor', {
|
|
388
|
+
elapsed: formatElapsed(workEndedAt - workStartedAt),
|
|
389
|
+
})
|
|
390
|
+
: t('thought.worked')
|
|
391
|
+
}
|
|
392
|
+
hint={t('thought.viewDetails')}
|
|
393
|
+
onPress={viewDetails}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
{/* A turn that reasoned without tools: its reasoning, a press away. */}
|
|
397
|
+
{workInvocations.length > 0 || !message.thinking || turnWorking ? null : (
|
|
398
|
+
<TurnStatusLine
|
|
399
|
+
label={t('thought.reasoning')}
|
|
400
|
+
hint={t('thought.viewDetails')}
|
|
401
|
+
onPress={viewDetails}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
{taskLog === null ? null : (
|
|
405
|
+
<TaskList
|
|
406
|
+
tasks={taskLog.tasks}
|
|
407
|
+
revealed={taskLog.revealed}
|
|
408
|
+
collapseOnComplete="all"
|
|
409
|
+
working={false}
|
|
410
|
+
/>
|
|
411
|
+
)}
|
|
412
|
+
{webLog.steps.length === 0 ? null : (
|
|
413
|
+
<WebSearch
|
|
414
|
+
steps={webLog.steps}
|
|
415
|
+
revealed={webLog.revealed}
|
|
416
|
+
working={turnWorking ? t('chat.working') : false}
|
|
417
|
+
labels={{ sources: t('chat.sources') }}
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
{rendered.map(({ key, node }) => (
|
|
421
|
+
<MessageBlockBoundary key={key}>{node}</MessageBlockBoundary>
|
|
422
|
+
))}
|
|
423
|
+
{/* Each card in its own boundary: `cardOf` checks the card's NAME, and its data is
|
|
424
|
+
cast unchecked. */}
|
|
425
|
+
{toolCards.map(({ key, card }) => (
|
|
426
|
+
<MessageBlockBoundary key={key}>
|
|
427
|
+
<ToolResultCard card={card} />
|
|
428
|
+
</MessageBlockBoundary>
|
|
429
|
+
))}
|
|
430
|
+
{questions.length === 0 ? null : (
|
|
431
|
+
<MessageBlockBoundary>
|
|
432
|
+
<AskedQuestions questions={questions} />
|
|
433
|
+
</MessageBlockBoundary>
|
|
434
|
+
)}
|
|
435
|
+
{agent && message.turnOutcome === 'failed' && text.length === 0 ? (
|
|
436
|
+
<Text className="text-sm text-muted-foreground">
|
|
437
|
+
{t('chat.consultFailed', { name: agent.name })}
|
|
438
|
+
</Text>
|
|
439
|
+
) : null}
|
|
440
|
+
{message.source === 'voice' ? (
|
|
441
|
+
<Text className="text-base leading-7 text-foreground">
|
|
442
|
+
{text}
|
|
443
|
+
{message.isStreaming ? '▌' : ''}
|
|
444
|
+
</Text>
|
|
445
|
+
) : (
|
|
446
|
+
<ReplyMarkdown
|
|
447
|
+
content={text}
|
|
448
|
+
toolInvocations={message.toolInvocations}
|
|
449
|
+
researchSources={message.researchProgress?.sources}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
</AiChatAssistantMessage>
|
|
453
|
+
</TurnMenu>
|
|
454
|
+
</View>
|
|
455
|
+
)}
|
|
456
|
+
</>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ThreadSearchHit } from '../../runtime/thread/thread-history';
|
|
2
|
+
import { useThreadSearch } from '../../runtime/use-thread-search';
|
|
3
|
+
import { useLabels } from '../../lib/labels';
|
|
4
|
+
import { Button } from '@oxy.so/bloom/button';
|
|
5
|
+
import { Card, CardBody } from '@oxy.so/bloom/card';
|
|
6
|
+
import { RiCloseLine } from '@oxy.so/bloom/icons/RiCloseLine';
|
|
7
|
+
import { Item } from '@oxy.so/bloom/item';
|
|
8
|
+
import { Search } from '@oxy.so/bloom/search';
|
|
9
|
+
import { Muted } from '@oxy.so/bloom/typography';
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { ScrollView, View } from 'react-native';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Searching what was said in this thread, across every conversation in it.
|
|
15
|
+
*
|
|
16
|
+
* Not the app-wide palette on ⌘K, and the difference is the point: that one
|
|
17
|
+
* finds a chat, this one finds a SENTENCE, and it looks past the stretch on
|
|
18
|
+
* screen into every one before it. `GET /conversations/:id/…` could not serve
|
|
19
|
+
* this — it would stop at the current conversation, which is the one thing the
|
|
20
|
+
* reader can already see.
|
|
21
|
+
*
|
|
22
|
+
* Each result carries the cursor that opens the thread around it. A result you
|
|
23
|
+
* can read and not reach is a wall, so `onJump` is not an extra: it is what the
|
|
24
|
+
* hit is for.
|
|
25
|
+
*
|
|
26
|
+
* Bloom's `Search` for the field and an `Item` per hit, on a `Card` floating
|
|
27
|
+
* over the top of the thread in its 768 column: the card is the surface, so
|
|
28
|
+
* nothing here paints one of its own.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface ThreadSearchProps {
|
|
32
|
+
/** The handle whose thread is searched. */
|
|
33
|
+
handle: string;
|
|
34
|
+
/** Open the thread around this message. */
|
|
35
|
+
onJump: (hit: ThreadSearchHit) => void;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The day a hit was written, in the reader's own locale and timezone. */
|
|
40
|
+
function hitDay(createdAt: string, locale: string): string {
|
|
41
|
+
return new Date(createdAt).toLocaleDateString(locale, {
|
|
42
|
+
day: 'numeric',
|
|
43
|
+
month: 'short',
|
|
44
|
+
year: 'numeric',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ThreadSearch = ({ handle, onJump, onClose }: ThreadSearchProps) => {
|
|
49
|
+
const { t, locale } = useLabels();
|
|
50
|
+
const [query, setQuery] = useState('');
|
|
51
|
+
const { data: hits, isFetching, isError } = useThreadSearch(handle, query);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Told apart deliberately: nothing typed is not a search that found nothing.
|
|
55
|
+
* One is an invitation and the other is an answer, and saying "no results"
|
|
56
|
+
* over an empty field answers a question nobody asked.
|
|
57
|
+
*/
|
|
58
|
+
const asked = query.trim().length > 0;
|
|
59
|
+
const found = hits ?? [];
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<View className="absolute inset-x-0 top-0 z-20 px-4 pt-4" pointerEvents="box-none">
|
|
63
|
+
<View className="w-full max-w-[768px] self-center">
|
|
64
|
+
<Card elevation="m">
|
|
65
|
+
<CardBody>
|
|
66
|
+
<View className="flex-row items-center gap-2">
|
|
67
|
+
<View className="flex-1">
|
|
68
|
+
<Search
|
|
69
|
+
label={t('chat.searchThreadPlaceholder')}
|
|
70
|
+
value={query}
|
|
71
|
+
onChangeText={setQuery}
|
|
72
|
+
onClearText={() => setQuery('')}
|
|
73
|
+
autoFocus
|
|
74
|
+
returnKeyType="search"
|
|
75
|
+
/>
|
|
76
|
+
</View>
|
|
77
|
+
<Button
|
|
78
|
+
size="md"
|
|
79
|
+
appearance="plain"
|
|
80
|
+
tone="neutral"
|
|
81
|
+
iconOnly
|
|
82
|
+
leadingIcon={RiCloseLine}
|
|
83
|
+
accessibilityLabel={t('common.close')}
|
|
84
|
+
onPress={onClose}
|
|
85
|
+
/>
|
|
86
|
+
</View>
|
|
87
|
+
|
|
88
|
+
{!asked ? (
|
|
89
|
+
<View className="px-1 py-4">
|
|
90
|
+
<Muted>{t('chat.searchThreadHint')}</Muted>
|
|
91
|
+
</View>
|
|
92
|
+
) : isError ? (
|
|
93
|
+
<View className="px-1 py-4">
|
|
94
|
+
<Muted>{t('chat.searchThreadFailed')}</Muted>
|
|
95
|
+
</View>
|
|
96
|
+
) : found.length === 0 ? (
|
|
97
|
+
<View className="px-1 py-4">
|
|
98
|
+
<Muted>
|
|
99
|
+
{isFetching ? t('chat.searchThreadSearching') : t('chat.searchThreadEmpty')}
|
|
100
|
+
</Muted>
|
|
101
|
+
</View>
|
|
102
|
+
) : (
|
|
103
|
+
<ScrollView
|
|
104
|
+
// A height in px, not a percentage of a parent that has none:
|
|
105
|
+
// measured in Chromium, the list otherwise sized itself to its
|
|
106
|
+
// content and spilled the results over the thread behind it.
|
|
107
|
+
className="mt-2 max-h-96"
|
|
108
|
+
keyboardShouldPersistTaps="handled"
|
|
109
|
+
>
|
|
110
|
+
{found.map((hit) => (
|
|
111
|
+
<Item
|
|
112
|
+
// The cursor, never `messageId`: a message the server wrote
|
|
113
|
+
// has no client id, and `null` is not a key.
|
|
114
|
+
key={hit.cursor}
|
|
115
|
+
title={hit.snippet}
|
|
116
|
+
subtitle={`${t(
|
|
117
|
+
hit.role === 'user' ? 'chat.searchThreadYou' : 'chat.searchThreadAgent',
|
|
118
|
+
)} · ${hitDay(hit.createdAt, locale)}`}
|
|
119
|
+
onPress={() => onJump(hit)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</ScrollView>
|
|
123
|
+
)}
|
|
124
|
+
</CardBody>
|
|
125
|
+
</Card>
|
|
126
|
+
</View>
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
};
|