experimental-a2 0.18.0 → 0.19.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 +39 -0
- package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
- package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +122 -44
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
- package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
- package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +114 -23
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +127 -55
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
- package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +10 -14
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +25 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +55 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +35 -20
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/agents/03-conversations.mdx +27 -15
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/index.mdx +3 -3
- package/docs/reference/02-server.mdx +12 -7
- package/docs/reference/04-ai.mdx +41 -23
- package/docs/reference/05-adapters.mdx +6 -6
- package/docs/server/01-handlers.mdx +1 -1
- package/docs/server/02-timers.mdx +1 -1
- package/docs/server/03-cancellation.mdx +3 -2
- package/docs/server/05-production.mdx +4 -3
- package/docs/server/06-guarantees.mdx +20 -11
- package/examples/playground/DEPLOY.md +11 -4
- package/examples/playground/app/a0/[appId]/page.tsx +2 -8
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +504 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +13 -19
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +3 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +14 -3
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +184 -67
- package/src/ai-harness.ts +153 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +161 -46
- package/src/ai-stored-state.ts +1 -3
- package/src/ai-tool-input.ts +45 -0
- package/src/ai.ts +116 -77
- package/src/internal.ts +6 -0
- package/src/libfx.d.ts +8 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +15 -27
- package/src/server.ts +58 -12
- package/src/store-memory.ts +50 -9
- package/src/store-postgres.ts +79 -28
- package/src/store-redis-core.ts +112 -31
- package/src/store-sqlite.ts +61 -28
- package/src/store.ts +22 -3
- package/dist/ai-BpOGm-zB.js.map +0 -1
- package/dist/ai-DrUaMyRt.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
- package/dist/server-Dw_k_7kO.js.map +0 -1
- package/dist/server-En7cbi3v.d.ts.map +0 -1
- package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
- package/dist/store-Djdv0bnZ.d.ts.map +0 -1
- package/dist/store-redis-notify-BRITti_o.js.map +0 -1
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -345,10 +345,140 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
345
345
|
.a0-text[data-role='user'] {
|
|
346
346
|
align-self: flex-end;
|
|
347
347
|
padding: 0.5rem 0.75rem;
|
|
348
|
+
overflow-wrap: anywhere;
|
|
348
349
|
background: var(--shade);
|
|
349
350
|
color: var(--ink);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
/* ── attachments ─────────────────────────────────────────────────── */
|
|
354
|
+
|
|
355
|
+
.a0-chat-drop {
|
|
356
|
+
display: contents;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* Over the whole chat column, which positions it. */
|
|
360
|
+
.a0-drop {
|
|
361
|
+
position: absolute;
|
|
362
|
+
z-index: 2;
|
|
363
|
+
inset: 0;
|
|
364
|
+
display: flex;
|
|
365
|
+
align-items: center;
|
|
366
|
+
justify-content: center;
|
|
367
|
+
gap: 0.5rem;
|
|
368
|
+
background: color-mix(in oklab, var(--bg) 85%, transparent);
|
|
369
|
+
outline: 2px dashed var(--muted);
|
|
370
|
+
outline-offset: -0.75rem;
|
|
371
|
+
color: var(--bright);
|
|
372
|
+
pointer-events: none;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.a0-files {
|
|
376
|
+
display: flex;
|
|
377
|
+
flex-wrap: wrap;
|
|
378
|
+
gap: 0.375rem;
|
|
379
|
+
max-width: 85%;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.a0-files[data-role='user'] {
|
|
383
|
+
align-self: flex-end;
|
|
384
|
+
justify-content: flex-end;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.a0-files[data-role='assistant'] {
|
|
388
|
+
align-self: flex-start;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.a0-files img {
|
|
392
|
+
display: block;
|
|
393
|
+
max-width: 12rem;
|
|
394
|
+
max-height: 9rem;
|
|
395
|
+
border: 1px solid var(--line);
|
|
396
|
+
object-fit: cover;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.a0-file {
|
|
400
|
+
display: inline-flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
gap: 0.25rem;
|
|
403
|
+
color: inherit;
|
|
404
|
+
text-decoration: underline dotted var(--faint);
|
|
405
|
+
text-underline-offset: 3px;
|
|
406
|
+
vertical-align: bottom;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.a0-files .a0-file {
|
|
410
|
+
padding: 0.25rem 0.5rem;
|
|
411
|
+
border: 1px dashed var(--line);
|
|
412
|
+
text-decoration: none;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.a0-attachments {
|
|
416
|
+
display: flex;
|
|
417
|
+
flex-wrap: wrap;
|
|
418
|
+
gap: 0.375rem;
|
|
419
|
+
margin: 0;
|
|
420
|
+
padding: 0.625rem 0.75rem 0;
|
|
421
|
+
list-style: none;
|
|
422
|
+
font-size: 0.75rem;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.a0-attachments li {
|
|
426
|
+
position: relative;
|
|
427
|
+
display: inline-flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 0.25rem;
|
|
430
|
+
min-width: 0;
|
|
431
|
+
max-width: 100%;
|
|
432
|
+
color: var(--muted);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.a0-attachments li[data-kind='link'] {
|
|
436
|
+
height: 1.75rem;
|
|
437
|
+
padding-left: 0.5rem;
|
|
438
|
+
border: 1px dashed var(--line);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.a0-attachments li[data-kind='link'] span {
|
|
442
|
+
overflow: hidden;
|
|
443
|
+
text-overflow: ellipsis;
|
|
444
|
+
white-space: nowrap;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.a0-attachments img {
|
|
448
|
+
display: block;
|
|
449
|
+
width: 3rem;
|
|
450
|
+
height: 3rem;
|
|
451
|
+
border: 1px solid var(--line);
|
|
452
|
+
object-fit: cover;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.a0-attachments li[data-kind='image'][data-uploading] img {
|
|
456
|
+
opacity: 0.5;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* On the thumbnail's corners: the spinner, and the button that removes it. */
|
|
460
|
+
.a0-attachments li[data-kind='image'] .a0-spin {
|
|
461
|
+
position: absolute;
|
|
462
|
+
top: calc(50% - 6px);
|
|
463
|
+
left: calc(1.5rem - 6px);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.a0-attachments li[data-kind='image'] .a0-icon-button {
|
|
467
|
+
position: absolute;
|
|
468
|
+
top: -0.375rem;
|
|
469
|
+
right: -0.375rem;
|
|
470
|
+
width: 1.125rem;
|
|
471
|
+
height: 1.125rem;
|
|
472
|
+
border: 1px solid var(--line);
|
|
473
|
+
border-radius: 50%;
|
|
474
|
+
background: var(--bg);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.a0-attachments li[data-kind='link'] .a0-icon-button {
|
|
478
|
+
width: 1.5rem;
|
|
479
|
+
height: 1.5rem;
|
|
480
|
+
}
|
|
481
|
+
|
|
352
482
|
.a0-text[data-role='assistant'] {
|
|
353
483
|
align-self: flex-start;
|
|
354
484
|
color: var(--muted);
|
|
@@ -428,6 +558,86 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
428
558
|
white-space: nowrap;
|
|
429
559
|
}
|
|
430
560
|
|
|
561
|
+
/* A tool step that shows what the call writes, folded once it's done. */
|
|
562
|
+
.a0-tool {
|
|
563
|
+
align-self: stretch;
|
|
564
|
+
min-width: 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.a0-tool summary {
|
|
568
|
+
width: fit-content;
|
|
569
|
+
cursor: pointer;
|
|
570
|
+
list-style: none;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.a0-tool summary::-webkit-details-marker {
|
|
574
|
+
display: none;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.a0-tool summary:hover .a0-step-label {
|
|
578
|
+
color: var(--ink);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.a0-tool[data-failed] summary,
|
|
582
|
+
.a0-tool[data-failed] .a0-step-label {
|
|
583
|
+
color: var(--danger);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.a0-tool-text {
|
|
587
|
+
max-height: 14rem;
|
|
588
|
+
margin: 0.375rem 0 0 1.125rem;
|
|
589
|
+
padding: 0.5rem 0.625rem;
|
|
590
|
+
overflow: auto;
|
|
591
|
+
background: color-mix(in oklab, var(--shade) 60%, transparent);
|
|
592
|
+
color: var(--muted);
|
|
593
|
+
font-size: 0.6875rem;
|
|
594
|
+
line-height: 1.5;
|
|
595
|
+
overflow-wrap: anywhere;
|
|
596
|
+
scrollbar-color: var(--faint) transparent;
|
|
597
|
+
scrollbar-width: thin;
|
|
598
|
+
white-space: pre-wrap;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* Messages waiting for the turn to end, just above the composer. */
|
|
602
|
+
.a0-queue {
|
|
603
|
+
display: flex;
|
|
604
|
+
flex-shrink: 0;
|
|
605
|
+
flex-direction: column;
|
|
606
|
+
gap: 0.25rem;
|
|
607
|
+
margin: 0 0.75rem;
|
|
608
|
+
padding: 0;
|
|
609
|
+
list-style: none;
|
|
610
|
+
font-size: 0.75rem;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.a0-queue li {
|
|
614
|
+
display: flex;
|
|
615
|
+
align-items: center;
|
|
616
|
+
gap: 0.375rem;
|
|
617
|
+
padding: 0.25rem 0.25rem 0.25rem 0.625rem;
|
|
618
|
+
border: 1px dashed var(--line);
|
|
619
|
+
color: var(--muted);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.a0-queue span {
|
|
623
|
+
flex: 1;
|
|
624
|
+
min-width: 0;
|
|
625
|
+
overflow: hidden;
|
|
626
|
+
text-overflow: ellipsis;
|
|
627
|
+
white-space: nowrap;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.a0-queue button {
|
|
631
|
+
height: 1.375rem;
|
|
632
|
+
padding: 0 0.375rem;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.a0-queue .a0-icon-button {
|
|
636
|
+
width: 1.375rem;
|
|
637
|
+
padding: 0;
|
|
638
|
+
color: var(--faint);
|
|
639
|
+
}
|
|
640
|
+
|
|
431
641
|
.a0-send-error {
|
|
432
642
|
margin: 0;
|
|
433
643
|
padding: 0 1.25rem 0.5rem;
|
|
@@ -533,16 +743,45 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
533
743
|
gap: 0.5rem;
|
|
534
744
|
}
|
|
535
745
|
|
|
536
|
-
.a0-queued {
|
|
537
|
-
color: var(--faint);
|
|
538
|
-
font-size: 0.75rem;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
746
|
.a0-composer-actions button {
|
|
542
747
|
height: 1.75rem;
|
|
543
748
|
padding: 0 0.625rem;
|
|
544
749
|
}
|
|
545
750
|
|
|
751
|
+
.a0-composer-actions > .a0-icon-button {
|
|
752
|
+
padding: 0;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* Pause or resume, then stop, as a player's buttons. */
|
|
756
|
+
.a0-player {
|
|
757
|
+
display: flex;
|
|
758
|
+
align-items: center;
|
|
759
|
+
gap: 0.125rem;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.a0-composer-actions .a0-player button {
|
|
763
|
+
width: 1.75rem;
|
|
764
|
+
padding: 0;
|
|
765
|
+
color: var(--muted);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/* Send, or during a turn Steer or Queue with its mode on the chevron. */
|
|
769
|
+
.a0-send {
|
|
770
|
+
display: flex;
|
|
771
|
+
align-items: center;
|
|
772
|
+
gap: 1px;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.a0-composer-actions .a0-send-mode {
|
|
776
|
+
width: 1.5rem;
|
|
777
|
+
padding: 0;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.a0-menu-check {
|
|
781
|
+
margin-right: 0.375rem;
|
|
782
|
+
vertical-align: -0.125rem;
|
|
783
|
+
}
|
|
784
|
+
|
|
546
785
|
.a0-ghost {
|
|
547
786
|
color: var(--muted);
|
|
548
787
|
}
|
|
@@ -44,11 +44,12 @@ export function AppView({
|
|
|
44
44
|
<section aria-label="Chat" className="a0-chat">
|
|
45
45
|
<h1 className="a0-title">{name}</h1>
|
|
46
46
|
<Chat
|
|
47
|
+
appId={appId}
|
|
47
48
|
harness={harness}
|
|
48
49
|
onPrompt={(text) =>
|
|
49
50
|
prompted({
|
|
50
51
|
id: appId,
|
|
51
|
-
name: titleFromPrompt(prompt
|
|
52
|
+
name: titleFromPrompt(prompt || text),
|
|
52
53
|
harness,
|
|
53
54
|
})
|
|
54
55
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { upload as uploadToBlob } from '@vercel/blob/client'
|
|
3
|
+
import type { FileUIPart } from 'ai'
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { isAttachmentUrl } from './model'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A file the user attached. An image goes out with the message, for the
|
|
9
|
+
* model to see; any other file goes into the message's text as a link, which
|
|
10
|
+
* the agent downloads in its sandbox.
|
|
11
|
+
*/
|
|
12
|
+
export type Attachment = {
|
|
13
|
+
id: string
|
|
14
|
+
file: File
|
|
15
|
+
kind: 'image' | 'link'
|
|
16
|
+
/** Once Blob has it. */
|
|
17
|
+
part?: FileUIPart
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Attachments = {
|
|
21
|
+
list: Attachment[]
|
|
22
|
+
add: (files: File[]) => void
|
|
23
|
+
/** Stops its upload, if it's still running. */
|
|
24
|
+
remove: (id: string) => void
|
|
25
|
+
clear: () => void
|
|
26
|
+
/** Puts back what a failed send took, before anything attached since. */
|
|
27
|
+
restore: (sent: Attachment[]) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isImage = (file: File): boolean => file.type.startsWith('image/')
|
|
31
|
+
|
|
32
|
+
/** Uploads straight to Blob, with a token from the app's uploads route. */
|
|
33
|
+
async function upload({
|
|
34
|
+
appId,
|
|
35
|
+
file,
|
|
36
|
+
signal,
|
|
37
|
+
}: {
|
|
38
|
+
appId: string
|
|
39
|
+
file: File
|
|
40
|
+
signal: AbortSignal
|
|
41
|
+
}): Promise<FileUIPart> {
|
|
42
|
+
const blob = await uploadToBlob(`a0/${appId}/${file.name}`, file, {
|
|
43
|
+
access: 'public',
|
|
44
|
+
handleUploadUrl: '/a0/uploads',
|
|
45
|
+
clientPayload: appId,
|
|
46
|
+
...(file.type ? { contentType: file.type } : {}),
|
|
47
|
+
abortSignal: signal,
|
|
48
|
+
}).catch((cause: unknown) => {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Couldn't upload ${file.name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
51
|
+
{ cause },
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
return {
|
|
55
|
+
type: 'file',
|
|
56
|
+
mediaType: blob.contentType,
|
|
57
|
+
filename: file.name,
|
|
58
|
+
url: blob.url,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The line a file that isn't an image adds to the message. */
|
|
63
|
+
const linkLine = (part: FileUIPart): string =>
|
|
64
|
+
`${part.filename ?? 'file'}: ${part.url}`
|
|
65
|
+
|
|
66
|
+
/** One of the composer's link lines, as the file it names. */
|
|
67
|
+
export function attachedLink(
|
|
68
|
+
line: string,
|
|
69
|
+
): { name: string; url: string } | null {
|
|
70
|
+
const match = /^(.+): (https:\/\/\S+)$/.exec(line)
|
|
71
|
+
return match?.[1] && match[2] && isAttachmentUrl(match[2])
|
|
72
|
+
? { name: match[1], url: match[2] }
|
|
73
|
+
: null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The files attached to the next message, each uploading as it's added. */
|
|
77
|
+
export function useAttachments({
|
|
78
|
+
appId,
|
|
79
|
+
onLink,
|
|
80
|
+
onError,
|
|
81
|
+
}: {
|
|
82
|
+
appId: string
|
|
83
|
+
/** A file that isn't an image finished uploading: its line for the text. */
|
|
84
|
+
onLink: (line: string) => void
|
|
85
|
+
onError: (cause: unknown) => void
|
|
86
|
+
}): Attachments {
|
|
87
|
+
const [list, setList] = useState<Attachment[]>([])
|
|
88
|
+
// The uploads still running, which removing their file or leaving the app
|
|
89
|
+
// stops.
|
|
90
|
+
const uploads = useRef(new Map<string, AbortController>())
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const running = uploads.current
|
|
93
|
+
return () => {
|
|
94
|
+
for (const controller of running.values()) controller.abort()
|
|
95
|
+
}
|
|
96
|
+
}, [])
|
|
97
|
+
const drop = (id: string): void =>
|
|
98
|
+
setList((current) => current.filter((item) => item.id !== id))
|
|
99
|
+
const attach = async (file: File): Promise<void> => {
|
|
100
|
+
const id = crypto.randomUUID()
|
|
101
|
+
const kind = isImage(file) ? 'image' : 'link'
|
|
102
|
+
const controller = new AbortController()
|
|
103
|
+
uploads.current.set(id, controller)
|
|
104
|
+
setList((current) => [...current, { id, file, kind }])
|
|
105
|
+
try {
|
|
106
|
+
const part = await upload({ appId, file, signal: controller.signal })
|
|
107
|
+
if (kind === 'link') {
|
|
108
|
+
drop(id)
|
|
109
|
+
onLink(linkLine(part))
|
|
110
|
+
} else
|
|
111
|
+
setList((current) =>
|
|
112
|
+
current.map((item) => (item.id === id ? { ...item, part } : item)),
|
|
113
|
+
)
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
drop(id)
|
|
116
|
+
if (!controller.signal.aborted) onError(cause)
|
|
117
|
+
} finally {
|
|
118
|
+
uploads.current.delete(id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
list,
|
|
123
|
+
add: (files) => {
|
|
124
|
+
for (const file of files) void attach(file)
|
|
125
|
+
},
|
|
126
|
+
remove: (id) => {
|
|
127
|
+
uploads.current.get(id)?.abort()
|
|
128
|
+
drop(id)
|
|
129
|
+
},
|
|
130
|
+
clear: () => setList([]),
|
|
131
|
+
restore: (sent) => setList((current) => [...sent, ...current]),
|
|
132
|
+
}
|
|
133
|
+
}
|