@octavus/docs 0.0.8 → 1.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.
Files changed (173) hide show
  1. package/README.md +127 -0
  2. package/content/01-getting-started/01-introduction.md +3 -3
  3. package/content/01-getting-started/02-quickstart.md +40 -17
  4. package/content/02-server-sdk/01-overview.md +54 -11
  5. package/content/02-server-sdk/02-sessions.md +166 -15
  6. package/content/02-server-sdk/03-tools.md +21 -21
  7. package/content/02-server-sdk/04-streaming.md +50 -20
  8. package/content/02-server-sdk/05-cli.md +247 -0
  9. package/content/03-client-sdk/01-overview.md +65 -35
  10. package/content/03-client-sdk/02-messages.md +116 -8
  11. package/content/03-client-sdk/03-streaming.md +36 -17
  12. package/content/03-client-sdk/04-execution-blocks.md +8 -12
  13. package/content/03-client-sdk/05-socket-transport.md +161 -45
  14. package/content/03-client-sdk/06-http-transport.md +48 -24
  15. package/content/03-client-sdk/07-structured-output.md +412 -0
  16. package/content/03-client-sdk/08-file-uploads.md +473 -0
  17. package/content/03-client-sdk/09-error-handling.md +274 -0
  18. package/content/04-protocol/01-overview.md +25 -14
  19. package/content/04-protocol/02-input-resources.md +35 -35
  20. package/content/04-protocol/03-triggers.md +9 -11
  21. package/content/04-protocol/04-tools.md +72 -29
  22. package/content/04-protocol/05-skills.md +304 -0
  23. package/content/04-protocol/06-handlers.md +304 -0
  24. package/content/04-protocol/07-agent-config.md +334 -0
  25. package/content/04-protocol/08-provider-options.md +294 -0
  26. package/content/04-protocol/09-skills-advanced.md +439 -0
  27. package/content/04-protocol/10-types.md +719 -0
  28. package/content/05-api-reference/01-overview.md +20 -21
  29. package/content/05-api-reference/02-sessions.md +192 -37
  30. package/content/05-api-reference/03-agents.md +25 -37
  31. package/content/06-examples/01-overview.md +6 -4
  32. package/content/06-examples/02-nextjs-chat.md +28 -18
  33. package/content/06-examples/03-socket-chat.md +53 -30
  34. package/content/06-examples/_meta.md +0 -1
  35. package/dist/chunk-WJ2W3DUC.js +663 -0
  36. package/dist/chunk-WJ2W3DUC.js.map +1 -0
  37. package/dist/content.js +1 -1
  38. package/dist/docs.json +106 -34
  39. package/dist/index.js +1 -1
  40. package/dist/search-index.json +1 -1
  41. package/dist/search.js +1 -1
  42. package/dist/search.js.map +1 -1
  43. package/dist/sections.json +106 -34
  44. package/package.json +12 -2
  45. package/content/04-protocol/05-handlers.md +0 -251
  46. package/content/04-protocol/06-agent-config.md +0 -209
  47. package/dist/chunk-232K4EME.js +0 -439
  48. package/dist/chunk-232K4EME.js.map +0 -1
  49. package/dist/chunk-2JDZLMS3.js +0 -439
  50. package/dist/chunk-2JDZLMS3.js.map +0 -1
  51. package/dist/chunk-2YMRODFE.js +0 -421
  52. package/dist/chunk-2YMRODFE.js.map +0 -1
  53. package/dist/chunk-2ZBPX5QB.js +0 -421
  54. package/dist/chunk-2ZBPX5QB.js.map +0 -1
  55. package/dist/chunk-3PIIST4D.js +0 -421
  56. package/dist/chunk-3PIIST4D.js.map +0 -1
  57. package/dist/chunk-42JETGDO.js +0 -421
  58. package/dist/chunk-42JETGDO.js.map +0 -1
  59. package/dist/chunk-4WWUKU4V.js +0 -421
  60. package/dist/chunk-4WWUKU4V.js.map +0 -1
  61. package/dist/chunk-5M7DS4DF.js +0 -519
  62. package/dist/chunk-5M7DS4DF.js.map +0 -1
  63. package/dist/chunk-6JQ3OMGF.js +0 -421
  64. package/dist/chunk-6JQ3OMGF.js.map +0 -1
  65. package/dist/chunk-7AOWCJHW.js +0 -421
  66. package/dist/chunk-7AOWCJHW.js.map +0 -1
  67. package/dist/chunk-7AS4ST73.js +0 -421
  68. package/dist/chunk-7AS4ST73.js.map +0 -1
  69. package/dist/chunk-7F5WOCIL.js +0 -421
  70. package/dist/chunk-7F5WOCIL.js.map +0 -1
  71. package/dist/chunk-7FPUAWSX.js +0 -421
  72. package/dist/chunk-7FPUAWSX.js.map +0 -1
  73. package/dist/chunk-APASMJBS.js +0 -421
  74. package/dist/chunk-APASMJBS.js.map +0 -1
  75. package/dist/chunk-BCEV3WV2.js +0 -421
  76. package/dist/chunk-BCEV3WV2.js.map +0 -1
  77. package/dist/chunk-CHGY4G27.js +0 -421
  78. package/dist/chunk-CHGY4G27.js.map +0 -1
  79. package/dist/chunk-CI7JDWKU.js +0 -421
  80. package/dist/chunk-CI7JDWKU.js.map +0 -1
  81. package/dist/chunk-CVFWWRL7.js +0 -421
  82. package/dist/chunk-CVFWWRL7.js.map +0 -1
  83. package/dist/chunk-EPDM2NIJ.js +0 -421
  84. package/dist/chunk-EPDM2NIJ.js.map +0 -1
  85. package/dist/chunk-ESGSYVGK.js +0 -421
  86. package/dist/chunk-ESGSYVGK.js.map +0 -1
  87. package/dist/chunk-GDCTM2SV.js +0 -421
  88. package/dist/chunk-GDCTM2SV.js.map +0 -1
  89. package/dist/chunk-GJ6FMIPD.js +0 -421
  90. package/dist/chunk-GJ6FMIPD.js.map +0 -1
  91. package/dist/chunk-H6JGSSAJ.js +0 -519
  92. package/dist/chunk-H6JGSSAJ.js.map +0 -1
  93. package/dist/chunk-IKQHGGUZ.js +0 -421
  94. package/dist/chunk-IKQHGGUZ.js.map +0 -1
  95. package/dist/chunk-IUKE3XDN.js +0 -421
  96. package/dist/chunk-IUKE3XDN.js.map +0 -1
  97. package/dist/chunk-J26MLMLN.js +0 -421
  98. package/dist/chunk-J26MLMLN.js.map +0 -1
  99. package/dist/chunk-J7BMB3ZW.js +0 -421
  100. package/dist/chunk-J7BMB3ZW.js.map +0 -1
  101. package/dist/chunk-JCBQRD5N.js +0 -421
  102. package/dist/chunk-JCBQRD5N.js.map +0 -1
  103. package/dist/chunk-JOB6YWEF.js +0 -421
  104. package/dist/chunk-JOB6YWEF.js.map +0 -1
  105. package/dist/chunk-JZRABTHU.js +0 -519
  106. package/dist/chunk-JZRABTHU.js.map +0 -1
  107. package/dist/chunk-K3GFQUMC.js +0 -421
  108. package/dist/chunk-K3GFQUMC.js.map +0 -1
  109. package/dist/chunk-LWYMRXBF.js +0 -421
  110. package/dist/chunk-LWYMRXBF.js.map +0 -1
  111. package/dist/chunk-M2R2NDPR.js +0 -421
  112. package/dist/chunk-M2R2NDPR.js.map +0 -1
  113. package/dist/chunk-MA3P7WCA.js +0 -421
  114. package/dist/chunk-MA3P7WCA.js.map +0 -1
  115. package/dist/chunk-MDMRCS4W.mjs +0 -421
  116. package/dist/chunk-MDMRCS4W.mjs.map +0 -1
  117. package/dist/chunk-MJXTA2R6.js +0 -421
  118. package/dist/chunk-MJXTA2R6.js.map +0 -1
  119. package/dist/chunk-NFVJQNDP.js +0 -421
  120. package/dist/chunk-NFVJQNDP.js.map +0 -1
  121. package/dist/chunk-O5TLYMQP.js +0 -421
  122. package/dist/chunk-O5TLYMQP.js.map +0 -1
  123. package/dist/chunk-OECAPVSX.js +0 -439
  124. package/dist/chunk-OECAPVSX.js.map +0 -1
  125. package/dist/chunk-OL5QDJ42.js +0 -483
  126. package/dist/chunk-OL5QDJ42.js.map +0 -1
  127. package/dist/chunk-PMOVVTHO.js +0 -519
  128. package/dist/chunk-PMOVVTHO.js.map +0 -1
  129. package/dist/chunk-QCHDPR2D.js +0 -421
  130. package/dist/chunk-QCHDPR2D.js.map +0 -1
  131. package/dist/chunk-R5MTVABN.js +0 -439
  132. package/dist/chunk-R5MTVABN.js.map +0 -1
  133. package/dist/chunk-RJ4H4YVA.js +0 -519
  134. package/dist/chunk-RJ4H4YVA.js.map +0 -1
  135. package/dist/chunk-S5U4IWCR.js +0 -439
  136. package/dist/chunk-S5U4IWCR.js.map +0 -1
  137. package/dist/chunk-SCKIOGKI.js +0 -421
  138. package/dist/chunk-SCKIOGKI.js.map +0 -1
  139. package/dist/chunk-TGJSIJYP.js +0 -421
  140. package/dist/chunk-TGJSIJYP.js.map +0 -1
  141. package/dist/chunk-TQZRBMU7.js +0 -421
  142. package/dist/chunk-TQZRBMU7.js.map +0 -1
  143. package/dist/chunk-TRL4RSEO.js +0 -421
  144. package/dist/chunk-TRL4RSEO.js.map +0 -1
  145. package/dist/chunk-TWUMRHQ7.js +0 -421
  146. package/dist/chunk-TWUMRHQ7.js.map +0 -1
  147. package/dist/chunk-UCJE36LL.js +0 -519
  148. package/dist/chunk-UCJE36LL.js.map +0 -1
  149. package/dist/chunk-VCASA6KL.js +0 -421
  150. package/dist/chunk-VCASA6KL.js.map +0 -1
  151. package/dist/chunk-VWPQ6ORV.js +0 -421
  152. package/dist/chunk-VWPQ6ORV.js.map +0 -1
  153. package/dist/chunk-WPXKIHLT.js +0 -421
  154. package/dist/chunk-WPXKIHLT.js.map +0 -1
  155. package/dist/chunk-WUNFFJ32.js +0 -421
  156. package/dist/chunk-WUNFFJ32.js.map +0 -1
  157. package/dist/chunk-WW7TRC7S.js +0 -519
  158. package/dist/chunk-WW7TRC7S.js.map +0 -1
  159. package/dist/chunk-XVSMRXBJ.js +0 -421
  160. package/dist/chunk-XVSMRXBJ.js.map +0 -1
  161. package/dist/chunk-YPPXXV3I.js +0 -421
  162. package/dist/chunk-YPPXXV3I.js.map +0 -1
  163. package/dist/chunk-ZKZVV4OQ.js +0 -421
  164. package/dist/chunk-ZKZVV4OQ.js.map +0 -1
  165. package/dist/chunk-ZOFEX73I.js +0 -421
  166. package/dist/chunk-ZOFEX73I.js.map +0 -1
  167. package/dist/content.mjs +0 -17
  168. package/dist/content.mjs.map +0 -1
  169. package/dist/index.mjs +0 -11
  170. package/dist/index.mjs.map +0 -1
  171. package/dist/search.mjs +0 -30
  172. package/dist/search.mjs.map +0 -1
  173. package/dist/types-BltYGlWI.d.ts +0 -36
@@ -0,0 +1,473 @@
1
+ ---
2
+ title: File Uploads
3
+ description: Uploading images and files for vision models and document processing.
4
+ ---
5
+
6
+ # File Uploads
7
+
8
+ The Client SDK supports uploading images and documents that can be sent with messages. This enables vision model capabilities (analyzing images) and document processing.
9
+
10
+ ## Overview
11
+
12
+ File uploads follow a two-step flow:
13
+
14
+ 1. **Request upload URLs** from the platform via your backend
15
+ 2. **Upload files directly to S3** using presigned URLs
16
+ 3. **Send file references** with your message
17
+
18
+ This architecture keeps your API key secure on the server while enabling fast, direct uploads.
19
+
20
+ ## Setup
21
+
22
+ ### Backend: Upload URLs Endpoint
23
+
24
+ Create an endpoint that proxies upload URL requests to the Octavus platform:
25
+
26
+ ```typescript
27
+ // app/api/upload-urls/route.ts (Next.js)
28
+ import { NextResponse } from 'next/server';
29
+ import { octavus } from '@/lib/octavus';
30
+
31
+ export async function POST(request: Request) {
32
+ const { sessionId, files } = await request.json();
33
+
34
+ // Get presigned URLs from Octavus
35
+ const result = await octavus.files.getUploadUrls(sessionId, files);
36
+
37
+ return NextResponse.json(result);
38
+ }
39
+ ```
40
+
41
+ ### Client: Configure File Uploads
42
+
43
+ Pass `requestUploadUrls` to the chat hook:
44
+
45
+ ```tsx
46
+ import { useMemo, useCallback } from 'react';
47
+ import { useOctavusChat, createHttpTransport } from '@octavus/react';
48
+
49
+ function Chat({ sessionId }: { sessionId: string }) {
50
+ const transport = useMemo(
51
+ () =>
52
+ createHttpTransport({
53
+ triggerRequest: (triggerName, input) =>
54
+ fetch('/api/trigger', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ sessionId, triggerName, input }),
58
+ }),
59
+ }),
60
+ [sessionId],
61
+ );
62
+
63
+ // Request upload URLs from your backend
64
+ const requestUploadUrls = useCallback(
65
+ async (files: { filename: string; mediaType: string; size: number }[]) => {
66
+ const response = await fetch('/api/upload-urls', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ sessionId, files }),
70
+ });
71
+ return response.json();
72
+ },
73
+ [sessionId],
74
+ );
75
+
76
+ const { messages, status, send, uploadFiles } = useOctavusChat({
77
+ transport,
78
+ requestUploadUrls,
79
+ });
80
+
81
+ // ...
82
+ }
83
+ ```
84
+
85
+ ## Uploading Files
86
+
87
+ ### Method 1: Upload Before Sending
88
+
89
+ For the best UX (showing upload progress), upload files first, then send:
90
+
91
+ ```tsx
92
+ import { useState, useRef } from 'react';
93
+ import type { FileReference } from '@octavus/react';
94
+
95
+ function ChatInput({ sessionId }: { sessionId: string }) {
96
+ const [pendingFiles, setPendingFiles] = useState<FileReference[]>([]);
97
+ const [uploading, setUploading] = useState(false);
98
+ const fileInputRef = useRef<HTMLInputElement>(null);
99
+
100
+ const { send, uploadFiles } = useOctavusChat({
101
+ transport,
102
+ requestUploadUrls,
103
+ });
104
+
105
+ async function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
106
+ const files = event.target.files;
107
+ if (!files?.length) return;
108
+
109
+ setUploading(true);
110
+ try {
111
+ // Upload files with progress tracking
112
+ const fileRefs = await uploadFiles(files, (fileIndex, progress) => {
113
+ console.log(`File ${fileIndex}: ${progress}%`);
114
+ });
115
+ setPendingFiles((prev) => [...prev, ...fileRefs]);
116
+ } finally {
117
+ setUploading(false);
118
+ }
119
+ }
120
+
121
+ async function handleSend(message: string) {
122
+ await send(
123
+ 'user-message',
124
+ {
125
+ USER_MESSAGE: message,
126
+ FILES: pendingFiles.length > 0 ? pendingFiles : undefined,
127
+ },
128
+ {
129
+ userMessage: {
130
+ content: message,
131
+ files: pendingFiles.length > 0 ? pendingFiles : undefined,
132
+ },
133
+ },
134
+ );
135
+ setPendingFiles([]);
136
+ }
137
+
138
+ return (
139
+ <div>
140
+ {/* File preview */}
141
+ {pendingFiles.map((file) => (
142
+ <img key={file.id} src={file.url} alt={file.filename} className="h-16" />
143
+ ))}
144
+
145
+ <input
146
+ ref={fileInputRef}
147
+ type="file"
148
+ accept="image/*,.pdf"
149
+ multiple
150
+ onChange={handleFileSelect}
151
+ className="hidden"
152
+ />
153
+
154
+ <button onClick={() => fileInputRef.current?.click()} disabled={uploading}>
155
+ {uploading ? 'Uploading...' : 'Attach'}
156
+ </button>
157
+ </div>
158
+ );
159
+ }
160
+ ```
161
+
162
+ ### Method 2: Upload on Send (Automatic)
163
+
164
+ For simpler implementations, pass `File` objects directly:
165
+
166
+ ```tsx
167
+ async function handleSend(message: string, files?: File[]) {
168
+ await send(
169
+ 'user-message',
170
+ { USER_MESSAGE: message, FILES: files },
171
+ { userMessage: { content: message, files } },
172
+ );
173
+ }
174
+ ```
175
+
176
+ The SDK automatically uploads the files before sending. Note: This doesn't provide upload progress.
177
+
178
+ ## FileReference Type
179
+
180
+ File references contain metadata and URLs:
181
+
182
+ ```typescript
183
+ interface FileReference {
184
+ /** Unique file ID (platform-generated) */
185
+ id: string;
186
+ /** IANA media type (e.g., 'image/png', 'application/pdf') */
187
+ mediaType: string;
188
+ /** Presigned download URL (S3) */
189
+ url: string;
190
+ /** Original filename */
191
+ filename?: string;
192
+ /** File size in bytes */
193
+ size?: number;
194
+ }
195
+ ```
196
+
197
+ ## Protocol Integration
198
+
199
+ To accept files in your agent protocol, use the `file[]` type:
200
+
201
+ ```yaml
202
+ triggers:
203
+ user-message:
204
+ input:
205
+ USER_MESSAGE:
206
+ type: string
207
+ description: The user's message
208
+ FILES:
209
+ type: file[]
210
+ optional: true
211
+ description: User-attached images for vision analysis
212
+
213
+ handlers:
214
+ user-message:
215
+ Add user message:
216
+ block: add-message
217
+ role: user
218
+ prompt: user-message
219
+ input:
220
+ - USER_MESSAGE
221
+ files:
222
+ - FILES # Attach files to the message
223
+ display: hidden
224
+
225
+ Respond to user:
226
+ block: next-message
227
+ ```
228
+
229
+ The `file` type is a built-in type representing uploaded files. Use `file[]` for arrays of files.
230
+
231
+ ## Supported File Types
232
+
233
+ | Type | Media Types |
234
+ | --------- | -------------------------------------------------------------------- |
235
+ | Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
236
+ | Documents | `application/pdf`, `text/plain`, `text/markdown`, `application/json` |
237
+
238
+ ## File Limits
239
+
240
+ | Limit | Value |
241
+ | --------------------- | ---------- |
242
+ | Max file size | 10 MB |
243
+ | Max total per request | 50 MB |
244
+ | Max files per request | 20 |
245
+ | Upload URL expiry | 15 minutes |
246
+ | Download URL expiry | 24 hours |
247
+
248
+ ## Rendering User Files
249
+
250
+ User-uploaded files appear as `UIFilePart` in user messages:
251
+
252
+ ```tsx
253
+ function UserMessage({ message }: { message: UIMessage }) {
254
+ return (
255
+ <div>
256
+ {message.parts.map((part, i) => {
257
+ if (part.type === 'file') {
258
+ if (part.mediaType.startsWith('image/')) {
259
+ return (
260
+ <img
261
+ key={i}
262
+ src={part.url}
263
+ alt={part.filename || 'Uploaded image'}
264
+ className="max-h-48 rounded-lg"
265
+ />
266
+ );
267
+ }
268
+ return (
269
+ <a key={i} href={part.url} className="text-blue-500">
270
+ 📄 {part.filename}
271
+ </a>
272
+ );
273
+ }
274
+ if (part.type === 'text') {
275
+ return <p key={i}>{part.text}</p>;
276
+ }
277
+ return null;
278
+ })}
279
+ </div>
280
+ );
281
+ }
282
+ ```
283
+
284
+ ## Server SDK: Files API
285
+
286
+ The Server SDK provides direct access to the Files API:
287
+
288
+ ```typescript
289
+ import { OctavusClient } from '@octavus/server-sdk';
290
+
291
+ const client = new OctavusClient({
292
+ baseUrl: 'https://octavus.ai',
293
+ apiKey: 'your-api-key',
294
+ });
295
+
296
+ // Get presigned upload URLs
297
+ const { files } = await client.files.getUploadUrls(sessionId, [
298
+ { filename: 'photo.jpg', mediaType: 'image/jpeg', size: 102400 },
299
+ { filename: 'doc.pdf', mediaType: 'application/pdf', size: 204800 },
300
+ ]);
301
+
302
+ // files[0].id - Use in FileReference
303
+ // files[0].uploadUrl - PUT to this URL to upload
304
+ // files[0].downloadUrl - Use as FileReference.url
305
+ ```
306
+
307
+ ## Complete Example
308
+
309
+ Here's a full chat input component with file upload:
310
+
311
+ ```tsx
312
+ 'use client';
313
+
314
+ import { useState, useRef, useMemo, useCallback } from 'react';
315
+ import { useOctavusChat, createHttpTransport, type FileReference } from '@octavus/react';
316
+
317
+ interface PendingFile {
318
+ file: File;
319
+ id: string;
320
+ status: 'uploading' | 'done' | 'error';
321
+ progress: number;
322
+ fileRef?: FileReference;
323
+ error?: string;
324
+ }
325
+
326
+ export function Chat({ sessionId }: { sessionId: string }) {
327
+ const [input, setInput] = useState('');
328
+ const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
329
+ const fileInputRef = useRef<HTMLInputElement>(null);
330
+ const fileIdCounter = useRef(0);
331
+
332
+ const transport = useMemo(
333
+ () =>
334
+ createHttpTransport({
335
+ triggerRequest: (triggerName, input) =>
336
+ fetch('/api/trigger', {
337
+ method: 'POST',
338
+ headers: { 'Content-Type': 'application/json' },
339
+ body: JSON.stringify({ sessionId, triggerName, input }),
340
+ }),
341
+ }),
342
+ [sessionId],
343
+ );
344
+
345
+ const requestUploadUrls = useCallback(
346
+ async (files: { filename: string; mediaType: string; size: number }[]) => {
347
+ const res = await fetch('/api/upload-urls', {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ sessionId, files }),
351
+ });
352
+ return res.json();
353
+ },
354
+ [sessionId],
355
+ );
356
+
357
+ const { messages, status, send, uploadFiles } = useOctavusChat({
358
+ transport,
359
+ requestUploadUrls,
360
+ });
361
+
362
+ const isUploading = pendingFiles.some((f) => f.status === 'uploading');
363
+ const hasErrors = pendingFiles.some((f) => f.status === 'error');
364
+ const allReady = pendingFiles.every((f) => f.status === 'done');
365
+
366
+ async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
367
+ const files = Array.from(e.target.files ?? []);
368
+ if (!files.length) return;
369
+ e.target.value = '';
370
+
371
+ const newPending: PendingFile[] = files.map((file) => ({
372
+ file,
373
+ id: `pending-${++fileIdCounter.current}`,
374
+ status: 'uploading',
375
+ progress: 0,
376
+ }));
377
+
378
+ setPendingFiles((prev) => [...prev, ...newPending]);
379
+
380
+ for (const pending of newPending) {
381
+ try {
382
+ const [fileRef] = await uploadFiles([pending.file], (_, progress) => {
383
+ setPendingFiles((prev) =>
384
+ prev.map((f) => (f.id === pending.id ? { ...f, progress } : f)),
385
+ );
386
+ });
387
+ setPendingFiles((prev) =>
388
+ prev.map((f) => (f.id === pending.id ? { ...f, status: 'done', fileRef } : f)),
389
+ );
390
+ } catch (err) {
391
+ setPendingFiles((prev) =>
392
+ prev.map((f) =>
393
+ f.id === pending.id ? { ...f, status: 'error', error: String(err) } : f,
394
+ ),
395
+ );
396
+ }
397
+ }
398
+ }
399
+
400
+ async function handleSubmit() {
401
+ if ((!input.trim() && !pendingFiles.length) || !allReady) return;
402
+
403
+ const fileRefs = pendingFiles.filter((f) => f.fileRef).map((f) => f.fileRef!);
404
+
405
+ await send(
406
+ 'user-message',
407
+ {
408
+ USER_MESSAGE: input,
409
+ FILES: fileRefs.length > 0 ? fileRefs : undefined,
410
+ },
411
+ {
412
+ userMessage: {
413
+ content: input,
414
+ files: fileRefs.length > 0 ? fileRefs : undefined,
415
+ },
416
+ },
417
+ );
418
+
419
+ setInput('');
420
+ setPendingFiles([]);
421
+ }
422
+
423
+ return (
424
+ <div>
425
+ {/* Messages */}
426
+ {messages.map((msg) => (
427
+ <div key={msg.id}>{/* ... render message */}</div>
428
+ ))}
429
+
430
+ {/* Pending files */}
431
+ {pendingFiles.length > 0 && (
432
+ <div className="flex gap-2">
433
+ {pendingFiles.map((f) => (
434
+ <div key={f.id} className="relative">
435
+ <img
436
+ src={URL.createObjectURL(f.file)}
437
+ alt={f.file.name}
438
+ className="h-16 w-16 object-cover rounded"
439
+ />
440
+ {f.status === 'uploading' && (
441
+ <div className="absolute inset-0 flex items-center justify-center bg-black/50">
442
+ <span className="text-white text-xs">{f.progress}%</span>
443
+ </div>
444
+ )}
445
+ <button
446
+ onClick={() => setPendingFiles((prev) => prev.filter((p) => p.id !== f.id))}
447
+ className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5"
448
+ >
449
+ ×
450
+ </button>
451
+ </div>
452
+ ))}
453
+ </div>
454
+ )}
455
+
456
+ {/* Input */}
457
+ <div className="flex gap-2">
458
+ <input type="file" ref={fileInputRef} onChange={handleFileSelect} hidden />
459
+ <button onClick={() => fileInputRef.current?.click()}>📎</button>
460
+ <input
461
+ value={input}
462
+ onChange={(e) => setInput(e.target.value)}
463
+ placeholder="Type a message..."
464
+ className="flex-1"
465
+ />
466
+ <button onClick={handleSubmit} disabled={isUploading || hasErrors}>
467
+ {isUploading ? 'Uploading...' : 'Send'}
468
+ </button>
469
+ </div>
470
+ </div>
471
+ );
472
+ }
473
+ ```