@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,1427 @@
|
|
|
1
|
+
import { ASK_USER_QUESTION_TOOL } from '../tools';
|
|
2
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { useLabels } from '../lib/labels';
|
|
6
|
+
import { type AliaArtifact, useAliaRuntime } from './config';
|
|
7
|
+
import { errorCode, errorMessage as getErrorMessage, errorName, errorStatus } from './error-utils';
|
|
8
|
+
import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
|
|
9
|
+
import { aliaQueryKeys } from './query-keys';
|
|
10
|
+
import { createRandomUuid } from './random-uuid';
|
|
11
|
+
import { type AliaHostTools, HostOutcomeQueue, hostRequest } from './host-tools';
|
|
12
|
+
import { API_ROUTES } from './routes';
|
|
13
|
+
import { createSseFrameReader } from './sse-frame-reader';
|
|
14
|
+
import {
|
|
15
|
+
type ConsultEvent,
|
|
16
|
+
lockedFeatureOf,
|
|
17
|
+
parseConsultEvent,
|
|
18
|
+
parseHostAction,
|
|
19
|
+
} from './stream-events';
|
|
20
|
+
import { hasUsableStreamOutput, type StreamOutputEvidence } from './stream-outcome';
|
|
21
|
+
import { failureDetail, readAliaMeta, type FailedTurn } from './turn-failure';
|
|
22
|
+
import type {
|
|
23
|
+
AgentRun,
|
|
24
|
+
Conversation,
|
|
25
|
+
ConversationsInfiniteData,
|
|
26
|
+
Message,
|
|
27
|
+
SendOptions,
|
|
28
|
+
ToolInvocation,
|
|
29
|
+
} from './types';
|
|
30
|
+
import { UsageLimitError } from './usage-limit-error';
|
|
31
|
+
|
|
32
|
+
export type { ToolInvocation };
|
|
33
|
+
export type { FailedTurn };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How a send ended.
|
|
37
|
+
*
|
|
38
|
+
* - `sent`: the turn got an answer, or at least real output. It may STILL
|
|
39
|
+
* carry a `failedTurn` — a stream that produced output and then a
|
|
40
|
+
* synthetic tail keeps the output and shows the tail as an error.
|
|
41
|
+
* - `errored`: no answer, and the person's turn is KEPT in the thread with
|
|
42
|
+
* `failedTurn` attached, retry and all. The caller has nothing to restore.
|
|
43
|
+
* - `failed`: the turn was rolled back to what the list was before the send,
|
|
44
|
+
* so the caller hands the text back to the composer. Only the usage-limit
|
|
45
|
+
* errors end this way now: they open a dialog of their own, and a retry
|
|
46
|
+
* would be the wrong offer next to "upgrade" or "wait".
|
|
47
|
+
* - `aborted`: the person stopped it; partial output is theirs to keep.
|
|
48
|
+
*/
|
|
49
|
+
export type SendOutcome = 'sent' | 'errored' | 'failed' | 'aborted';
|
|
50
|
+
|
|
51
|
+
/** Shape of an error body thrown by the streaming fetch (rate-limit / credit info). */
|
|
52
|
+
interface ThrownErrorBody {
|
|
53
|
+
code?: string;
|
|
54
|
+
message?: string;
|
|
55
|
+
retryable?: boolean;
|
|
56
|
+
retryAfter?: number;
|
|
57
|
+
/** Alia's run id, on a turn that failed after the stream began. */
|
|
58
|
+
reference?: string;
|
|
59
|
+
suggestedAction?: 'wait' | 'upgrade';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Structured `error` object the server may embed in a non-OK response / SSE error event. */
|
|
63
|
+
interface StreamErrorObject {
|
|
64
|
+
code?: string;
|
|
65
|
+
message?: string;
|
|
66
|
+
retryable?: boolean;
|
|
67
|
+
retryAfter?: number;
|
|
68
|
+
suggestedAction?: 'wait' | 'upgrade';
|
|
69
|
+
type?: string;
|
|
70
|
+
/** Alia's run id, on a turn that failed (before any output or part-way). */
|
|
71
|
+
reference?: string;
|
|
72
|
+
details?: {
|
|
73
|
+
limitType?: string;
|
|
74
|
+
current?: number;
|
|
75
|
+
limit?: number;
|
|
76
|
+
tier?: string;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Non-OK response body read from the stream before the SSE loop starts. */
|
|
81
|
+
interface StreamErrorResponse {
|
|
82
|
+
error?: StreamErrorObject | string;
|
|
83
|
+
details?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The artifact a tool result carries, if it carries one: a generated file, or
|
|
88
|
+
* an `artifact` object a tool returned for its own panel.
|
|
89
|
+
*/
|
|
90
|
+
export function artifactOf(
|
|
91
|
+
toolCallId: string,
|
|
92
|
+
name: string | undefined,
|
|
93
|
+
output: unknown,
|
|
94
|
+
): AliaArtifact | null {
|
|
95
|
+
if (output === null || typeof output !== 'object') return null;
|
|
96
|
+
// Structural per-tool output, read the way the tools write it.
|
|
97
|
+
const result = output as Record<string, any>;
|
|
98
|
+
if (name === 'generateFile') {
|
|
99
|
+
const type = result.language ? 'code' : 'markdown';
|
|
100
|
+
return {
|
|
101
|
+
id: toolCallId,
|
|
102
|
+
type,
|
|
103
|
+
content:
|
|
104
|
+
type === 'code'
|
|
105
|
+
? { language: result.language, code: result.content }
|
|
106
|
+
: { content: result.content },
|
|
107
|
+
title: result.filename || result.title || 'Generated file',
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (result.artifact) {
|
|
112
|
+
const a = result.artifact;
|
|
113
|
+
return {
|
|
114
|
+
id: toolCallId,
|
|
115
|
+
type: a.type || 'markdown',
|
|
116
|
+
content: a.data || a.content || a,
|
|
117
|
+
title: a.title || name || 'Artifact',
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface UseConversationStreamOptions {
|
|
125
|
+
/** The conversation the turns belong to; omitted, the server makes the turn's own. */
|
|
126
|
+
conversationId?: string;
|
|
127
|
+
/** The power level (or a device model) every turn is sent with. */
|
|
128
|
+
model?: string;
|
|
129
|
+
/** The agent this conversation is with — its lead — when it is not Alia. */
|
|
130
|
+
agentId?: string | null;
|
|
131
|
+
/**
|
|
132
|
+
* The UI effects the host's client performs (`docs/agent-embedding.md` §2): declared with every
|
|
133
|
+
* request, performed once when the stream asks, and their outcomes sent with the next request.
|
|
134
|
+
*/
|
|
135
|
+
hostTools?: AliaHostTools;
|
|
136
|
+
/**
|
|
137
|
+
* What the host app is, for Alia to know where the person is ("Notes — viewing note #42").
|
|
138
|
+
* Sent as the request's leading system message, which the server reads as client context.
|
|
139
|
+
*/
|
|
140
|
+
clientContext?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The words a message holds, as text: a consulted agent's reply is plain text as it streams. */
|
|
144
|
+
function messageWords(message: Message): string {
|
|
145
|
+
return typeof message.content === 'string' ? message.content : '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useConversationStream({
|
|
149
|
+
conversationId,
|
|
150
|
+
model,
|
|
151
|
+
agentId,
|
|
152
|
+
clientContext,
|
|
153
|
+
hostTools,
|
|
154
|
+
}: UseConversationStreamOptions = {}) {
|
|
155
|
+
const { apiUrl, streamFetch, host } = useAliaRuntime();
|
|
156
|
+
/** Read at send time and when an action arrives: the host's declaration may change between turns. */
|
|
157
|
+
const hostToolsRef = useRef(hostTools);
|
|
158
|
+
hostToolsRef.current = hostTools;
|
|
159
|
+
/** This conversation's host actions: performed once, their outcomes held until a request carries them. */
|
|
160
|
+
const hostOutcomesRef = useRef(new HostOutcomeQueue());
|
|
161
|
+
const labels = useLabels();
|
|
162
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
163
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
164
|
+
const [error, setError] = useState<Error | null>(null);
|
|
165
|
+
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
|
166
|
+
/**
|
|
167
|
+
* The agent proposing that the next stretch of the thread start fresh, or
|
|
168
|
+
* `null` for none.
|
|
169
|
+
*
|
|
170
|
+
* The `reason` is the model's own sentence, in the model's own words. It is
|
|
171
|
+
* carried through untouched so the offer can say WHY instead of appearing as
|
|
172
|
+
* a button with no context.
|
|
173
|
+
*
|
|
174
|
+
* At most one is ever in flight: the server bounds it to one per turn and a
|
|
175
|
+
* later one replaces an unanswered earlier one, so there is nothing to
|
|
176
|
+
* collapse here. And nothing has been written when it arrives — the tool that
|
|
177
|
+
* emits it creates nothing — so ignoring it leaves the thread exactly as it
|
|
178
|
+
* was.
|
|
179
|
+
*/
|
|
180
|
+
const [suggestedNewConversation, setSuggestedNewConversation] = useState<string | null>(null);
|
|
181
|
+
/** The run a delegated agent started for this conversation (`alia.agent_turn`), or `null`. */
|
|
182
|
+
const [agentRun, setAgentRun] = useState<AgentRun | null>(null);
|
|
183
|
+
/**
|
|
184
|
+
* The turn that got no answer, drawn in the thread with an error and a
|
|
185
|
+
* retry — or `null`. See `turn-failure.ts` for why a
|
|
186
|
+
* failure is kept rather than rolled back, and what the server persisted
|
|
187
|
+
* (nothing) that makes a plain re-send safe.
|
|
188
|
+
*/
|
|
189
|
+
const [failedTurn, setFailedTurn] = useState<FailedTurn | null>(null);
|
|
190
|
+
/** What a retry re-sends: the same content and attachments, the same options. */
|
|
191
|
+
const retryRef = useRef<{
|
|
192
|
+
message: Omit<Message, 'id'>;
|
|
193
|
+
options?: SendOptions;
|
|
194
|
+
userMessageId: string;
|
|
195
|
+
} | null>(null);
|
|
196
|
+
/**
|
|
197
|
+
* The connector and skills each user turn was sent with, by its id, so an
|
|
198
|
+
* edit or a regenerate sends it the way it was sent (#608 §6). A message
|
|
199
|
+
* does not carry them and the server does not return them, so this is only
|
|
200
|
+
* known for turns sent while this screen was open.
|
|
201
|
+
*/
|
|
202
|
+
const turnOptionsRef = useRef(new Map<string, Pick<SendOptions, 'mcpServerId' | 'skillNames'>>());
|
|
203
|
+
const { oxyServices, user } = useOxy();
|
|
204
|
+
const queryClient = useQueryClient();
|
|
205
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
206
|
+
|
|
207
|
+
// Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
|
|
208
|
+
const pendingContentRef = useRef('');
|
|
209
|
+
const pendingReasoningRef = useRef('');
|
|
210
|
+
/** A consulted agent's streamed words, by the id of the message they belong to. */
|
|
211
|
+
const pendingAgentContentRef = useRef(new Map<string, string>());
|
|
212
|
+
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
213
|
+
|
|
214
|
+
const flushPendingUpdates = useCallback(() => {
|
|
215
|
+
const content = pendingContentRef.current;
|
|
216
|
+
const reasoning = pendingReasoningRef.current;
|
|
217
|
+
const agentContent = pendingAgentContentRef.current;
|
|
218
|
+
if (!content && !reasoning && agentContent.size === 0) return;
|
|
219
|
+
|
|
220
|
+
pendingContentRef.current = '';
|
|
221
|
+
pendingReasoningRef.current = '';
|
|
222
|
+
pendingAgentContentRef.current = new Map();
|
|
223
|
+
|
|
224
|
+
setMessages((prev) => {
|
|
225
|
+
const updated = prev.map((message) => {
|
|
226
|
+
const words = agentContent.get(message.id);
|
|
227
|
+
return words === undefined
|
|
228
|
+
? message
|
|
229
|
+
: { ...message, content: messageWords(message) + words };
|
|
230
|
+
});
|
|
231
|
+
const lastMessage = updated[updated.length - 1];
|
|
232
|
+
if (lastMessage?.role === 'assistant' && (content || reasoning)) {
|
|
233
|
+
const changes: Partial<Message> = {};
|
|
234
|
+
if (content) changes.content = lastMessage.content + content;
|
|
235
|
+
if (reasoning) changes.thinking = (lastMessage.thinking || '') + reasoning;
|
|
236
|
+
updated[updated.length - 1] = { ...lastMessage, ...changes };
|
|
237
|
+
}
|
|
238
|
+
return updated;
|
|
239
|
+
});
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
/** Drop everything still batched: the turn it belonged to is being undone. */
|
|
243
|
+
const dropPendingUpdates = useCallback(() => {
|
|
244
|
+
pendingContentRef.current = '';
|
|
245
|
+
pendingReasoningRef.current = '';
|
|
246
|
+
pendingAgentContentRef.current = new Map();
|
|
247
|
+
if (flushTimerRef.current) {
|
|
248
|
+
clearTimeout(flushTimerRef.current);
|
|
249
|
+
flushTimerRef.current = null;
|
|
250
|
+
}
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
const scheduleFlush = useCallback(() => {
|
|
254
|
+
if (flushTimerRef.current) return;
|
|
255
|
+
flushTimerRef.current = setTimeout(() => {
|
|
256
|
+
flushTimerRef.current = null;
|
|
257
|
+
flushPendingUpdates();
|
|
258
|
+
}, 50);
|
|
259
|
+
}, [flushPendingUpdates]);
|
|
260
|
+
|
|
261
|
+
// Cleanup: flush remaining content and clear timer on unmount
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
return () => {
|
|
264
|
+
flushPendingUpdates();
|
|
265
|
+
if (flushTimerRef.current) {
|
|
266
|
+
clearTimeout(flushTimerRef.current);
|
|
267
|
+
flushTimerRef.current = null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}, [flushPendingUpdates]);
|
|
271
|
+
|
|
272
|
+
// Ref to avoid messages in append's dep array (avoids recreation every 50ms during streaming)
|
|
273
|
+
// Synced both via useEffect (for streaming updates) and eagerly in setMessagesAndRef
|
|
274
|
+
// so that setMessages + append in the same tick see the correct history.
|
|
275
|
+
const messagesRef = useRef<Message[]>([]);
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
messagesRef.current = messages;
|
|
278
|
+
}, [messages]);
|
|
279
|
+
|
|
280
|
+
// Wrapper that eagerly syncs messagesRef before React re-renders,
|
|
281
|
+
// so append() called in the same tick reads truncated history (e.g. editMessage).
|
|
282
|
+
const setMessagesAndRef = useCallback((update: Message[] | ((prev: Message[]) => Message[])) => {
|
|
283
|
+
if (typeof update === 'function') {
|
|
284
|
+
setMessages((prev) => {
|
|
285
|
+
const next = update(prev);
|
|
286
|
+
messagesRef.current = next;
|
|
287
|
+
return next;
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
messagesRef.current = update;
|
|
291
|
+
setMessages(update);
|
|
292
|
+
}
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A different conversation is a different thread: a failure from the last
|
|
297
|
+
* one must not be drawn under a message in this one.
|
|
298
|
+
*/
|
|
299
|
+
const conversationRef = useRef(conversationId);
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
setFailedTurn(null);
|
|
302
|
+
retryRef.current = null;
|
|
303
|
+
turnOptionsRef.current.clear();
|
|
304
|
+
// Outcomes answer this conversation's calls. The first turn of a new chat gives it its id,
|
|
305
|
+
// which is the same conversation; any other change is a different one.
|
|
306
|
+
const was = conversationRef.current;
|
|
307
|
+
conversationRef.current = conversationId;
|
|
308
|
+
if (was !== undefined && was !== conversationId) hostOutcomesRef.current.clear();
|
|
309
|
+
}, [conversationId]);
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A different account is a different person: the thread on screen is not
|
|
313
|
+
* theirs.
|
|
314
|
+
*
|
|
315
|
+
* The new-chat screen stays mounted across a sign-out or a switch, and in
|
|
316
|
+
* ghost mode its thread exists nowhere but here — so without this the next
|
|
317
|
+
* person to sit down saw the previous one's conversation, and a reply still
|
|
318
|
+
* streaming kept writing into it. The stream is cut and the thread emptied.
|
|
319
|
+
* Signing IN from signed-out keeps what is there: nobody else owned it.
|
|
320
|
+
*/
|
|
321
|
+
const accountId = user?.id ?? null;
|
|
322
|
+
const accountRef = useRef(accountId);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
const was = accountRef.current;
|
|
325
|
+
accountRef.current = accountId;
|
|
326
|
+
if (was === null || was === accountId) return;
|
|
327
|
+
abortControllerRef.current?.abort();
|
|
328
|
+
abortControllerRef.current = null;
|
|
329
|
+
dropPendingUpdates();
|
|
330
|
+
hostOutcomesRef.current.clear();
|
|
331
|
+
setMessagesAndRef([]);
|
|
332
|
+
setIsLoading(false);
|
|
333
|
+
setError(null);
|
|
334
|
+
setConversationTitle(null);
|
|
335
|
+
setSuggestedNewConversation(null);
|
|
336
|
+
setAgentRun(null);
|
|
337
|
+
setFailedTurn(null);
|
|
338
|
+
retryRef.current = null;
|
|
339
|
+
turnOptionsRef.current.clear();
|
|
340
|
+
}, [accountId, setMessagesAndRef, dropPendingUpdates]);
|
|
341
|
+
|
|
342
|
+
const append = useCallback(
|
|
343
|
+
async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
|
|
344
|
+
setIsLoading(true);
|
|
345
|
+
setError(null);
|
|
346
|
+
// A new send answers the old failure, whether it is the retry or a fresh
|
|
347
|
+
// message: either way the card comes down.
|
|
348
|
+
setFailedTurn(null);
|
|
349
|
+
retryRef.current = null;
|
|
350
|
+
|
|
351
|
+
// Everything the send is about to change, so a turn that produces no real
|
|
352
|
+
// output can be undone in one step: the user message, the assistant
|
|
353
|
+
// placeholder, and any history editMessage truncated just before this call.
|
|
354
|
+
const snapshot = messagesRef.current;
|
|
355
|
+
|
|
356
|
+
// Only content the model actually produced counts. The server answers a
|
|
357
|
+
// dead provider, a mid-stream break or a global timeout with HTTP 200 and a
|
|
358
|
+
// stand-in message flagged `alia_meta.synthetic` — that is a failed send
|
|
359
|
+
// wearing a reply's clothes.
|
|
360
|
+
const outputEvidence: StreamOutputEvidence = {
|
|
361
|
+
realOutputChars: 0,
|
|
362
|
+
agentOutputChars: 0,
|
|
363
|
+
durableArtifactCount: 0,
|
|
364
|
+
toolInvocationCount: 0,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const rollback = (): SendOutcome => {
|
|
368
|
+
// Drop anything still batched first: flushPendingUpdates appends to
|
|
369
|
+
// whichever message is last, so a buffered fragment surviving the restore
|
|
370
|
+
// would land on the previous turn's reply.
|
|
371
|
+
dropPendingUpdates();
|
|
372
|
+
setMessagesAndRef(snapshot);
|
|
373
|
+
return 'failed';
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** Keep a half-streamed turn — destroying real output is worse than showing the error. */
|
|
377
|
+
const settleError = (): SendOutcome => {
|
|
378
|
+
if (!hasUsableStreamOutput(outputEvidence)) return rollback();
|
|
379
|
+
settleAssistant('failed');
|
|
380
|
+
return 'sent';
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The server's stand-in for an answer it could not get, if one arrived.
|
|
385
|
+
*
|
|
386
|
+
* Its content is NEVER appended: "all models are busy" rendered under
|
|
387
|
+
* Alia's mark is Alia declining, and it is the one thing this must not
|
|
388
|
+
* read as. The flag is remembered here and answered when the stream ends.
|
|
389
|
+
*/
|
|
390
|
+
let syntheticTail: { retryable: boolean; detail?: string } | null = null;
|
|
391
|
+
/** The reply's text as the model produced it: what the host previews when the turn settles. */
|
|
392
|
+
let replyText = '';
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Keep the person's turn, and hang the failure on it.
|
|
396
|
+
*
|
|
397
|
+
* With no real output the empty assistant placeholder comes out — an
|
|
398
|
+
* empty bubble under a thinking indicator would say an answer is still
|
|
399
|
+
* coming — and the user message stays where it was sent, with the error
|
|
400
|
+
* drawn under it. With real output, everything stays and the error is
|
|
401
|
+
* drawn under the answer as its tail. Either way what a retry needs is
|
|
402
|
+
* parked in `retryRef`, and the whole thing is one state update.
|
|
403
|
+
*/
|
|
404
|
+
const keepFailedTurn = (retryable: boolean, detail?: string): SendOutcome => {
|
|
405
|
+
const partial = hasUsableStreamOutput(outputEvidence);
|
|
406
|
+
if (partial) {
|
|
407
|
+
// The output is kept, so what is still batched is part of it: an
|
|
408
|
+
// in-stream error frame can land inside one flush window, and dropping
|
|
409
|
+
// the buffer drew the "interrupted" card under an empty bubble.
|
|
410
|
+
flushPendingUpdates();
|
|
411
|
+
} else {
|
|
412
|
+
dropPendingUpdates();
|
|
413
|
+
}
|
|
414
|
+
if (partial) {
|
|
415
|
+
settleAssistant('failed');
|
|
416
|
+
} else {
|
|
417
|
+
// The updater form, on purpose: the user row and the placeholder went
|
|
418
|
+
// in through plain `setMessages` calls that may not have rendered yet
|
|
419
|
+
// when a very fast failure lands, so `messagesRef` can still hold the
|
|
420
|
+
// pre-send snapshot here. `prev` is always the queue's own truth.
|
|
421
|
+
setMessagesAndRef((prev) => prev.filter((m) => m.id !== assistantMessage.id));
|
|
422
|
+
}
|
|
423
|
+
retryRef.current = { message, options, userMessageId: userMessage.id };
|
|
424
|
+
setFailedTurn({
|
|
425
|
+
userMessageId: userMessage.id,
|
|
426
|
+
anchorMessageId: partial ? assistantMessage.id : userMessage.id,
|
|
427
|
+
retryable,
|
|
428
|
+
partial,
|
|
429
|
+
detail,
|
|
430
|
+
});
|
|
431
|
+
return partial ? 'sent' : 'errored';
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Stamped here, not on the way back: a thread left open across midnight has
|
|
436
|
+
* to draw its date line as the turn happens, and the server's own stamp only
|
|
437
|
+
* arrives with the next full load. `POST /conversations` already accepts a
|
|
438
|
+
* client `createdAt`, so this is the value that persists too.
|
|
439
|
+
*/
|
|
440
|
+
const userMessage: Message = {
|
|
441
|
+
...message,
|
|
442
|
+
id: createRandomUuid(),
|
|
443
|
+
createdAt: new Date().toISOString(),
|
|
444
|
+
unsaved: true,
|
|
445
|
+
};
|
|
446
|
+
if (options !== undefined) {
|
|
447
|
+
turnOptionsRef.current.set(userMessage.id, {
|
|
448
|
+
mcpServerId: options.mcpServerId,
|
|
449
|
+
skillNames: options.skillNames,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
// Build from the pre-send snapshot before any await. The optimistic user
|
|
453
|
+
// row and assistant placeholder may reach messagesRef while device info is
|
|
454
|
+
// collected; reading the ref afterwards used to send both plus userMessage
|
|
455
|
+
// again, persisting user -> empty assistant -> duplicate user.
|
|
456
|
+
const history = buildOutboundMessages(snapshot, userMessage);
|
|
457
|
+
const messagesToSend = clientContext
|
|
458
|
+
? [{ id: 'client-context', role: 'system', content: clientContext }, ...history]
|
|
459
|
+
: history;
|
|
460
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
461
|
+
// The sidebar's row for this agent, immediately — this is the half that
|
|
462
|
+
// makes sending feel like a chat list rather than a form.
|
|
463
|
+
host.onTurnPreview?.(agentId, typeof message.content === 'string' ? message.content : '');
|
|
464
|
+
|
|
465
|
+
// Create assistant message placeholder. `isStreaming` is the turn's own
|
|
466
|
+
// lifecycle stamp: the thought panel reads "still running" from it rather
|
|
467
|
+
// than from whether the message has text yet, and `settleAssistant` below
|
|
468
|
+
// clears it however the stream ends.
|
|
469
|
+
const assistantMessage: Message = {
|
|
470
|
+
id: createRandomUuid(),
|
|
471
|
+
role: 'assistant',
|
|
472
|
+
content: '',
|
|
473
|
+
toolInvocations: [],
|
|
474
|
+
createdAt: new Date().toISOString(),
|
|
475
|
+
isStreaming: true,
|
|
476
|
+
unsaved: true,
|
|
477
|
+
};
|
|
478
|
+
setMessages((prev) => [...prev, assistantMessage]);
|
|
479
|
+
/** How many agents the lead has consulted in this turn: the next one's `agentMessageId` index. */
|
|
480
|
+
let agentAnswers = 0;
|
|
481
|
+
const nextAgentMessageId = (): string => agentMessageId(assistantMessage.id, agentAnswers++);
|
|
482
|
+
/** Each consultation of this turn, by `consultId`: the message its agent writes into. */
|
|
483
|
+
const consults = new Map<string, string>();
|
|
484
|
+
/** Whether `id` names a message this turn wrote: the question, the reply, an agent's answer. */
|
|
485
|
+
const isThisTurn = (id: string): boolean =>
|
|
486
|
+
id === userMessage.id ||
|
|
487
|
+
id === assistantMessage.id ||
|
|
488
|
+
isAgentMessageOf(id, assistantMessage.id);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* End the assistant message's turn, once.
|
|
492
|
+
*
|
|
493
|
+
* Only a message still marked streaming is touched: the error paths settle
|
|
494
|
+
* it as `failed` before returning, and the `finally` that runs after them
|
|
495
|
+
* must not re-settle it as completed or cancelled. Through the updater,
|
|
496
|
+
* so it lands after every batched content flush queued before it.
|
|
497
|
+
*/
|
|
498
|
+
const settleAssistant = (outcome: NonNullable<Message['turnOutcome']>): void => {
|
|
499
|
+
setMessages((prev) => {
|
|
500
|
+
if (prev.find((m) => m.id === assistantMessage.id)?.isStreaming !== true) return prev;
|
|
501
|
+
// A turn that completed is one the server has stored, under these ids.
|
|
502
|
+
const stored = outcome === 'completed';
|
|
503
|
+
return prev.map((m) => {
|
|
504
|
+
// A consultation still open when the turn ends ends with it.
|
|
505
|
+
const settles =
|
|
506
|
+
m.id === assistantMessage.id ||
|
|
507
|
+
(m.isStreaming === true && isAgentMessageOf(m.id, assistantMessage.id));
|
|
508
|
+
let next = settles ? { ...m, isStreaming: false, turnOutcome: outcome } : m;
|
|
509
|
+
if (stored && next.unsaved === true && isThisTurn(next.id)) {
|
|
510
|
+
const { unsaved: _unsaved, ...rest } = next;
|
|
511
|
+
next = rest;
|
|
512
|
+
}
|
|
513
|
+
return next;
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/** What a consult frame does to the thread: the agent's own message, from start to end. */
|
|
519
|
+
const applyConsult = (event: ConsultEvent): void => {
|
|
520
|
+
if (event.type === 'start') {
|
|
521
|
+
if (consults.has(event.consultId)) return;
|
|
522
|
+
const id = nextAgentMessageId();
|
|
523
|
+
consults.set(event.consultId, id);
|
|
524
|
+
const agentMessage: Message = {
|
|
525
|
+
id,
|
|
526
|
+
unsaved: true,
|
|
527
|
+
role: 'assistant',
|
|
528
|
+
content: '',
|
|
529
|
+
toolInvocations: [],
|
|
530
|
+
createdAt: new Date().toISOString(),
|
|
531
|
+
isStreaming: true,
|
|
532
|
+
agentInfo: { ...event.agent },
|
|
533
|
+
};
|
|
534
|
+
// Above the lead's own reply, which is still being written under it.
|
|
535
|
+
setMessages((prev) => {
|
|
536
|
+
const at = prev.findIndex((m) => m.id === assistantMessage.id);
|
|
537
|
+
return at < 0
|
|
538
|
+
? [...prev, agentMessage]
|
|
539
|
+
: [...prev.slice(0, at), agentMessage, ...prev.slice(at)];
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const id = consults.get(event.consultId);
|
|
544
|
+
if (id === undefined) return;
|
|
545
|
+
if (event.type === 'delta') {
|
|
546
|
+
if (event.content === '') return;
|
|
547
|
+
outputEvidence.agentOutputChars += event.content.length;
|
|
548
|
+
const map = pendingAgentContentRef.current;
|
|
549
|
+
map.set(id, (map.get(id) ?? '') + event.content);
|
|
550
|
+
scheduleFlush();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (event.type === 'tool') {
|
|
554
|
+
if (event.phase === 'call') outputEvidence.toolInvocationCount += 1;
|
|
555
|
+
setMessages((prev) =>
|
|
556
|
+
prev.map((m) => {
|
|
557
|
+
if (m.id !== id) return m;
|
|
558
|
+
const invocations = [...(m.toolInvocations ?? [])];
|
|
559
|
+
const at = invocations.findIndex((call) => call.toolCallId === event.toolCallId);
|
|
560
|
+
const previous = at < 0 ? undefined : invocations[at];
|
|
561
|
+
const invocation: ToolInvocation = {
|
|
562
|
+
toolCallId: event.toolCallId,
|
|
563
|
+
toolName: event.name,
|
|
564
|
+
state: event.phase,
|
|
565
|
+
args: event.input ?? previous?.args,
|
|
566
|
+
...(event.phase === 'result' ? { result: event.output } : {}),
|
|
567
|
+
};
|
|
568
|
+
if (at < 0) invocations.push(invocation);
|
|
569
|
+
else invocations[at] = invocation;
|
|
570
|
+
return { ...m, toolInvocations: invocations };
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// The end: what was streamed lands first, then the agent's final words, if it sent them.
|
|
576
|
+
flushPendingUpdates();
|
|
577
|
+
if (event.content !== undefined) outputEvidence.agentOutputChars += event.content.length;
|
|
578
|
+
setMessages((prev) =>
|
|
579
|
+
prev.map((m) =>
|
|
580
|
+
m.id !== id
|
|
581
|
+
? m
|
|
582
|
+
: {
|
|
583
|
+
...m,
|
|
584
|
+
...(event.content === undefined ? {} : { content: event.content }),
|
|
585
|
+
isStreaming: false,
|
|
586
|
+
turnOutcome: event.status === 'completed' ? 'completed' : 'failed',
|
|
587
|
+
},
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/** The request's own controller: `finally` asks it whether the person stopped the turn. */
|
|
593
|
+
let controller: AbortController | null = null;
|
|
594
|
+
let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
// Collect device info (will be available to AI via tool if needed)
|
|
598
|
+
const deviceInfo = host.deviceInfo ? await host.deviceInfo() : undefined;
|
|
599
|
+
|
|
600
|
+
// Create abort controller for this request
|
|
601
|
+
controller = new AbortController();
|
|
602
|
+
abortControllerRef.current = controller;
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Read at send time rather than closed over: the capability switches
|
|
606
|
+
* live in a menu that stays open across a send, and a value captured
|
|
607
|
+
* when the callback was built would send the state the composer had
|
|
608
|
+
* before the person touched it.
|
|
609
|
+
*/
|
|
610
|
+
const { deepResearch: deepResearchMode, webSearch } = host.turnRequest?.() ?? {};
|
|
611
|
+
|
|
612
|
+
// The host's tools, and the outcomes of the actions it performed since the last request.
|
|
613
|
+
const outcomes = hostOutcomesRef.current.pending();
|
|
614
|
+
const hostMember = hostRequest(hostToolsRef.current, outcomes);
|
|
615
|
+
|
|
616
|
+
// Authentication, the preflight refresh and the single 401 replay belong
|
|
617
|
+
// to the linked Oxy client; this layer never reads or writes a bearer.
|
|
618
|
+
linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
619
|
+
const response = await linked.client.requestAuthenticatedResponse({
|
|
620
|
+
method: 'POST',
|
|
621
|
+
url: API_ROUTES.chat,
|
|
622
|
+
headers: {
|
|
623
|
+
Accept: 'text/event-stream',
|
|
624
|
+
'Content-Type': 'application/json',
|
|
625
|
+
...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
|
|
626
|
+
},
|
|
627
|
+
body: JSON.stringify({
|
|
628
|
+
messages: messagesToSend,
|
|
629
|
+
// The reply is stored under the id it is drawn under here, so a vote
|
|
630
|
+
// or a read-aloud on it needs no reload to find it.
|
|
631
|
+
assistantMessageId: assistantMessage.id,
|
|
632
|
+
stream: true,
|
|
633
|
+
...(conversationId && { conversationId }),
|
|
634
|
+
// Only sent when OFF. `true` is the server's default and every
|
|
635
|
+
// request has behaved that way, so sending it would be noise.
|
|
636
|
+
...(webSearch === false && { webSearch: false }),
|
|
637
|
+
// The power level (or a device model). Oxy picks the model of that
|
|
638
|
+
// level; the client never names a hosted model.
|
|
639
|
+
...(model && { model }),
|
|
640
|
+
...(options?.skillNames?.length ? { skillIds: options.skillNames } : {}),
|
|
641
|
+
...(agentId && { agentId }),
|
|
642
|
+
...(deepResearchMode && { deepResearch: true }),
|
|
643
|
+
...(options?.mcpServerId === undefined ? {} : { mcpServerId: options.mcpServerId }),
|
|
644
|
+
...(hostMember === undefined ? {} : { host: hostMember }),
|
|
645
|
+
}),
|
|
646
|
+
signal: controller.signal,
|
|
647
|
+
...(streamFetch ? { fetch: streamFetch } : {}),
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
if (!response.ok) {
|
|
651
|
+
let errorData: StreamErrorResponse | null = null;
|
|
652
|
+
try {
|
|
653
|
+
// A streaming fetch is body-oriented; .json() may not work for error responses.
|
|
654
|
+
// Read the body manually via the ReadableStream reader.
|
|
655
|
+
if (response.body) {
|
|
656
|
+
const errReader = response.body.getReader();
|
|
657
|
+
const { value } = await errReader.read();
|
|
658
|
+
if (value) {
|
|
659
|
+
errorData = JSON.parse(new TextDecoder().decode(value));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
// Best-effort: if the error body isn't readable/JSON, fall through to
|
|
664
|
+
// the generic status-based error message below.
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// An agent's feature gate refused the turn: the host shows its own upsell, and the
|
|
668
|
+
// person's words go back to the composer for after.
|
|
669
|
+
const lockedFeature = lockedFeatureOf(
|
|
670
|
+
errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
|
|
671
|
+
);
|
|
672
|
+
if (lockedFeature !== null) {
|
|
673
|
+
host.onFeatureLocked?.(lockedFeature);
|
|
674
|
+
return rollback();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Detect usage limit errors (429 rate limit, 402 insufficient credits, 403 model access)
|
|
678
|
+
if (response.status === 429 || response.status === 402 || response.status === 403) {
|
|
679
|
+
const errObj =
|
|
680
|
+
errorData?.error && typeof errorData.error === 'object' ? errorData.error : null;
|
|
681
|
+
const isModelAccess = response.status === 403 && errObj?.code === 'MODEL_NOT_IN_PLAN';
|
|
682
|
+
const isCredits = response.status === 402 || errObj?.code === 'INSUFFICIENT_CREDITS';
|
|
683
|
+
|
|
684
|
+
if (isModelAccess || isCredits || response.status === 429) {
|
|
685
|
+
throw new UsageLimitError({
|
|
686
|
+
type: isModelAccess ? 'model_access' : isCredits ? 'credits' : 'rate_limit',
|
|
687
|
+
code:
|
|
688
|
+
errObj?.code ||
|
|
689
|
+
(isModelAccess
|
|
690
|
+
? 'MODEL_NOT_IN_PLAN'
|
|
691
|
+
: isCredits
|
|
692
|
+
? 'INSUFFICIENT_CREDITS'
|
|
693
|
+
: 'RATE_LIMIT_EXCEEDED'),
|
|
694
|
+
message:
|
|
695
|
+
errObj?.message ||
|
|
696
|
+
(isModelAccess
|
|
697
|
+
? 'Upgrade your plan to use this model.'
|
|
698
|
+
: isCredits
|
|
699
|
+
? "You've run out of credits."
|
|
700
|
+
: "You've sent too many messages."),
|
|
701
|
+
retryable: errObj?.retryable ?? (!isCredits && !isModelAccess),
|
|
702
|
+
retryAfterSeconds: errObj?.retryAfter,
|
|
703
|
+
suggestedAction:
|
|
704
|
+
errObj?.suggestedAction || (isCredits || isModelAccess ? 'upgrade' : 'wait'),
|
|
705
|
+
limitType: errObj?.details?.limitType,
|
|
706
|
+
current: errObj?.details?.current,
|
|
707
|
+
limit: errObj?.details?.limit,
|
|
708
|
+
tier: errObj?.details?.tier,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Session expired or signed out mid-request: friendly sign-in prompt
|
|
714
|
+
// instead of the raw server error string.
|
|
715
|
+
if (response.status === 401) {
|
|
716
|
+
throw new Error(labels.signInRequired);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// A turn that failed before any output, answered before the stream
|
|
720
|
+
// opened (Alia's 503/500 with `code`, `retryable` and `reference`):
|
|
721
|
+
// the same failed-turn card as the in-stream error frame — the app's
|
|
722
|
+
// own line, with code and reference — never the server's English.
|
|
723
|
+
const failed =
|
|
724
|
+
errorData?.error && typeof errorData.error === 'object' ? errorData.error : null;
|
|
725
|
+
if (failed && typeof failed.reference === 'string') {
|
|
726
|
+
setError(new Error(getErrorMessage(failed) || `Server error (${response.status})`));
|
|
727
|
+
setIsLoading(false);
|
|
728
|
+
return keepFailedTurn(
|
|
729
|
+
failed.retryable !== false,
|
|
730
|
+
failureDetail({
|
|
731
|
+
code: typeof failed.code === 'string' ? failed.code : undefined,
|
|
732
|
+
reference: failed.reference,
|
|
733
|
+
}),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Generic error fallback
|
|
738
|
+
let errorMessage = `Server error (${response.status})`;
|
|
739
|
+
if (errorData) {
|
|
740
|
+
const err = errorData.error;
|
|
741
|
+
if (typeof err === 'string') {
|
|
742
|
+
errorMessage = err;
|
|
743
|
+
} else if (getErrorMessage(err)) {
|
|
744
|
+
errorMessage = getErrorMessage(err);
|
|
745
|
+
} else if (typeof errorData.details === 'string') {
|
|
746
|
+
errorMessage = errorData.details;
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
errorMessage = response.statusText || errorMessage;
|
|
750
|
+
}
|
|
751
|
+
throw new Error(errorMessage);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!response.body) {
|
|
755
|
+
throw new Error('No response received from server');
|
|
756
|
+
}
|
|
757
|
+
// The server has the outcomes this request carried: they are its calls' results now.
|
|
758
|
+
hostOutcomesRef.current.delivered(outcomes);
|
|
759
|
+
|
|
760
|
+
const reader = response.body.getReader();
|
|
761
|
+
const decoder = new TextDecoder();
|
|
762
|
+
/**
|
|
763
|
+
* Frame reassembly lives in `sse-frame-reader.ts`.
|
|
764
|
+
*
|
|
765
|
+
* It used to be loose variables here, and that is how the bug happened:
|
|
766
|
+
* `buffer` was declared outside the read loop and survived a chunk
|
|
767
|
+
* boundary, but the current event name was declared INSIDE it and reset
|
|
768
|
+
* on every `reader.read()`. A frame is `event: X\ndata: {…}\n\n`, so any
|
|
769
|
+
* frame split between those two lines lost its name, fell through to the
|
|
770
|
+
* OpenAI-shaped branch below, found no `choices[0]`, and was dropped in
|
|
771
|
+
* silence — most often for the largest payloads, which are the ones that
|
|
772
|
+
* do not fit in one read: `alia.title`, `alia.tool_result`,
|
|
773
|
+
* `alia.plan_preview`, `alia.agent_start`, `alia.host_action`.
|
|
774
|
+
*
|
|
775
|
+
* Two pieces of state that must both outlive a chunk are fields of one
|
|
776
|
+
* object now, and its test feeds a stream split at every byte offset —
|
|
777
|
+
* which is not something a test of this hook could do.
|
|
778
|
+
*/
|
|
779
|
+
const sse = createSseFrameReader();
|
|
780
|
+
let lastOutputAt = 0;
|
|
781
|
+
|
|
782
|
+
while (true) {
|
|
783
|
+
const { done, value } = await reader.read();
|
|
784
|
+
|
|
785
|
+
if (done) {
|
|
786
|
+
// Flush any remaining batched content before checking
|
|
787
|
+
flushPendingUpdates();
|
|
788
|
+
|
|
789
|
+
// The server answered with a stand-in, or with nothing usable at
|
|
790
|
+
// all: the turn stays, with the error under it. (With real output
|
|
791
|
+
// AND a synthetic tail, this keeps the output and marks the tail.)
|
|
792
|
+
if (syntheticTail !== null) {
|
|
793
|
+
return keepFailedTurn(syntheticTail.retryable, syntheticTail.detail);
|
|
794
|
+
}
|
|
795
|
+
if (!hasUsableStreamOutput(outputEvidence)) {
|
|
796
|
+
setError(new Error('No response received from AI'));
|
|
797
|
+
return keepFailedTurn(true);
|
|
798
|
+
}
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// `{ stream: true }`: a multi-byte character split across two reads
|
|
803
|
+
// decodes to U+FFFD without it.
|
|
804
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
805
|
+
|
|
806
|
+
for (const frame of sse.push(chunk)) {
|
|
807
|
+
const data = frame.data;
|
|
808
|
+
const currentEventType = frame.event;
|
|
809
|
+
|
|
810
|
+
// Skip [DONE] marker
|
|
811
|
+
if (data === '[DONE]') {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
const parsed = JSON.parse(data);
|
|
817
|
+
|
|
818
|
+
// ── Named SSE events (Alia extensions) ──
|
|
819
|
+
if (currentEventType) {
|
|
820
|
+
switch (currentEventType) {
|
|
821
|
+
case 'alia.reasoning': {
|
|
822
|
+
const content = parsed.content;
|
|
823
|
+
if (content) {
|
|
824
|
+
pendingReasoningRef.current += content;
|
|
825
|
+
scheduleFlush();
|
|
826
|
+
}
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
case 'alia.tool_result': {
|
|
830
|
+
const { tool_call_id, name, output } = parsed;
|
|
831
|
+
if (tool_call_id) {
|
|
832
|
+
setMessages((prev) => {
|
|
833
|
+
const updated = [...prev];
|
|
834
|
+
const lastMessage = updated[updated.length - 1];
|
|
835
|
+
if (lastMessage?.role === 'assistant') {
|
|
836
|
+
const invocations = [...(lastMessage.toolInvocations || [])];
|
|
837
|
+
const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
|
|
838
|
+
if (idx >= 0) {
|
|
839
|
+
invocations[idx] = {
|
|
840
|
+
...invocations[idx],
|
|
841
|
+
state: 'result',
|
|
842
|
+
result: output,
|
|
843
|
+
};
|
|
844
|
+
} else {
|
|
845
|
+
invocations.push({
|
|
846
|
+
toolCallId: tool_call_id,
|
|
847
|
+
toolName: name || 'unknown',
|
|
848
|
+
state: 'result',
|
|
849
|
+
result: output,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
updated[updated.length - 1] = {
|
|
853
|
+
...lastMessage,
|
|
854
|
+
toolInvocations: invocations,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return updated;
|
|
858
|
+
});
|
|
859
|
+
// The host hears of every finished tool (the memory document
|
|
860
|
+
// may have been rewritten) and of the artifact it produced.
|
|
861
|
+
host.onToolResult?.({ toolCallId: tool_call_id, name, output });
|
|
862
|
+
const artifact = artifactOf(tool_call_id, name, output);
|
|
863
|
+
if (artifact) {
|
|
864
|
+
outputEvidence.durableArtifactCount += 1;
|
|
865
|
+
host.onArtifact?.(artifact);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
case 'alia.agent_start':
|
|
871
|
+
case 'alia.agent_delta':
|
|
872
|
+
case 'alia.agent_tool':
|
|
873
|
+
case 'alia.agent_end': {
|
|
874
|
+
const consult = parseConsultEvent(currentEventType, parsed);
|
|
875
|
+
if (consult !== null) applyConsult(consult);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
case 'alia.host_action': {
|
|
879
|
+
// Performed here, by the client that declared it, once; its outcome rides
|
|
880
|
+
// the next request. The stream does not wait for it.
|
|
881
|
+
const action = parseHostAction(parsed);
|
|
882
|
+
if (action !== null) {
|
|
883
|
+
void hostOutcomesRef.current.dispatch(action, hostToolsRef.current);
|
|
884
|
+
}
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
case 'alia.title': {
|
|
888
|
+
if (parsed.title && parsed.conversationId) {
|
|
889
|
+
queryClient.setQueryData(
|
|
890
|
+
aliaQueryKeys.conversations.detail(parsed.conversationId),
|
|
891
|
+
(old: Conversation | undefined) =>
|
|
892
|
+
old ? { ...old, title: parsed.title } : old,
|
|
893
|
+
);
|
|
894
|
+
queryClient.setQueriesData(
|
|
895
|
+
{ queryKey: aliaQueryKeys.conversations.all },
|
|
896
|
+
(old: ConversationsInfiniteData | undefined) => {
|
|
897
|
+
if (!old?.pages) return old;
|
|
898
|
+
return {
|
|
899
|
+
...old,
|
|
900
|
+
pages: old.pages.map((page) => ({
|
|
901
|
+
...page,
|
|
902
|
+
conversations: page.conversations.map((c) =>
|
|
903
|
+
c.id === parsed.conversationId ? { ...c, title: parsed.title } : c,
|
|
904
|
+
),
|
|
905
|
+
})),
|
|
906
|
+
};
|
|
907
|
+
},
|
|
908
|
+
);
|
|
909
|
+
setConversationTitle(parsed.title);
|
|
910
|
+
}
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
case 'alia.research_progress': {
|
|
914
|
+
setMessages((prev) => {
|
|
915
|
+
const updated = [...prev];
|
|
916
|
+
const lastMessage = updated[updated.length - 1];
|
|
917
|
+
if (lastMessage?.role === 'assistant') {
|
|
918
|
+
updated[updated.length - 1] = {
|
|
919
|
+
...lastMessage,
|
|
920
|
+
researchProgress: {
|
|
921
|
+
phase: parsed.phase,
|
|
922
|
+
message: parsed.message,
|
|
923
|
+
subQuestions:
|
|
924
|
+
parsed.subQuestions || lastMessage.researchProgress?.subQuestions,
|
|
925
|
+
sourcesFound: parsed.sourcesFound,
|
|
926
|
+
currentQuery: parsed.currentQuery,
|
|
927
|
+
iteration: parsed.iteration,
|
|
928
|
+
isComplete: parsed.phase === 'complete',
|
|
929
|
+
// The final event carries the sources; every earlier
|
|
930
|
+
// one carries none, and a progress event after the
|
|
931
|
+
// final one must not erase them.
|
|
932
|
+
sources: parsed.sources ?? lastMessage.researchProgress?.sources,
|
|
933
|
+
totalSearches:
|
|
934
|
+
parsed.totalSearches ?? lastMessage.researchProgress?.totalSearches,
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
return updated;
|
|
939
|
+
});
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
case 'alia.plan_preview': {
|
|
943
|
+
setMessages((prev) => {
|
|
944
|
+
const updated = [...prev];
|
|
945
|
+
const lastMessage = updated[updated.length - 1];
|
|
946
|
+
if (lastMessage?.role === 'assistant') {
|
|
947
|
+
updated[updated.length - 1] = {
|
|
948
|
+
...lastMessage,
|
|
949
|
+
plan: {
|
|
950
|
+
planId: parsed.planId,
|
|
951
|
+
steps: parsed.steps || [],
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return updated;
|
|
956
|
+
});
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
case 'alia.context': {
|
|
960
|
+
// What this turn put in the context window, for the usage card.
|
|
961
|
+
host.onContext?.(
|
|
962
|
+
typeof parsed.conversationId === 'string'
|
|
963
|
+
? parsed.conversationId
|
|
964
|
+
: (conversationId ?? null),
|
|
965
|
+
parsed,
|
|
966
|
+
);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
case 'alia.suggest_new_conversation': {
|
|
970
|
+
// A missing or blank reason degrades to an offer without
|
|
971
|
+
// one rather than to an invented one: the sentence belongs
|
|
972
|
+
// to the model, and a plausible substitute would be worse
|
|
973
|
+
// than none.
|
|
974
|
+
setSuggestedNewConversation(
|
|
975
|
+
typeof parsed.reason === 'string' && parsed.reason.trim() !== ''
|
|
976
|
+
? parsed.reason.trim()
|
|
977
|
+
: '',
|
|
978
|
+
);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
case 'alia.agent_turn': {
|
|
982
|
+
if (parsed.turnId) {
|
|
983
|
+
const run = {
|
|
984
|
+
turnId: String(parsed.turnId),
|
|
985
|
+
agentId: String(parsed.agentId ?? agentId ?? ''),
|
|
986
|
+
};
|
|
987
|
+
setAgentRun(run);
|
|
988
|
+
host.onAgentTurn?.({ ...run, conversationId: conversationId ?? null });
|
|
989
|
+
}
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
default:
|
|
993
|
+
// Unknown named event — skip
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── Standard OpenAI data events ──
|
|
999
|
+
|
|
1000
|
+
// Handle structured error events sent via SSE
|
|
1001
|
+
if (parsed.error) {
|
|
1002
|
+
const err = parsed.error;
|
|
1003
|
+
// An agent's feature gate refused the turn before anything was spent: the host
|
|
1004
|
+
// shows its own upsell, and the person's words go back to the composer.
|
|
1005
|
+
const lockedFeature = lockedFeatureOf(err);
|
|
1006
|
+
if (lockedFeature !== null) {
|
|
1007
|
+
setIsLoading(false);
|
|
1008
|
+
abortControllerRef.current?.abort();
|
|
1009
|
+
abortControllerRef.current = null;
|
|
1010
|
+
reader.cancel();
|
|
1011
|
+
host.onFeatureLocked?.(lockedFeature);
|
|
1012
|
+
return settleError();
|
|
1013
|
+
}
|
|
1014
|
+
// Check for usage limit errors (rate limit, credits, model access)
|
|
1015
|
+
if (
|
|
1016
|
+
errorCode(err) === 'MODEL_NOT_IN_PLAN' ||
|
|
1017
|
+
errorCode(err) === 'INSUFFICIENT_CREDITS' ||
|
|
1018
|
+
err.type === 'rate_limit_error'
|
|
1019
|
+
) {
|
|
1020
|
+
const isRateLimit = err.type === 'rate_limit_error';
|
|
1021
|
+
throw new UsageLimitError({
|
|
1022
|
+
type:
|
|
1023
|
+
errorCode(err) === 'MODEL_NOT_IN_PLAN'
|
|
1024
|
+
? 'model_access'
|
|
1025
|
+
: errorCode(err) === 'INSUFFICIENT_CREDITS'
|
|
1026
|
+
? 'credits'
|
|
1027
|
+
: 'rate_limit',
|
|
1028
|
+
code: String(errorCode(err) ?? ''),
|
|
1029
|
+
message: getErrorMessage(err),
|
|
1030
|
+
retryable: isRateLimit,
|
|
1031
|
+
// A limit to wait out (the plan's usage window, the request
|
|
1032
|
+
// rate) carries when to retry, which drives the dialog's
|
|
1033
|
+
// countdown; running out of credits or a model's plan is an
|
|
1034
|
+
// upgrade.
|
|
1035
|
+
retryAfterSeconds:
|
|
1036
|
+
isRateLimit && typeof err.retryAfter === 'number'
|
|
1037
|
+
? err.retryAfter
|
|
1038
|
+
: undefined,
|
|
1039
|
+
suggestedAction: isRateLimit ? 'wait' : 'upgrade',
|
|
1040
|
+
limitType:
|
|
1041
|
+
typeof err.details?.limitType === 'string'
|
|
1042
|
+
? err.details.limitType
|
|
1043
|
+
: undefined,
|
|
1044
|
+
current:
|
|
1045
|
+
typeof err.details?.current === 'number' ? err.details.current : undefined,
|
|
1046
|
+
limit: typeof err.details?.limit === 'number' ? err.details.limit : undefined,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Generic SSE error — stop, and report it IN the thread: the
|
|
1051
|
+
// turn stays with the error under it, so nothing needs a toast.
|
|
1052
|
+
const msg = getErrorMessage(err) || 'Something went wrong. Please try again.';
|
|
1053
|
+
setError(new Error(msg));
|
|
1054
|
+
setIsLoading(false);
|
|
1055
|
+
if (abortControllerRef.current) {
|
|
1056
|
+
abortControllerRef.current.abort();
|
|
1057
|
+
abortControllerRef.current = null;
|
|
1058
|
+
}
|
|
1059
|
+
reader.cancel();
|
|
1060
|
+
// A turn that failed after it started carries a run reference:
|
|
1061
|
+
// its card says code and reference, never the server's English
|
|
1062
|
+
// prose. A refusal without one (a gate, a bad request) keeps its
|
|
1063
|
+
// message, which is the only thing that says what to change.
|
|
1064
|
+
const detail =
|
|
1065
|
+
typeof err.reference === 'string'
|
|
1066
|
+
? failureDetail({
|
|
1067
|
+
code: typeof err.code === 'string' ? err.code : undefined,
|
|
1068
|
+
reference: err.reference,
|
|
1069
|
+
})
|
|
1070
|
+
: getErrorMessage(err) || undefined;
|
|
1071
|
+
return keepFailedTurn(err.retryable !== false, detail);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Handle usage/credits info (comes at the end of stream).
|
|
1075
|
+
// New format: alia_usage (separate from OpenAI usage), fallback to legacy usage.
|
|
1076
|
+
//
|
|
1077
|
+
// Read BEFORE the choice guard: the server writes it on a chunk
|
|
1078
|
+
// with `choices: []`, which the guard skips — so it was never
|
|
1079
|
+
// read, and neither the balance nor the spending warning reached
|
|
1080
|
+
// the screen.
|
|
1081
|
+
const aliaUsage = parsed.alia_usage || parsed.usage;
|
|
1082
|
+
if (aliaUsage && aliaUsage.credits_remaining !== undefined) {
|
|
1083
|
+
host.onUsage?.(aliaUsage);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Handle OpenAI-compatible format
|
|
1087
|
+
const choice = parsed.choices?.[0];
|
|
1088
|
+
if (!choice) continue;
|
|
1089
|
+
|
|
1090
|
+
const delta = choice.delta;
|
|
1091
|
+
if (!delta) continue;
|
|
1092
|
+
|
|
1093
|
+
// Handle reasoning/thinking content (batched for performance)
|
|
1094
|
+
if (delta.reasoning) {
|
|
1095
|
+
pendingReasoningRef.current += delta.reasoning;
|
|
1096
|
+
scheduleFlush();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Handle text content (batched for performance)
|
|
1100
|
+
if (delta.content) {
|
|
1101
|
+
const meta = readAliaMeta(parsed);
|
|
1102
|
+
if (meta.synthetic) {
|
|
1103
|
+
// Remembered, never rendered — see `syntheticTail`. The
|
|
1104
|
+
// server sends a stop chunk and [DONE] right after, and the
|
|
1105
|
+
// `done` branch turns this into the error under the turn.
|
|
1106
|
+
const detail = failureDetail(meta);
|
|
1107
|
+
syntheticTail = {
|
|
1108
|
+
retryable: meta.retryable,
|
|
1109
|
+
...(detail === undefined ? {} : { detail }),
|
|
1110
|
+
};
|
|
1111
|
+
} else {
|
|
1112
|
+
outputEvidence.realOutputChars += delta.content.length;
|
|
1113
|
+
|
|
1114
|
+
// A tick for the host (a haptic), throttled by time — per-character
|
|
1115
|
+
// counting fired dozens of native bridge calls per second on
|
|
1116
|
+
// fast streams.
|
|
1117
|
+
const now = Date.now();
|
|
1118
|
+
if (now - lastOutputAt >= 150) {
|
|
1119
|
+
lastOutputAt = now;
|
|
1120
|
+
host.onOutput?.();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
replyText += delta.content;
|
|
1124
|
+
pendingContentRef.current += delta.content;
|
|
1125
|
+
scheduleFlush();
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Handle tool calls (OpenAI format: delta.tool_calls)
|
|
1130
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
1131
|
+
for (const tc of delta.tool_calls) {
|
|
1132
|
+
const toolCallId = tc.id;
|
|
1133
|
+
const toolName = tc.function?.name;
|
|
1134
|
+
if (!toolCallId || !toolName) continue;
|
|
1135
|
+
outputEvidence.toolInvocationCount += 1;
|
|
1136
|
+
// A question card is the turn's output, even with no sentence before it.
|
|
1137
|
+
if (toolName === ASK_USER_QUESTION_TOOL) outputEvidence.durableArtifactCount += 1;
|
|
1138
|
+
|
|
1139
|
+
let args: Record<string, unknown> | undefined;
|
|
1140
|
+
if (tc.function?.arguments) {
|
|
1141
|
+
try {
|
|
1142
|
+
args = JSON.parse(tc.function.arguments);
|
|
1143
|
+
} catch {
|
|
1144
|
+
args = { _raw: tc.function.arguments };
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
setMessages((prev) => {
|
|
1149
|
+
const updated = [...prev];
|
|
1150
|
+
const lastMessage = updated[updated.length - 1];
|
|
1151
|
+
if (lastMessage?.role === 'assistant') {
|
|
1152
|
+
const invocations = [...(lastMessage.toolInvocations || [])];
|
|
1153
|
+
const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
|
|
1154
|
+
const invocation: ToolInvocation = {
|
|
1155
|
+
toolCallId,
|
|
1156
|
+
toolName,
|
|
1157
|
+
state: 'call',
|
|
1158
|
+
args,
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
if (idx >= 0) {
|
|
1162
|
+
invocations[idx] = invocation;
|
|
1163
|
+
} else {
|
|
1164
|
+
invocations.push(invocation);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
updated[updated.length - 1] = {
|
|
1168
|
+
...lastMessage,
|
|
1169
|
+
toolInvocations: invocations,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
return updated;
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Handle tool results (custom extension: delta.tool_result)
|
|
1178
|
+
if (delta.tool_result) {
|
|
1179
|
+
const { tool_call_id, name, output } = delta.tool_result;
|
|
1180
|
+
if (tool_call_id) {
|
|
1181
|
+
setMessages((prev) => {
|
|
1182
|
+
const updated = [...prev];
|
|
1183
|
+
const lastMessage = updated[updated.length - 1];
|
|
1184
|
+
if (lastMessage?.role === 'assistant') {
|
|
1185
|
+
const invocations = [...(lastMessage.toolInvocations || [])];
|
|
1186
|
+
const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
|
|
1187
|
+
|
|
1188
|
+
if (idx >= 0) {
|
|
1189
|
+
invocations[idx] = { ...invocations[idx], state: 'result', result: output };
|
|
1190
|
+
} else {
|
|
1191
|
+
invocations.push({
|
|
1192
|
+
toolCallId: tool_call_id,
|
|
1193
|
+
toolName: name || 'unknown',
|
|
1194
|
+
state: 'result',
|
|
1195
|
+
result: output,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
updated[updated.length - 1] = {
|
|
1200
|
+
...lastMessage,
|
|
1201
|
+
toolInvocations: invocations,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
return updated;
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
host.onToolResult?.({ toolCallId: tool_call_id, name, output });
|
|
1208
|
+
const artifact = artifactOf(tool_call_id, name, output);
|
|
1209
|
+
if (artifact) {
|
|
1210
|
+
outputEvidence.durableArtifactCount += 1;
|
|
1211
|
+
host.onArtifact?.(artifact);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Handle error events from server
|
|
1217
|
+
if (parsed.type === 'error') {
|
|
1218
|
+
const errMsg =
|
|
1219
|
+
typeof parsed.error === 'string'
|
|
1220
|
+
? parsed.error
|
|
1221
|
+
: parsed.error?.message || JSON.stringify(parsed.error);
|
|
1222
|
+
setError(new Error(errMsg));
|
|
1223
|
+
setIsLoading(false);
|
|
1224
|
+
|
|
1225
|
+
// Abort the stream
|
|
1226
|
+
if (abortControllerRef.current) {
|
|
1227
|
+
abortControllerRef.current.abort();
|
|
1228
|
+
abortControllerRef.current = null;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Break out of the streaming loop
|
|
1232
|
+
reader.cancel();
|
|
1233
|
+
return keepFailedTurn(true, errMsg);
|
|
1234
|
+
}
|
|
1235
|
+
} catch (frameError: unknown) {
|
|
1236
|
+
/**
|
|
1237
|
+
* Malformed SSE fragments are expected mid-stream; the next
|
|
1238
|
+
* complete event supersedes them.
|
|
1239
|
+
*
|
|
1240
|
+
* A `UsageLimitError` is NOT one of those. It is thrown
|
|
1241
|
+
* deliberately from the `parsed.error` branch above so the outer
|
|
1242
|
+
* handler can show the upgrade dialog — and it was thrown from
|
|
1243
|
+
* inside this same `try`, so this `catch` ate it. An in-stream
|
|
1244
|
+
* `INSUFFICIENT_CREDITS`, `MODEL_NOT_IN_PLAN` or
|
|
1245
|
+
* `rate_limit_error` therefore did nothing at all: the loop kept
|
|
1246
|
+
* reading, the stream ended, and the user saw a reply that
|
|
1247
|
+
* simply stopped with no error and no way to act on it. (The
|
|
1248
|
+
* generic-error branch beside it escaped only because it uses
|
|
1249
|
+
* `return` rather than `throw`.)
|
|
1250
|
+
*/
|
|
1251
|
+
// `instanceof` AND the name, matching the outer handler: Hermes
|
|
1252
|
+
// can break `instanceof` for Error subclasses, and a rethrow that
|
|
1253
|
+
// misses is the same silent swallow this fixes.
|
|
1254
|
+
if (
|
|
1255
|
+
frameError instanceof UsageLimitError ||
|
|
1256
|
+
errorName(frameError) === 'UsageLimitError'
|
|
1257
|
+
) {
|
|
1258
|
+
throw frameError;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
return 'sent';
|
|
1265
|
+
} catch (e: unknown) {
|
|
1266
|
+
// Ignore abort errors (user cancelled) — partial output is theirs to keep.
|
|
1267
|
+
if (e instanceof Error && errorName(e) === 'AbortError') {
|
|
1268
|
+
return 'aborted';
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// UsageLimitError thrown from the 429/402 handler above
|
|
1272
|
+
// Check both instanceof AND name — Hermes can break instanceof for Error subclasses
|
|
1273
|
+
if (e instanceof UsageLimitError || errorName(e) === 'UsageLimitError') {
|
|
1274
|
+
setError(e instanceof Error ? e : new Error(getErrorMessage(e)));
|
|
1275
|
+
return settleError();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// A streaming fetch may throw a non-Error object (e.g. the response body)
|
|
1279
|
+
// Try to detect rate limit / credit errors from the thrown object
|
|
1280
|
+
if (e && typeof e === 'object' && !(e instanceof Error)) {
|
|
1281
|
+
const thrown = e as {
|
|
1282
|
+
status?: number;
|
|
1283
|
+
error?: ThrownErrorBody;
|
|
1284
|
+
body?: { error?: ThrownErrorBody };
|
|
1285
|
+
};
|
|
1286
|
+
const status = thrown.status || errorStatus(e);
|
|
1287
|
+
const errBody: ThrownErrorBody | undefined =
|
|
1288
|
+
thrown.error || thrown.body?.error || (thrown as ThrownErrorBody);
|
|
1289
|
+
if (
|
|
1290
|
+
status === 429 ||
|
|
1291
|
+
status === 402 ||
|
|
1292
|
+
errBody?.code === 'RATE_LIMIT_EXCEEDED' ||
|
|
1293
|
+
errBody?.code === 'INSUFFICIENT_CREDITS'
|
|
1294
|
+
) {
|
|
1295
|
+
const isCredits = status === 402 || errBody?.code === 'INSUFFICIENT_CREDITS';
|
|
1296
|
+
const usageError = new UsageLimitError({
|
|
1297
|
+
type: isCredits ? 'credits' : 'rate_limit',
|
|
1298
|
+
code: errBody?.code || (isCredits ? 'INSUFFICIENT_CREDITS' : 'RATE_LIMIT_EXCEEDED'),
|
|
1299
|
+
message:
|
|
1300
|
+
errBody?.message ||
|
|
1301
|
+
(isCredits ? "You've run out of credits." : "You've sent too many messages."),
|
|
1302
|
+
retryable: errBody?.retryable ?? !isCredits,
|
|
1303
|
+
retryAfterSeconds: errBody?.retryAfter,
|
|
1304
|
+
suggestedAction: errBody?.suggestedAction || (isCredits ? 'upgrade' : 'wait'),
|
|
1305
|
+
});
|
|
1306
|
+
setError(usageError);
|
|
1307
|
+
return settleError();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Everything else — the network, a 5xx, a 401 — keeps the turn in the
|
|
1312
|
+
// thread with the error under it and a retry beside it. The message is
|
|
1313
|
+
// shown as the card's detail line, not as an answer.
|
|
1314
|
+
const finalError =
|
|
1315
|
+
e instanceof Error
|
|
1316
|
+
? e
|
|
1317
|
+
: new Error(
|
|
1318
|
+
typeof e === 'string' ? e : getErrorMessage(e) || 'An unexpected error occurred',
|
|
1319
|
+
);
|
|
1320
|
+
setError(finalError);
|
|
1321
|
+
return keepFailedTurn(true, finalError.message);
|
|
1322
|
+
} finally {
|
|
1323
|
+
// Flush any remaining batched content
|
|
1324
|
+
flushPendingUpdates();
|
|
1325
|
+
if (flushTimerRef.current) {
|
|
1326
|
+
clearTimeout(flushTimerRef.current);
|
|
1327
|
+
flushTimerRef.current = null;
|
|
1328
|
+
}
|
|
1329
|
+
abortControllerRef.current = null;
|
|
1330
|
+
linked?.dispose();
|
|
1331
|
+
// A turn that is still streaming here ended without an error path
|
|
1332
|
+
// settling it: on its own, or because `stop()` aborted the controller.
|
|
1333
|
+
// (The error paths abort it too, but they have settled it first.)
|
|
1334
|
+
settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
|
|
1335
|
+
setIsLoading(false);
|
|
1336
|
+
/*
|
|
1337
|
+
* The answer replaces your own line in the sidebar — once per turn rather
|
|
1338
|
+
* than once per token. Read from what this turn received, not from the
|
|
1339
|
+
* rendered messages, which have not caught up with the last batched flush.
|
|
1340
|
+
*/
|
|
1341
|
+
if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
[
|
|
1345
|
+
apiUrl,
|
|
1346
|
+
streamFetch,
|
|
1347
|
+
host,
|
|
1348
|
+
labels,
|
|
1349
|
+
oxyServices,
|
|
1350
|
+
queryClient,
|
|
1351
|
+
conversationId,
|
|
1352
|
+
model,
|
|
1353
|
+
agentId,
|
|
1354
|
+
clientContext,
|
|
1355
|
+
scheduleFlush,
|
|
1356
|
+
flushPendingUpdates,
|
|
1357
|
+
dropPendingUpdates,
|
|
1358
|
+
setMessagesAndRef,
|
|
1359
|
+
],
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const stop = useCallback(() => {
|
|
1363
|
+
if (abortControllerRef.current) {
|
|
1364
|
+
abortControllerRef.current.abort();
|
|
1365
|
+
abortControllerRef.current = null;
|
|
1366
|
+
}
|
|
1367
|
+
setIsLoading(false);
|
|
1368
|
+
}, []);
|
|
1369
|
+
|
|
1370
|
+
const clearError = useCallback(() => setError(null), []);
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Send the failed turn again: the same content and attachments, the same
|
|
1374
|
+
* options, through the same `append`.
|
|
1375
|
+
*
|
|
1376
|
+
* The thread is cut back to just before the failed user message first —
|
|
1377
|
+
* the same truncation an edit does — so the re-sent turn lands where the
|
|
1378
|
+
* failed one was rather than after it, and the history the server receives
|
|
1379
|
+
* holds the message once. It was never persisted (see `turn-failure.ts`),
|
|
1380
|
+
* so there is no second row to avoid on that side either. Cut from
|
|
1381
|
+
* `messagesRef` directly and set as an array, not through an updater, so
|
|
1382
|
+
* `append`'s snapshot sees the cut regardless of when React runs it.
|
|
1383
|
+
*/
|
|
1384
|
+
const retryFailedTurn = useCallback(async (): Promise<SendOutcome> => {
|
|
1385
|
+
const retry = retryRef.current;
|
|
1386
|
+
if (retry === null) return 'failed';
|
|
1387
|
+
const current = messagesRef.current;
|
|
1388
|
+
const idx = current.findIndex((m) => m.id === retry.userMessageId);
|
|
1389
|
+
if (idx >= 0) setMessagesAndRef(current.slice(0, idx));
|
|
1390
|
+
return append(retry.message, retry.options);
|
|
1391
|
+
}, [append, setMessagesAndRef]);
|
|
1392
|
+
|
|
1393
|
+
/** Take the error down without retrying — the thread was cleared, or the person moved on. */
|
|
1394
|
+
const clearFailedTurn = useCallback(() => {
|
|
1395
|
+
setFailedTurn(null);
|
|
1396
|
+
retryRef.current = null;
|
|
1397
|
+
}, []);
|
|
1398
|
+
|
|
1399
|
+
/** Put the offer away. It wrote nothing, so there is nothing else to undo. */
|
|
1400
|
+
const dismissSuggestedNewConversation = useCallback(() => {
|
|
1401
|
+
setSuggestedNewConversation(null);
|
|
1402
|
+
}, []);
|
|
1403
|
+
|
|
1404
|
+
/** The options a user turn was sent with, if it was sent from this screen. */
|
|
1405
|
+
const turnOptionsOf = useCallback(
|
|
1406
|
+
(userMessageId: string) => turnOptionsRef.current.get(userMessageId),
|
|
1407
|
+
[],
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
return {
|
|
1411
|
+
messages,
|
|
1412
|
+
isLoading,
|
|
1413
|
+
error,
|
|
1414
|
+
append,
|
|
1415
|
+
stop,
|
|
1416
|
+
setMessages: setMessagesAndRef,
|
|
1417
|
+
conversationTitle,
|
|
1418
|
+
clearError,
|
|
1419
|
+
suggestedNewConversation,
|
|
1420
|
+
dismissSuggestedNewConversation,
|
|
1421
|
+
agentRun,
|
|
1422
|
+
failedTurn,
|
|
1423
|
+
retryFailedTurn,
|
|
1424
|
+
clearFailedTurn,
|
|
1425
|
+
turnOptionsOf,
|
|
1426
|
+
};
|
|
1427
|
+
}
|