bloby-bot 0.70.12 → 0.71.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 (65) hide show
  1. package/bin/cli.js +234 -48
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
  3. package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -4
  11. package/scripts/install +156 -41
  12. package/scripts/install.ps1 +146 -29
  13. package/scripts/install.sh +156 -41
  14. package/shared/config.ts +37 -2
  15. package/shared/relay.ts +3 -1
  16. package/supervisor/channels/manager.ts +84 -44
  17. package/supervisor/channels/telegram.ts +57 -16
  18. package/supervisor/channels/types.ts +4 -1
  19. package/supervisor/channels/whatsapp.ts +57 -10
  20. package/supervisor/chat/OnboardWizard.tsx +0 -15
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
  22. package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
  23. package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
  24. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
  25. package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
  26. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
  27. package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
  28. package/supervisor/chat/src/hooks/useChat.ts +52 -0
  29. package/supervisor/chat/src/lib/authedFile.ts +24 -12
  30. package/supervisor/file-saver.ts +92 -19
  31. package/supervisor/harnesses/attachment-policy.ts +111 -0
  32. package/supervisor/harnesses/claude.ts +62 -15
  33. package/supervisor/harnesses/codex.ts +69 -43
  34. package/supervisor/harnesses/pi/index.ts +367 -112
  35. package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
  36. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  37. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
  38. package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
  39. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
  40. package/supervisor/harnesses/pi/providers/types.ts +29 -1
  41. package/supervisor/harnesses/pi/session.ts +143 -3
  42. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  43. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  44. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  45. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  46. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  47. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  48. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  49. package/supervisor/index.ts +93 -18
  50. package/supervisor/widget.js +19 -5
  51. package/worker/db.ts +2 -0
  52. package/worker/index.ts +18 -1
  53. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  54. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  55. package/worker/prompts/bloby-system-prompt.txt +1 -1
  56. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  57. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  58. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  59. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  60. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  61. package/workspace/skills/mac/SKILL.md +13 -4
  62. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  63. package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
  64. package/supervisor/public/headphones_spritesheet.webp +0 -0
  65. package/supervisor/public/spritesheet.webp +0 -0
@@ -20,10 +20,14 @@ const POLL_TIMEOUT_S = 25; // long-poll hold time
20
20
  const MAX_MESSAGE_CHARS = 4096; // Telegram hard limit per sendMessage
21
21
  const TYPING_REFRESH_MS = 5_000; // Telegram "typing" expires ~5s
22
22
 
23
- /** Image extracted from an inbound Telegram message. */
24
- export interface TelegramImageAttachment {
23
+ /** Media attachment extracted from an inbound Telegram message.
24
+ * `type: 'image'` → inline vision; `type: 'file'` → a document the agent reads from disk. */
25
+ export interface TelegramMediaAttachment {
26
+ type: 'image' | 'file';
25
27
  mediaType: string;
26
28
  data: string; // base64
29
+ /** Original filename — present for documents, absent for photos. */
30
+ name?: string;
27
31
  }
28
32
 
29
33
  /** Normalized inbound message handed to the ChannelManager. */
@@ -37,7 +41,7 @@ export interface TelegramInbound {
37
41
  text: string;
38
42
  isGroup: boolean;
39
43
  messageId?: number;
40
- images?: TelegramImageAttachment[];
44
+ attachments?: TelegramMediaAttachment[];
41
45
  }
42
46
 
43
47
  export type OnTelegramMessage = (msg: TelegramInbound) => void;
@@ -236,13 +240,27 @@ export class TelegramChannel implements ChannelProvider {
236
240
  : (from.username || undefined);
237
241
 
238
242
  let rawText: string = message.text || message.caption || '';
239
- const images: TelegramImageAttachment[] = [];
243
+ const attachments: TelegramMediaAttachment[] = [];
240
244
 
241
- // Photo: download the largest available size.
245
+ // Photo: download the largest available size. Derive the real mediaType from the CDN
246
+ // file extension (Telegram stores PNG/JPEG/WebP as-is) — default to image/jpeg only when unknown.
242
247
  if (Array.isArray(message.photo) && message.photo.length > 0) {
243
248
  const largest = message.photo[message.photo.length - 1];
244
249
  const img = await this.downloadFile(largest.file_id).catch(() => null);
245
- if (img) images.push({ mediaType: 'image/jpeg', data: img.toString('base64') });
250
+ if (img) attachments.push({ type: 'image', mediaType: mimeFromPath(img.filePath, 'image/jpeg'), data: img.buffer.toString('base64') });
251
+ }
252
+
253
+ // Document: download the binary and forward as a file the agent reads from disk.
254
+ if (message.document?.file_id) {
255
+ const doc = await this.downloadFile(message.document.file_id).catch(() => null);
256
+ if (doc) {
257
+ attachments.push({
258
+ type: 'file',
259
+ mediaType: message.document.mime_type || mimeFromPath(doc.filePath, 'application/octet-stream'),
260
+ data: doc.buffer.toString('base64'),
261
+ name: message.document.file_name || undefined,
262
+ });
263
+ }
246
264
  }
247
265
 
248
266
  // Voice note / audio: download + transcribe.
@@ -252,9 +270,9 @@ export class TelegramChannel implements ChannelProvider {
252
270
  await this.sendMessage(chatId, 'Voice transcription is off — add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable it.');
253
271
  return;
254
272
  }
255
- const buf = await this.downloadFile(voice.file_id).catch(() => null);
256
- if (buf) {
257
- const transcript = await this.transcribe(buf.toString('base64')).catch(() => null);
273
+ const got = await this.downloadFile(voice.file_id).catch(() => null);
274
+ if (got) {
275
+ const transcript = await this.transcribe(got.buffer.toString('base64')).catch(() => null);
258
276
  if (transcript) {
259
277
  rawText = transcript;
260
278
  log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`);
@@ -265,12 +283,23 @@ export class TelegramChannel implements ChannelProvider {
265
283
  }
266
284
  }
267
285
 
268
- if (!rawText && images.length === 0) return;
269
- if (!rawText && images.length > 0) rawText = '(image)';
286
+ // Nothing usable extracted. If the message DID carry media we couldn't handle
287
+ // (sticker, video, location, contact, ), tell the user instead of dropping it silently.
288
+ if (!rawText && attachments.length === 0) {
289
+ const hadUnsupportedMedia = !!(message.sticker || message.video || message.video_note ||
290
+ message.animation || message.location || message.contact || message.poll || message.dice);
291
+ if (hadUnsupportedMedia) {
292
+ await this.sendMessage(chatId, "Sorry, I can't read that type of message yet — try sending text, a photo, or a document.");
293
+ }
294
+ return;
295
+ }
296
+ if (!rawText && attachments.length > 0) {
297
+ rawText = attachments.some((a) => a.type === 'image') ? '(image)' : '(document)';
298
+ }
270
299
 
271
300
  const text = escapeMessageText(rawText);
272
301
 
273
- log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, images=${images.length}): ${text.slice(0, 80)}`);
302
+ log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, media=${attachments.length}): ${text.slice(0, 80)}`);
274
303
 
275
304
  this.onMessage({
276
305
  chatId,
@@ -279,12 +308,13 @@ export class TelegramChannel implements ChannelProvider {
279
308
  text,
280
309
  isGroup,
281
310
  messageId: message.message_id,
282
- images: images.length > 0 ? images : undefined,
311
+ attachments: attachments.length > 0 ? attachments : undefined,
283
312
  });
284
313
  }
285
314
 
286
- /** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). */
287
- private async downloadFile(fileId: string): Promise<Buffer | null> {
315
+ /** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN).
316
+ * Also returns the CDN file_path so callers can derive an extension/mediaType. */
317
+ private async downloadFile(fileId: string): Promise<{ buffer: Buffer; filePath: string } | null> {
288
318
  const file = await this.call('getFile', { file_id: fileId });
289
319
  const filePath = file?.file_path;
290
320
  if (!filePath) return null;
@@ -292,7 +322,7 @@ export class TelegramChannel implements ChannelProvider {
292
322
  if (!r.ok) throw new Error(`file download HTTP ${r.status}`);
293
323
  const buf = Buffer.from(await r.arrayBuffer());
294
324
  log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`);
295
- return buf;
325
+ return { buffer: buf, filePath };
296
326
  }
297
327
 
298
328
  /** Call a Bot API method, returning `result` or throwing on `ok:false`. */
@@ -336,6 +366,17 @@ function sleep(ms: number): Promise<void> {
336
366
  return new Promise((resolve) => setTimeout(resolve, ms));
337
367
  }
338
368
 
369
+ /** Best-effort mime type from a file path's extension; returns `fallback` when unknown.
370
+ * Used for Telegram photos/documents where the API doesn't always supply a mime_type. */
371
+ function mimeFromPath(filePath: string | undefined, fallback: string): string {
372
+ const ext = (filePath?.split('.').pop() || '').toLowerCase();
373
+ const map: Record<string, string> = {
374
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
375
+ pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain', csv: 'text/csv', json: 'application/json',
376
+ };
377
+ return map[ext] || fallback;
378
+ }
379
+
339
380
  /** Split a long message into <=limit-char chunks, preferring newline boundaries. */
340
381
  function splitMessage(text: string, limit: number): string[] {
341
382
  if (text.length <= limit) return [text];
@@ -23,9 +23,12 @@ export interface ChannelConfig {
23
23
  }
24
24
 
25
25
  export interface InboundMessageAttachment {
26
- type: 'image';
26
+ /** 'image' → inline vision block; 'file' → any document the agent reads from disk. */
27
+ type: 'image' | 'file';
27
28
  mediaType: string;
28
29
  data: string; // base64
30
+ /** Original filename when the channel provides one (WhatsApp/Telegram documents). */
31
+ name?: string;
29
32
  }
30
33
 
31
34
  export interface InboundMessage {
@@ -24,16 +24,21 @@ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
24
24
 
25
25
  const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
26
26
 
27
- /** Image attachment extracted from a WhatsApp message */
28
- export interface WhatsAppImageAttachment {
27
+ /** Media attachment extracted from a WhatsApp message.
28
+ * `type: 'image'` → inline vision; `type: 'file'` → a document the agent reads from disk. */
29
+ export interface WhatsAppMediaAttachment {
30
+ type: 'image' | 'file';
29
31
  mediaType: string;
30
32
  data: string; // base64
33
+ /** Original filename — present for documents (WhatsApp supplies it), absent for images. */
34
+ name?: string;
31
35
  }
32
36
 
33
37
  /** Callback when a new message arrives.
34
38
  * - sender: who sent it (phone JID, translated from LID where possible)
35
39
  * - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this
36
40
  * - isGroup: true when the chat is a WhatsApp group (@g.us)
41
+ * - media: image and/or document attachments extracted from the message
37
42
  * - inboundKey: original Baileys message key — used to react/quote/ack the user's message
38
43
  */
39
44
  export type OnWhatsAppMessage = (
@@ -44,7 +49,7 @@ export type OnWhatsAppMessage = (
44
49
  isSelfChat: boolean,
45
50
  chatJid: string,
46
51
  isGroup: boolean,
47
- images?: WhatsAppImageAttachment[],
52
+ media?: WhatsAppMediaAttachment[],
48
53
  inboundKey?: WAMessageKey,
49
54
  ) => void;
50
55
 
@@ -576,7 +581,7 @@ export class WhatsAppChannel implements ChannelProvider {
576
581
 
577
582
  // Extract text — or transcribe audio if it's a voice note
578
583
  let rawText = this.extractText(msg.message);
579
- const images: WhatsAppImageAttachment[] = [];
584
+ const media: WhatsAppMediaAttachment[] = [];
580
585
 
581
586
  // Download image if present
582
587
  if (this.isImageMessage(msg.message)) {
@@ -584,13 +589,32 @@ export class WhatsAppChannel implements ChannelProvider {
584
589
  const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
585
590
  const mimeType = this.getImageMimeType(msg.message) || 'image/jpeg';
586
591
  const base64 = buffer.toString('base64');
587
- images.push({ mediaType: mimeType, data: base64 });
592
+ media.push({ type: 'image', mediaType: mimeType, data: base64 });
588
593
  log.info(`[whatsapp] Downloaded image (${Math.round(buffer.length / 1024)}KB, ${mimeType})`);
589
594
  } catch (err: any) {
590
595
  log.warn(`[whatsapp] Image download failed: ${err.message}`);
591
596
  }
592
597
  }
593
598
 
599
+ // Download document if present (PDF, docx, zip, etc.) — the binary is downloaded
600
+ // here (the caption, if any, is already covered by extractText above).
601
+ const docInfo = this.getDocumentInfo(msg.message);
602
+ if (docInfo) {
603
+ try {
604
+ const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
605
+ const base64 = buffer.toString('base64');
606
+ media.push({
607
+ type: 'file',
608
+ mediaType: docInfo.mimetype || 'application/octet-stream',
609
+ data: base64,
610
+ name: docInfo.fileName,
611
+ });
612
+ log.info(`[whatsapp] Downloaded document (${Math.round(buffer.length / 1024)}KB, ${docInfo.mimetype || 'unknown'}, ${docInfo.fileName || 'unnamed'})`);
613
+ } catch (err: any) {
614
+ log.warn(`[whatsapp] Document download failed: ${err.message}`);
615
+ }
616
+ }
617
+
594
618
  if (!rawText && this.isAudioMessage(msg.message)) {
595
619
  // Voice note / audio — download and transcribe
596
620
  if (!this.transcribe) {
@@ -616,11 +640,11 @@ export class WhatsAppChannel implements ChannelProvider {
616
640
  }
617
641
  }
618
642
 
619
- // Skip if no text AND no images; otherwise default text for image-only
643
+ // Skip if no text AND no media; otherwise default text for media-only
620
644
  // messages. Collapsing both branches also narrows `rawText` to `string`.
621
645
  if (!rawText) {
622
- if (images.length === 0) continue;
623
- rawText = '(image)';
646
+ if (media.length === 0) continue;
647
+ rawText = media.some((m) => m.type === 'image') ? '(image)' : '(document)';
624
648
  }
625
649
 
626
650
  // Escape special characters to prevent prompt injection via message content
@@ -663,7 +687,7 @@ export class WhatsAppChannel implements ChannelProvider {
663
687
  const ownsParticipant = !participant || participantResolved === this.ownPhoneJid;
664
688
  const isSelfChat = !isGroup && ownsChat && ownsParticipant;
665
689
 
666
- log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
690
+ log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, media=${media.length}): ${text.slice(0, 80)}`);
667
691
 
668
692
  this.onMessage(
669
693
  sender,
@@ -673,7 +697,7 @@ export class WhatsAppChannel implements ChannelProvider {
673
697
  isSelfChat,
674
698
  chatJid,
675
699
  isGroup,
676
- images.length > 0 ? images : undefined,
700
+ media.length > 0 ? media : undefined,
677
701
  msg.key,
678
702
  );
679
703
  }
@@ -692,6 +716,10 @@ export class WhatsAppChannel implements ChannelProvider {
692
716
  if (message.imageMessage?.caption) return message.imageMessage.caption;
693
717
  if (message.videoMessage?.caption) return message.videoMessage.caption;
694
718
  if (message.documentMessage?.caption) return message.documentMessage.caption;
719
+ // Captioned documents arrive wrapped in documentWithCaptionMessage.
720
+ if (message.documentWithCaptionMessage?.message?.documentMessage?.caption) {
721
+ return message.documentWithCaptionMessage.message.documentMessage.caption;
722
+ }
695
723
 
696
724
  // View-once wrappers
697
725
  if (message.viewOnceMessage?.message) return this.extractText(message.viewOnceMessage.message);
@@ -729,6 +757,25 @@ export class WhatsAppChannel implements ChannelProvider {
729
757
  return null;
730
758
  }
731
759
 
760
+ /** Extract document metadata (mimetype + fileName) from a message, unwrapping the
761
+ * common containers. Returns null when there is no document.
762
+ *
763
+ * WhatsApp wraps a captioned document in `documentWithCaptionMessage.message.documentMessage`
764
+ * while a bare document is `documentMessage` directly — both must resolve. (The actual binary
765
+ * is fetched via downloadMediaMessage on the outer `msg`, which Baileys unwraps itself.) */
766
+ private getDocumentInfo(message: any): { mimetype?: string; fileName?: string } | null {
767
+ if (!message) return null;
768
+ const doc =
769
+ message.documentMessage ||
770
+ message.documentWithCaptionMessage?.message?.documentMessage ||
771
+ message.viewOnceMessage?.message?.documentMessage ||
772
+ message.viewOnceMessageV2?.message?.documentMessage ||
773
+ message.ephemeralMessage?.message?.documentMessage ||
774
+ message.ephemeralMessage?.message?.documentWithCaptionMessage?.message?.documentMessage;
775
+ if (!doc) return null;
776
+ return { mimetype: doc.mimetype || undefined, fileName: doc.fileName || undefined };
777
+ }
778
+
732
779
  /** Check if a message contains audio (voice note or audio file) */
733
780
  private isAudioMessage(message: any): boolean {
734
781
  if (!message) return false;
@@ -3539,21 +3539,6 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3539
3539
  {/* ── Auth flow: Anthropic ── */}
3540
3540
  {provider === 'anthropic' && (
3541
3541
  <div className="space-y-2.5">
3542
- {/* Anthropic third-party usage policy notice — shown to anyone considering Claude. */}
3543
- <div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-3.5">
3544
- <div className="flex items-center gap-2 mb-2">
3545
- <TriangleAlert className="h-4 w-4 text-amber-400 shrink-0" />
3546
- <h3 className="text-[12.5px] font-semibold text-amber-200/90">Anthropic Third-Party App Policy Update</h3>
3547
- </div>
3548
- <div className="space-y-2 text-amber-100/70 text-[12px] leading-relaxed">
3549
- <p>Starting June 15, 2026, Anthropic will provide a separate Third-Party App credit equal to the amount you pay for your subscription.</p>
3550
- <p>For example, if you have the Max 5x plan at $100/month, you will receive $100 in credits to use with third-party tools like Bloby.</p>
3551
- <p>Unfortunately, this is only a fraction of the usage Bloby users had before. We don&apos;t control Anthropic&apos;s rules, but we do need to follow them.</p>
3552
- <p>The best alternative right now is a <span className="font-medium text-amber-100/90">ChatGPT subscription</span>, which also offers $100 and $200 plans with much higher usage limits for Bloby.</p>
3553
- <p>In the short term, Bloby will be optimized for ChatGPT. In the long term, we are building our own model harness so Bloby has more control, more flexibility, and does not depend too heavily on providers that can change their rules at any moment.</p>
3554
- </div>
3555
- </div>
3556
-
3557
3542
  {isConnected && (
3558
3543
  <div className="space-y-2.5">
3559
3544
  <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
@@ -23,7 +23,7 @@ export default function AudioBubble({ audioData }: Props) {
23
23
  // Historical audio is stored as an /api/files/* path, which a native Audio element
24
24
  // can't fetch (the Bearer token can't ride on the request) — resolve it to a blob URL
25
25
  // fetched with auth. data: URLs (freshly-recorded clips) pass straight through.
26
- const resolvedAudioUrl = useAuthedFileUrl(audioData);
26
+ const { url: resolvedAudioUrl } = useAuthedFileUrl(audioData);
27
27
 
28
28
  // Create Audio element once the source URL is ready
29
29
  useEffect(() => {
@@ -1,3 +1,4 @@
1
+ import { ImageOff } from 'lucide-react';
1
2
  import { useAuthedFileUrl } from '../../lib/authedFile';
2
3
 
3
4
  interface Props {
@@ -11,11 +12,23 @@ interface Props {
11
12
  * An `<img>` for `/api/files/*` attachments. The file is fetched with the auth token
12
13
  * (see `useAuthedFileUrl`) and rendered from a blob URL, because a native `<img src>`
13
14
  * request can't carry the Bearer token that `/api/files` now requires. While the fetch
14
- * is in flight (or if it failed) a subtle pulsing placeholder is shown in its place so
15
- * the layout doesn't jump.
15
+ * is in flight a subtle pulsing placeholder is shown in its place so the layout doesn't
16
+ * jump; if it failed (deleted / 401 / 5xx) a "broken image" fallback is shown instead.
16
17
  */
17
18
  export default function AuthedImage({ src, alt, className, onClick }: Props) {
18
- const resolvedSrc = useAuthedFileUrl(src);
19
+ const { url: resolvedSrc, status } = useAuthedFileUrl(src);
20
+
21
+ if (status === 'error') {
22
+ return (
23
+ <div
24
+ className={`${className ?? ''} flex items-center justify-center bg-black/10 text-muted-foreground/50`}
25
+ onClick={onClick}
26
+ title={alt || 'Image not found'}
27
+ >
28
+ <ImageOff className="h-5 w-5" />
29
+ </div>
30
+ );
31
+ }
19
32
 
20
33
  if (!resolvedSrc) {
21
34
  return <div className={`${className ?? ''} bg-white/10 animate-pulse`} onClick={onClick} />;
@@ -18,7 +18,7 @@ export default function BlobyImageCard({ src, alt }: Props) {
18
18
  // `src` may be a same-origin /api/files/* path (needs the auth token, which a native
19
19
  // <img> can't send) or an external URL — useAuthedFileUrl only fetches+authes the
20
20
  // former and passes external URLs through untouched (so the token never leaves origin).
21
- const resolvedSrc = useAuthedFileUrl(src);
21
+ const { url: resolvedSrc, status } = useAuthedFileUrl(src);
22
22
 
23
23
  const handleDownload = async () => {
24
24
  try {
@@ -37,7 +37,7 @@ export default function BlobyImageCard({ src, alt }: Props) {
37
37
  }
38
38
  };
39
39
 
40
- if (failed) {
40
+ if (failed || status === 'error') {
41
41
  return (
42
42
  <div className="my-2 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border border-border/30 bg-black/10 text-muted-foreground/50 text-xs">
43
43
  <ImageOff className="h-4 w-4 shrink-0" />
@@ -1,17 +1,26 @@
1
1
  import { useCallback, useEffect } from 'react';
2
2
  import { motion, AnimatePresence } from 'framer-motion';
3
- import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
3
+ import { ChevronLeft, ChevronRight, X, Download, ImageOff } from 'lucide-react';
4
4
  import { authFetch } from '../../lib/auth';
5
5
  import { useAuthedFileUrl } from '../../lib/authedFile';
6
6
 
7
+ /** One lightbox entry: the (possibly data:/`/api/files`) URL plus the human filename,
8
+ * so downloads and alt text use the real name rather than a URL stamp. */
9
+ export interface LightboxImage {
10
+ url: string;
11
+ name?: string;
12
+ }
13
+
7
14
  interface Props {
8
- images: string[];
15
+ images: LightboxImage[];
9
16
  index: number;
10
17
  onClose: () => void;
11
18
  onNavigate: (index: number) => void;
12
19
  }
13
20
 
14
21
  export default function ImageLightbox({ images, index, onClose, onNavigate }: Props) {
22
+ const current = images[index];
23
+
15
24
  const goPrev = useCallback(() => {
16
25
  if (index > 0) onNavigate(index - 1);
17
26
  }, [index, onNavigate]);
@@ -22,7 +31,7 @@ export default function ImageLightbox({ images, index, onClose, onNavigate }: Pr
22
31
 
23
32
  // /api/files/* needs the auth token, which a native <img src> can't send — resolve
24
33
  // the currently-shown image to a blob URL fetched with the Authorization header.
25
- const resolvedSrc = useAuthedFileUrl(images[index]);
34
+ const { url: resolvedSrc, status } = useAuthedFileUrl(current?.url);
26
35
 
27
36
  useEffect(() => {
28
37
  const handleKey = (e: KeyboardEvent) => {
@@ -50,18 +59,18 @@ export default function ImageLightbox({ images, index, onClose, onNavigate }: Pr
50
59
  onClick={async (e) => {
51
60
  e.stopPropagation();
52
61
  try {
53
- const res = await authFetch(images[index]);
62
+ const res = await authFetch(current.url);
54
63
  const blob = await res.blob();
55
64
  const url = URL.createObjectURL(blob);
56
65
  const a = document.createElement('a');
57
66
  a.href = url;
58
- a.download = images[index].split('/').pop() || 'image';
67
+ a.download = current.name || current.url.split('/').pop() || 'image';
59
68
  document.body.appendChild(a);
60
69
  a.click();
61
70
  document.body.removeChild(a);
62
71
  URL.revokeObjectURL(url);
63
72
  } catch {
64
- window.open(images[index], '_blank');
73
+ window.open(current.url, '_blank');
65
74
  }
66
75
  }}
67
76
  className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
@@ -105,10 +114,18 @@ export default function ImageLightbox({ images, index, onClose, onNavigate }: Pr
105
114
  )}
106
115
 
107
116
  {/* Image */}
108
- {resolvedSrc ? (
117
+ {status === 'error' ? (
118
+ <div
119
+ className="flex flex-col items-center gap-2 rounded-lg bg-white/5 px-8 py-10 text-white/50"
120
+ onClick={(e) => e.stopPropagation()}
121
+ >
122
+ <ImageOff className="h-10 w-10" />
123
+ <span className="text-sm">{current?.name || 'Image not found'}</span>
124
+ </div>
125
+ ) : resolvedSrc ? (
109
126
  <img
110
127
  src={resolvedSrc}
111
- alt=""
128
+ alt={current?.name || ''}
112
129
  className="max-h-[85vh] max-w-[90vw] object-contain rounded-lg"
113
130
  onClick={(e) => e.stopPropagation()}
114
131
  />
@@ -57,6 +57,14 @@ function compressImage(dataUrl: string, maxBytes = 4 * 1024 * 1024): Promise<str
57
57
 
58
58
  const DRAFT_KEY = 'bloby_draft';
59
59
 
60
+ /** Max per-file size — mirrors the server's MAX_ATTACHMENT_BYTES (~12MB). */
61
+ const MAX_ATTACHMENT_BYTES = 12 * 1024 * 1024;
62
+
63
+ /** Swap (or append) a file's extension so name/content/on-disk-ext agree after re-encode. */
64
+ function withExtension(name: string, ext: string): string {
65
+ return name.includes('.') ? name.replace(/\.[^.]+$/, `.${ext}`) : `${name}.${ext}`;
66
+ }
67
+
60
68
  export default function InputBar({ onSend, onStop, streaming, whisperEnabled, onTranscribe, onRecordingChange, onAudioReady }: Props) {
61
69
  const { start: startSpeech, stop: stopSpeech, abort: abortSpeech, isSupported: webSpeechSupported } = useSpeechRecognition();
62
70
  const voiceEnabled = whisperEnabled || webSpeechSupported;
@@ -64,6 +72,8 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
64
72
  try { return localStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; }
65
73
  });
66
74
  const [attachments, setAttachments] = useState<Attachment[]>([]);
75
+ const [attachError, setAttachError] = useState<string | null>(null);
76
+ const attachErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
77
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
78
  const [isRecording, _setIsRecording] = useState(false);
69
79
  const setIsRecording = useCallback((v: boolean) => {
@@ -107,6 +117,9 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
107
117
  return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); };
108
118
  }, [text]);
109
119
 
120
+ // Clean up the transient attach-error timer on unmount
121
+ useEffect(() => () => { if (attachErrorTimerRef.current) clearTimeout(attachErrorTimerRef.current); }, []);
122
+
110
123
  // Recording timer
111
124
  useEffect(() => {
112
125
  if (!isRecording) return;
@@ -198,18 +211,40 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
198
211
 
199
212
  // ── File handling ──
200
213
 
214
+ /** Surface a transient inline error in the attachment tray. */
215
+ const showAttachError = useCallback((msg: string) => {
216
+ setAttachError(msg);
217
+ if (attachErrorTimerRef.current) clearTimeout(attachErrorTimerRef.current);
218
+ attachErrorTimerRef.current = setTimeout(() => setAttachError(null), 4000);
219
+ }, []);
220
+
201
221
  const addFile = useCallback((file: File) => {
202
222
  const isImage = file.type.startsWith('image/');
203
- const isPdf = file.type === 'application/pdf';
204
- if (!isImage && !isPdf) return;
223
+
224
+ // Constrain by what the harness/model accepts, not by type — accept any file,
225
+ // but guard size (images are re-compressed below, so only gate non-images here).
226
+ if (!isImage && file.size > MAX_ATTACHMENT_BYTES) {
227
+ showAttachError(`"${file.name}" is too large (max ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB)`);
228
+ return;
229
+ }
205
230
 
206
231
  const reader = new FileReader();
207
232
  reader.onload = async (e) => {
208
233
  let preview = e.target?.result as string;
234
+ let name = file.name;
209
235
 
210
- // Compress images to stay under API limits
211
236
  if (isImage) {
237
+ // Compress images to stay under API limits. compressImage() always emits
238
+ // image/jpeg, so realign the name's extension to .jpg (name/mediaType/ext agree).
212
239
  preview = await compressImage(preview);
240
+ if (preview.startsWith('data:image/jpeg')) name = withExtension(name, 'jpg');
241
+ } else {
242
+ // Non-image: enforce size on the encoded payload too (data URL ~33% larger).
243
+ const approxBytes = Math.round((preview.length - (preview.indexOf(',') + 1)) * 0.75);
244
+ if (approxBytes > MAX_ATTACHMENT_BYTES) {
245
+ showAttachError(`"${file.name}" is too large (max ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB)`);
246
+ return;
247
+ }
213
248
  }
214
249
 
215
250
  setAttachments((prev) => [
@@ -217,13 +252,13 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
217
252
  {
218
253
  id: Math.random().toString(36).slice(2),
219
254
  type: isImage ? 'image' : 'file',
220
- name: file.name,
255
+ name,
221
256
  preview,
222
257
  },
223
258
  ]);
224
259
  };
225
260
  reader.readAsDataURL(file);
226
- }, []);
261
+ }, [showAttachError]);
227
262
 
228
263
  const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
229
264
  const files = e.target.files;
@@ -367,6 +402,24 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
367
402
  isRecording ? 'opacity-0 pointer-events-none' : 'opacity-100'
368
403
  }`}
369
404
  >
405
+ {/* ── Inline attachment error (oversize / read failure) ── */}
406
+ <AnimatePresence>
407
+ {attachError && (
408
+ <motion.div
409
+ initial={{ height: 0, opacity: 0 }}
410
+ animate={{ height: 'auto', opacity: 1 }}
411
+ exit={{ height: 0, opacity: 0 }}
412
+ transition={{ duration: 0.15 }}
413
+ className="overflow-hidden"
414
+ >
415
+ <div className="flex items-center gap-2 px-3 py-2 text-[13px] text-destructive bg-destructive/10 border border-destructive/20 rounded-2xl mb-2">
416
+ <X className="w-3.5 h-3.5 shrink-0" />
417
+ <span className="truncate">{attachError}</span>
418
+ </div>
419
+ </motion.div>
420
+ )}
421
+ </AnimatePresence>
422
+
370
423
  {/* ── Attachment previews ── */}
371
424
  <AnimatePresence>
372
425
  {attachments.length > 0 && (
@@ -427,8 +480,10 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
427
480
  className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400 overflow-y-auto"
428
481
  style={{ maxHeight: 88 }}
429
482
  />
430
- {/* Hidden file inputs for native PWA pickers */}
431
- <input ref={fileRef} type="file" className="hidden" accept="image/*,.pdf" multiple onChange={handleFileChange} />
483
+ {/* Hidden file inputs for native PWA pickers.
484
+ Paperclip accepts any file (the harness/model decides what it can use);
485
+ the camera input stays image-only. */}
486
+ <input ref={fileRef} type="file" className="hidden" accept="*/*" multiple onChange={handleFileChange} />
432
487
  <input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" onChange={handleFileChange} />
433
488
  <button
434
489
  type="button"