orchid-ai 2.1.4 → 2.2.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.
@@ -10,13 +10,6 @@ import {
10
10
  orchidAiProcessTraceHasDisplayableContent,
11
11
  } from '../orchidAiProcessTrace';
12
12
 
13
- /**
14
- * Default status message strings. Export these so server-side code can import
15
- * and reference the same values, making it easy to keep client and server in sync
16
- * or swap them out per-app.
17
- *
18
- * Set showStatus: false on the hook to suppress status display entirely.
19
- */
20
13
  export const ORCHID_AI_DEFAULT_STATUS = {
21
14
  thinking: 'Thinking',
22
15
  compilingResponse: ORCHID_AI_SSE_STATUS_CLEAR_STREAM,
@@ -24,25 +17,29 @@ export const ORCHID_AI_DEFAULT_STATUS = {
24
17
  };
25
18
 
26
19
  /**
27
- * React hook for Orchid AI chat. Handles SSE streaming when the server responds
28
- * with Content-Type: text/event-stream, falling back to plain JSON for non-streaming
29
- * backends.
20
+ * React hook for Orchid AI chat. Handles SSE streaming, stop-generation, and
21
+ * multi-slot request queuing (messages submitted while loading are held out of
22
+ * chat history until they fire, then inserted in order).
30
23
  *
31
24
  * @param {object} opts
32
- * @param {string} opts.endpoint - POST endpoint URL
33
- * @param {(userMessage: string, history: Array, sendOptions?: object) => object} opts.buildBody - builds the request body
34
- * @param {() => object} [opts.getHeaders] - returns extra request headers (e.g. CSRF token)
35
- * @param {boolean} [opts.showStatus=true] - set false to suppress status text entirely
36
- * @param {Array} [opts.initialMessages=[]] - seed the conversation (e.g. from localStorage)
25
+ * @param {string} opts.endpoint
26
+ * @param {(userMessage: string, history: Array, sendOptions?: object) => object} opts.buildBody
27
+ * @param {() => object} [opts.getHeaders]
28
+ * @param {boolean} [opts.showStatus=true]
29
+ * @param {Array} [opts.initialMessages=[]]
37
30
  *
38
- * @returns {{ messages, loading, statusText, sendMessage, clearMessages }}
31
+ * @returns {{ messages, loading, statusText, queuedMessages, sendMessage, stopGeneration, clearQueuedMessage, clearMessages }}
39
32
  */
40
33
  export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus = true, initialMessages = [] }) {
41
34
  const [messages, setMessages] = useState(initialMessages);
42
35
  const [loading, setLoading] = useState(false);
43
36
  const [statusText, setStatusText] = useState('');
37
+ const [queuedMessages, setQueuedMessages] = useState([]);
44
38
 
45
39
  const messagesRef = useRef(initialMessages);
40
+ const abortRef = useRef(null);
41
+ // Array of { message, sendOptions } held back from chat history until each fires.
42
+ const queueRef = useRef([]);
46
43
 
47
44
  const buildBodyRef = useRef(buildBody);
48
45
  const getHeadersRef = useRef(getHeaders);
@@ -57,149 +54,243 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
57
54
  });
58
55
  }, []);
59
56
 
60
- const sendMessage = useCallback(
61
- async (userMessage, sendOptions = {}) => {
62
- const history = messagesRef.current.slice();
63
- addMessage({ role: 'user', content: userMessage });
64
- setLoading(true);
65
- setStatusText('');
57
+ const stopGeneration = useCallback(() => {
58
+ abortRef.current?.abort();
59
+ }, []);
66
60
 
67
- try {
68
- const response = await fetch(endpoint, {
69
- method: 'POST',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- ...getHeadersRef.current?.(),
73
- },
74
- body: JSON.stringify(buildBodyRef.current(userMessage, history, sendOptions)),
75
- });
61
+ const clearQueuedMessage = useCallback((index) => {
62
+ if (typeof index === 'number') {
63
+ queueRef.current = queueRef.current.filter((_, i) => i !== index);
64
+ } else {
65
+ queueRef.current = [];
66
+ }
67
+ setQueuedMessages(queueRef.current.map((q) => q.message));
68
+ }, []);
76
69
 
77
- const contentType = response.headers.get('content-type') ?? '';
70
+ /**
71
+ * Internal — makes the actual API call without touching message history.
72
+ * `history` is a snapshot captured before the user message was appended.
73
+ */
74
+ const _doRequest = useCallback(async (userMessage, sendOptions, history) => {
75
+ setLoading(true);
76
+ setStatusText('');
78
77
 
79
- if (contentType.includes('text/event-stream') && response.body) {
80
- const reader = response.body.getReader();
81
- const decoder = new TextDecoder();
82
- let buffer = '';
83
- const collector = createOrchidAiProcessTraceCollector();
78
+ const controller = new AbortController();
79
+ abortRef.current = controller;
80
+ let collector = null;
81
+ let rafId = null;
84
82
 
85
- addMessage({
86
- role: 'assistant',
87
- content: '',
88
- isStreaming: true,
89
- processTrace: snapshotOrchidAiProcessTraceItems(collector),
90
- processInterimLive: '',
83
+ try {
84
+ const response = await fetch(endpoint, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ ...getHeadersRef.current?.(),
89
+ },
90
+ body: JSON.stringify(buildBodyRef.current(userMessage, history, sendOptions)),
91
+ signal: controller.signal,
92
+ });
93
+
94
+ const contentType = response.headers.get('content-type') ?? '';
95
+
96
+ if (contentType.includes('text/event-stream') && response.body) {
97
+ const reader = response.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buffer = '';
100
+ collector = createOrchidAiProcessTraceCollector();
101
+
102
+ addMessage({
103
+ role: 'assistant',
104
+ content: '',
105
+ isStreaming: true,
106
+ processTrace: snapshotOrchidAiProcessTraceItems(collector),
107
+ processInterimLive: '',
108
+ });
109
+
110
+ const patchStreamingAssistant = () => {
111
+ setMessages((prev) => {
112
+ const next = [...prev];
113
+ const last = next[next.length - 1];
114
+ if (last?.role !== 'assistant' || last?.isStreaming !== true) return prev;
115
+ next[next.length - 1] = {
116
+ ...last,
117
+ content: collector.getLiveMain(),
118
+ processTrace: augmentLiveProcessTraceSnapshot(
119
+ snapshotOrchidAiProcessTraceItems(collector),
120
+ collector.getLiveMain(),
121
+ collector.getLiveInterim()
122
+ ),
123
+ processInterimLive: collector.getLiveInterim(),
124
+ };
125
+ messagesRef.current = next;
126
+ return next;
127
+ });
128
+ };
129
+
130
+ // Batch delta-driven re-renders to one per animation frame (~60fps max).
131
+ // Status/query events still update immediately since they're infrequent.
132
+ const schedulePatch = () => {
133
+ if (rafId !== null) return;
134
+ rafId = requestAnimationFrame(() => {
135
+ rafId = null;
136
+ patchStreamingAssistant();
91
137
  });
138
+ };
92
139
 
93
- const patchStreamingAssistant = () => {
94
- setMessages((prev) => {
95
- const next = [...prev];
96
- const last = next[next.length - 1];
97
- if (last?.role !== 'assistant' || last?.isStreaming !== true) return prev;
98
- next[next.length - 1] = {
99
- ...last,
100
- content: collector.getLiveMain(),
101
- processTrace: augmentLiveProcessTraceSnapshot(
102
- snapshotOrchidAiProcessTraceItems(collector),
103
- collector.getLiveMain(),
104
- collector.getLiveInterim()
105
- ),
106
- processInterimLive: collector.getLiveInterim(),
107
- };
108
- messagesRef.current = next;
109
- return next;
110
- });
111
- };
112
-
113
- while (true) {
114
- const { done, value } = await reader.read();
115
- if (done) break;
116
- buffer += decoder.decode(value, { stream: true });
117
- const lines = buffer.split('\n');
118
- buffer = lines.pop() ?? '';
119
-
120
- for (const line of lines) {
121
- if (!line.startsWith('data: ')) continue;
122
- try {
123
- const event = JSON.parse(line.slice(6));
124
- if (event.type === 'status') {
125
- if (showStatus) setStatusText(event.text);
126
- collector.onStatus(event.text, {
127
- isClearStream: orchidAiStatusClearsStreamBuffer(event.text),
128
- });
129
- patchStreamingAssistant();
130
- } else if (event.type === 'query') {
131
- collector.onQuery(event.tool, event.input);
132
- patchStreamingAssistant();
133
- } else if (event.type === 'delta') {
134
- collector.onDelta(event.text);
135
- patchStreamingAssistant();
136
- } else if (event.type === 'done') {
137
- const rawTrace = collector.buildPersistedTrace();
138
- const trace =
139
- rawTrace && orchidAiProcessTraceHasDisplayableContent(rawTrace, '')
140
- ? rawTrace
141
- : undefined;
142
- setMessages((prev) => {
143
- const next = [...prev];
144
- const last = next[next.length - 1];
145
- const finalMsg = {
146
- role: 'assistant',
147
- content: event.response,
148
- truncated: event.truncated === true,
149
- ...(trace ? { processTrace: trace } : {}),
150
- ...(event.queryContext ? { queryContext: event.queryContext } : {}),
151
- };
152
- if (last?.role === 'assistant' && last?.isStreaming) {
153
- next[next.length - 1] = finalMsg;
154
- } else {
155
- next.push(finalMsg);
156
- }
157
- messagesRef.current = next;
158
- return next;
159
- });
160
- } else if (event.type === 'error') {
161
- setMessages((prev) => {
162
- const next = [...prev];
163
- const last = next[next.length - 1];
164
- const errMsg = { role: 'assistant', content: event.error || 'Something went wrong.' };
165
- if (last?.role === 'assistant' && last?.isStreaming) {
166
- next[next.length - 1] = errMsg;
167
- } else {
168
- next.push(errMsg);
169
- }
170
- messagesRef.current = next;
171
- return next;
172
- });
173
- }
174
- } catch {
175
- // ignore malformed SSE lines
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ buffer += decoder.decode(value, { stream: true });
144
+ const lines = buffer.split('\n');
145
+ buffer = lines.pop() ?? '';
146
+
147
+ for (const line of lines) {
148
+ if (!line.startsWith('data: ')) continue;
149
+ try {
150
+ const event = JSON.parse(line.slice(6));
151
+ if (event.type === 'status') {
152
+ if (showStatus) setStatusText(event.text);
153
+ collector.onStatus(event.text, {
154
+ isClearStream: orchidAiStatusClearsStreamBuffer(event.text),
155
+ });
156
+ patchStreamingAssistant();
157
+ } else if (event.type === 'query') {
158
+ collector.onQuery(event.tool, event.input);
159
+ patchStreamingAssistant();
160
+ } else if (event.type === 'delta') {
161
+ collector.onDelta(event.text);
162
+ schedulePatch();
163
+ } else if (event.type === 'done') {
164
+ const rawTrace = collector.buildPersistedTrace();
165
+ const trace =
166
+ rawTrace && orchidAiProcessTraceHasDisplayableContent(rawTrace, '')
167
+ ? rawTrace
168
+ : undefined;
169
+ setMessages((prev) => {
170
+ const next = [...prev];
171
+ const last = next[next.length - 1];
172
+ const finalMsg = {
173
+ role: 'assistant',
174
+ content: event.response,
175
+ truncated: event.truncated === true,
176
+ ...(trace ? { processTrace: trace } : {}),
177
+ ...(event.queryContext ? { queryContext: event.queryContext } : {}),
178
+ };
179
+ if (last?.role === 'assistant' && last?.isStreaming) {
180
+ next[next.length - 1] = finalMsg;
181
+ } else {
182
+ next.push(finalMsg);
183
+ }
184
+ messagesRef.current = next;
185
+ return next;
186
+ });
187
+ } else if (event.type === 'error') {
188
+ setMessages((prev) => {
189
+ const next = [...prev];
190
+ const last = next[next.length - 1];
191
+ const errMsg = { role: 'assistant', content: event.error || 'Something went wrong.' };
192
+ if (last?.role === 'assistant' && last?.isStreaming) {
193
+ next[next.length - 1] = errMsg;
194
+ } else {
195
+ next.push(errMsg);
196
+ }
197
+ messagesRef.current = next;
198
+ return next;
199
+ });
176
200
  }
201
+ } catch {
202
+ // ignore malformed SSE lines
177
203
  }
178
204
  }
205
+ }
206
+ } else {
207
+ const data = await response.json();
208
+ if (response.ok) {
209
+ addMessage({
210
+ role: 'assistant',
211
+ content: data.response,
212
+ truncated: data.truncated,
213
+ ...(data.queryContext ? { queryContext: data.queryContext } : {}),
214
+ });
179
215
  } else {
180
- const data = await response.json();
181
- if (response.ok) {
182
- addMessage({
216
+ addMessage({ role: 'assistant', content: data.error || 'Something went wrong.' });
217
+ }
218
+ }
219
+ } catch (err) {
220
+ if (err?.name === 'AbortError') {
221
+ // User-initiated stop — finalise the partial streaming message if one exists.
222
+ setMessages((prev) => {
223
+ const next = [...prev];
224
+ const last = next[next.length - 1];
225
+ if (last?.role === 'assistant' && last?.isStreaming) {
226
+ const rawTrace = collector?.buildPersistedTrace();
227
+ const trace =
228
+ rawTrace && orchidAiProcessTraceHasDisplayableContent(rawTrace, '')
229
+ ? rawTrace
230
+ : undefined;
231
+ next[next.length - 1] = {
183
232
  role: 'assistant',
184
- content: data.response,
185
- truncated: data.truncated,
186
- ...(data.queryContext ? { queryContext: data.queryContext } : {}),
187
- });
188
- } else {
189
- addMessage({ role: 'assistant', content: data.error || 'Something went wrong.' });
233
+ content: collector?.getLiveMain() ?? '',
234
+ stopped: true,
235
+ ...(trace ? { processTrace: trace } : {}),
236
+ };
237
+ messagesRef.current = next;
190
238
  }
191
- }
192
- } catch {
193
- addMessage({
194
- role: 'assistant',
195
- content: 'Network error. Please check your connection and try again.',
239
+ return next;
196
240
  });
197
- } finally {
198
- setLoading(false);
199
- setStatusText('');
241
+ // Fall through to finally — do not add a network-error message.
242
+ return;
200
243
  }
244
+ setMessages((prev) => {
245
+ const next = [...prev];
246
+ const last = next[next.length - 1];
247
+ const errMsg = { role: 'assistant', content: 'Network error. Please check your connection and try again.' };
248
+ if (last?.role === 'assistant' && last?.isStreaming) {
249
+ next[next.length - 1] = errMsg;
250
+ } else {
251
+ next.push(errMsg);
252
+ }
253
+ messagesRef.current = next;
254
+ return next;
255
+ });
256
+ } finally {
257
+ if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
258
+ abortRef.current = null;
259
+ setLoading(false);
260
+ setStatusText('');
261
+
262
+ // Auto-fire the next queued message if one exists. The queued user message
263
+ // was NOT added to messages while waiting, so we insert it now before firing.
264
+ if (queueRef.current.length > 0) {
265
+ const [next, ...rest] = queueRef.current;
266
+ queueRef.current = rest;
267
+ setQueuedMessages(rest.map((q) => q.message));
268
+ const history = messagesRef.current.slice();
269
+ const attPreviews = next.sendOptions?.attachments?.map(({ name, mediaType, preview }) => ({ name, mediaType, preview }));
270
+ const withUser = [...history, { role: 'user', content: next.message, ...(attPreviews?.length ? { attachments: attPreviews } : {}) }];
271
+ messagesRef.current = withUser;
272
+ setMessages(withUser);
273
+ _doRequest(next.message, next.sendOptions, history);
274
+ }
275
+ }
276
+ // eslint-disable-next-line react-hooks/exhaustive-deps
277
+ }, [endpoint, showStatus, addMessage]);
278
+
279
+ const sendMessage = useCallback(
280
+ (userMessage, sendOptions = {}) => {
281
+ if (abortRef.current) {
282
+ // A request is in flight — hold this message out of chat history until it fires.
283
+ queueRef.current = [...queueRef.current, { message: userMessage, sendOptions }];
284
+ setQueuedMessages(queueRef.current.map((q) => q.message));
285
+ return;
286
+ }
287
+ const history = messagesRef.current.slice();
288
+ // Store display-only attachment info (preview + name) — raw base64 data stays in sendOptions for buildBody.
289
+ const attPreviews = sendOptions.attachments?.map(({ name, mediaType, preview }) => ({ name, mediaType, preview }));
290
+ addMessage({ role: 'user', content: userMessage, ...(attPreviews?.length ? { attachments: attPreviews } : {}) });
291
+ _doRequest(userMessage, sendOptions, history);
201
292
  },
202
- [endpoint, showStatus, addMessage]
293
+ [addMessage, _doRequest]
203
294
  );
204
295
 
205
296
  const clearMessages = useCallback(() => {
@@ -207,5 +298,5 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
207
298
  messagesRef.current = [];
208
299
  }, []);
209
300
 
210
- return { messages, loading, statusText, sendMessage, clearMessages };
301
+ return { messages, loading, statusText, queuedMessages, sendMessage, stopGeneration, clearQueuedMessage, clearMessages };
211
302
  }
package/src/index.d.ts CHANGED
@@ -2,12 +2,36 @@ import * as React from 'react';
2
2
 
3
3
  // ─── Chat UI ────────────────────────────────────────────────────────────────
4
4
 
5
+ /** Full attachment object held in ChatInput state and passed via onSend / sendOptions. */
6
+ export interface ChatAttachment {
7
+ id: string;
8
+ name: string;
9
+ /** MIME type, e.g. "image/jpeg" or "application/pdf". */
10
+ mediaType: string;
11
+ /** Base64-encoded file content (no data-URL prefix). Pass this to the server. */
12
+ data: string;
13
+ /** Data URL for image preview — images only, null for other files. */
14
+ preview: string | null;
15
+ size: number;
16
+ }
17
+
18
+ /** Slim attachment record stored in ChatMessage (no raw base64 — display only). */
19
+ export interface ChatMessageAttachment {
20
+ name: string;
21
+ mediaType: string;
22
+ preview: string | null;
23
+ }
24
+
5
25
  export interface ChatMessage {
6
26
  role: 'user' | 'assistant';
7
27
  content: string;
8
28
  truncated?: boolean;
9
29
  /** When true, UI may show streaming placeholders (orchid-ai ChatWindow). */
10
30
  isStreaming?: boolean;
31
+ /** When true, generation was stopped by the user before the response completed. */
32
+ stopped?: boolean;
33
+ /** Attachments shown in the user bubble (display-only — no raw base64 data). */
34
+ attachments?: ChatMessageAttachment[];
11
35
  /** Collapsible interim trace (statuses, text, and query steps). Persisted for Hermes-style chats. */
12
36
  processTrace?: {
13
37
  items: Array<OrchidAiProcessTraceItem>;
@@ -27,6 +51,12 @@ export interface ChatWindowProps {
27
51
  messages: ChatMessage[];
28
52
  loading?: boolean;
29
53
  statusText?: string;
54
+ /** Messages currently waiting in the queue (held out of chat history until each fires). */
55
+ queuedMessages?: string[];
56
+ /** Called with the index of the queued item to remove, or omit index to clear all. */
57
+ onCancelQueue?: (index: number) => void;
58
+ /** Called when the user clicks "Send now" on the first queued item (stops current + auto-fires). */
59
+ onStop?: () => void;
30
60
  onSuggestionClick?: (text: string) => void;
31
61
  aiEnabled?: boolean;
32
62
  organisationName?: string;
@@ -43,7 +73,11 @@ export interface ChatWindowProps {
43
73
  }
44
74
 
45
75
  export interface ChatInputProps {
46
- onSend: (text: string) => void;
76
+ onSend: (text: string, attachments?: ChatAttachment[]) => void;
77
+ /** Called when the user clicks the Stop button during generation. */
78
+ onStop?: () => void;
79
+ /** When true, shows the Stop button instead of Send. Does not disable the textarea. */
80
+ loading?: boolean;
47
81
  disabled?: boolean;
48
82
  disabledReason?: string;
49
83
  onDisabledClick?: () => void;
@@ -63,6 +97,7 @@ export interface MessageProps {
63
97
  showProcessTracePanel?: boolean;
64
98
  queryContext?: Record<string, unknown>;
65
99
  showQuerySummary?: boolean;
100
+ attachments?: ChatMessageAttachment[];
66
101
  }
67
102
 
68
103
  export const ChatWindow: React.FC<ChatWindowProps>;
@@ -75,6 +110,8 @@ export const Message: React.FC<MessageProps>;
75
110
  export interface OrchidAiChatSendOptions {
76
111
  allowWebResearch?: boolean;
77
112
  urlsToCheck?: string[];
113
+ /** Files/images to include in this message. buildBody receives these via sendOptions. */
114
+ attachments?: ChatAttachment[];
78
115
  }
79
116
 
80
117
  export interface OrchidAiChatOptions {
@@ -105,7 +142,13 @@ export interface OrchidAiChatState {
105
142
  loading: boolean;
106
143
  /** Live status text emitted by the server (e.g. "Thinking", "Compiling response") */
107
144
  statusText: string;
108
- sendMessage: (userMessage: string, sendOptions?: OrchidAiChatSendOptions) => Promise<void>;
145
+ /** Messages waiting in the send queue (held out of chat history until each fires). */
146
+ queuedMessages: string[];
147
+ sendMessage: (userMessage: string, sendOptions?: OrchidAiChatSendOptions) => void;
148
+ /** Abort the current in-flight request. The next queued message auto-fires after abort. */
149
+ stopGeneration: () => void;
150
+ /** Remove a specific queued item by index, or clear the entire queue when called with no argument. */
151
+ clearQueuedMessage: (index?: number) => void;
109
152
  clearMessages: () => void;
110
153
  }
111
154