mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
634 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c44804d50 | |||
| 8589376c66 | |||
| d53a8b4941 | |||
| af819da623 | |||
| 3addc4e2ac | |||
| 7ff7e489ab | |||
| 95aa0da014 | |||
| b12637267b | |||
| 3a44f70db9 | |||
| 92206d9740 | |||
| bddd91df2a | |||
| 144ead8cfe | |||
| 185f8e7f44 | |||
| 1538cd83af | |||
| 027f7deb3a | |||
| 4043a6098b | |||
| 92b913be98 | |||
| 8505ba6b84 | |||
| c6973f6b4e | |||
| 94eddaff3f | |||
| f38be4aff3 | |||
| 3ea78fcf9f | |||
| 78cfcc6206 | |||
| 9c5d4a18ce | |||
| aa48b4d596 | |||
| 265acd9345 | |||
| 34ec1d5671 | |||
| 4a1f4f0a01 | |||
| 850528820f | |||
| 4dc8197c51 | |||
| 42e97eed4c | |||
| 065f30ac38 | |||
| 9e705a12b1 | |||
| b8144f0748 | |||
| e5b5faad3e | |||
| f840c1d424 | |||
| eabd268874 | |||
| 06aadc543a | |||
| 2a410f52b5 | |||
| eb7a32ed16 | |||
| 14118d3056 | |||
| c8b3d8ad9b | |||
| a097b32d5c | |||
| 0a88a9cee6 | |||
| bef1c0c5fc | |||
| 52e6ef436f | |||
| ad0617de90 | |||
| 1753c1a40a | |||
| 13b7004959 | |||
| 3b9a21bbf7 | |||
| 5f0beb9d00 | |||
| 8411a73589 | |||
| 009a3751c0 | |||
| adef88e358 | |||
| f8b9df7bf0 | |||
| c6fa3e1d24 | |||
| ae24dd1e28 | |||
| 1efca7dd48 | |||
| 3178f4e7e9 | |||
| e00f61dcd0 | |||
| 6a5774aae7 | |||
| 5119061861 | |||
| fdfbae334a | |||
| e3fce43e62 | |||
| 9251f8ff0e | |||
| 18ef40f6f4 | |||
| 46887d1d9f | |||
| 632d10e9e3 | |||
| 9fa33eea73 | |||
| 2c4c13bc2c | |||
| 33f8a4eb3a | |||
| aa7959a970 | |||
| 7471bc0bb2 | |||
| b257f75e53 | |||
| 455e279216 | |||
| 7fd359852a | |||
| 82ecfdbd37 | |||
| 478452983f | |||
| 5c1a7d485f | |||
| 39c4ce9240 | |||
| da49585df5 | |||
| 0b9bee02fe | |||
| 00e5d1ae27 | |||
| b290d63926 | |||
| 1b5438cc6c | |||
| 17323facce | |||
| bc9dedeea4 | |||
| 1b3a383b53 | |||
| 4e0a535402 | |||
| 0005db1b33 | |||
| 5cd74031be | |||
| facb85b5da | |||
| 5f97d17837 | |||
| af722e09f8 | |||
| 959edf6010 | |||
| d08f183394 | |||
| da541ae182 | |||
| 4582c4c03d | |||
| 8c7d70d434 | |||
| fcf9f9e562 | |||
| 7bb0fb294a | |||
| 2e7b5ba5f0 | |||
| 6b017f3678 | |||
| a303d00900 | |||
| aaa351dca4 | |||
| ee5fb5361c | |||
| aaffcdbfeb | |||
| a8fefb5a90 | |||
| 8e3b07fa49 | |||
| 36ac618e88 | |||
| ab0eeae1e3 | |||
| f74adffa12 | |||
| 8f23f41e2f | |||
| 7d04844c6a | |||
| c301dcc226 | |||
| 8dd4ece730 | |||
| 75bd68f9fe | |||
| 96af022afa | |||
| c570c68f1b | |||
| 21a226a486 | |||
| 2695cb8e46 | |||
| 2207405ebc | |||
| 3802123147 | |||
| c6c630f5c6 | |||
| 7c76a17c08 | |||
| 5ba7723fa0 | |||
| 87ff07c850 | |||
| 71e1a2eeec | |||
| 88fba0f53a | |||
| 07260a8e06 | |||
| c1d155b569 | |||
| 7e7cfe1db1 | |||
| d27a44ab7f | |||
| 2adcca1cda | |||
| cf854b7262 | |||
| ecb0e07312 | |||
| 7d6d7e619b | |||
| 8b2b88c7cb | |||
| 9af1a6a16b | |||
| 34caa16e39 | |||
| 976426dbd3 | |||
| d1ac9adc7e | |||
| 513edf90f7 | |||
| 60d47510ab | |||
| 5b7b9837f0 | |||
| 333c3327c4 | |||
| 9723c98940 | |||
| 97604f3c5b | |||
| 044f18da46 | |||
| 53946b9523 | |||
| fd8f88c5e4 | |||
| e7d15ce2b0 | |||
| ff1d98a87e | |||
| accc68cd28 | |||
| b2c7bc980f | |||
| 75fbe8d5d8 | |||
| 13ebf3b3aa | |||
| 916d3812db | |||
| 90610c819b | |||
| a5f6f62559 | |||
| bfb3501dec | |||
| c0513c50b1 | |||
| bcf4baf004 | |||
| 53bf948a04 | |||
| 2186d91f89 | |||
| aaf856a503 | |||
| 8af625b7dc | |||
| 4690891757 | |||
| bb3e17c0fa | |||
| 7965df5ff2 | |||
| 5b5f0a5a8d | |||
| fdb087a39b | |||
| 97749378d6 | |||
| 63dc2301ff | |||
| 5659c0bc70 | |||
| 1e288ab0fd | |||
| 4f058a0174 | |||
| 7284114565 | |||
| 0b2592dbd7 | |||
| edfaf6f002 | |||
| da3990b614 | |||
| 25740ae13c | |||
| fb4c05f698 | |||
| a0c4e37c94 | |||
| 278caf6f0c | |||
| 2ce0c61f83 | |||
| afb25324a7 | |||
| ba1b761c08 | |||
| 0e2d4af617 | |||
| 1b0b54a072 | |||
| 9c629d3c5c | |||
| 173af4e459 | |||
| c0f12c0a5d | |||
| 390605fe66 | |||
| e4bd5f865c | |||
| b31c891772 | |||
| 08e4016972 | |||
| aea7eb6ba3 | |||
| 5496750085 | |||
| 4b9709898c | |||
| 705daac737 | |||
| a802b32f47 | |||
| 8b8db5e447 | |||
| 3ee44599c7 | |||
| 2955a41ed5 | |||
| a52802c882 | |||
| b46c70512a | |||
| 18f91e2eeb | |||
| 9296984569 | |||
| 7b835d9855 | |||
| ce23b9169b | |||
| 47a535d309 | |||
| 6342801aa0 | |||
| 50c00f5516 | |||
| 4a49678fb6 | |||
| 0f10b8f677 | |||
| d8433b79cc | |||
| f94f640212 | |||
| 5cf779757f | |||
| d49acf379e | |||
| b9bff4abc0 | |||
| 6fc4dbe9d1 | |||
| cca8132a2c | |||
| 91654ca219 | |||
| 547d7eca59 | |||
| b86bf31baa | |||
| 5b5b4efe42 | |||
| e9fb65edba | |||
| cc1cba9aa8 | |||
| a765c566c8 | |||
| 63e9022b84 | |||
| 368a995e7f | |||
| c844c66b5a | |||
| 73b18313e9 | |||
| bdd68dc6c9 | |||
| 3901b94382 | |||
| 82ac276338 | |||
| 02c9f3ebdb | |||
| 364ad63877 | |||
| 5fc4196d01 | |||
| 3a1e10bd21 | |||
| 73519ec562 | |||
| bf9c9916b1 | |||
| 01d017c6cd | |||
| ca98ab02d8 | |||
| 347804a02e | |||
| 4c80f8dbf4 | |||
| 73ee96040f | |||
| 6180da1333 | |||
| 2756ff6ad0 | |||
| e57491b812 | |||
| 9d8ae538d9 | |||
| dd7defd2c7 | |||
| e79ec45b5b | |||
| 1a138bbc16 | |||
| b067165471 | |||
| 6fbcbb9399 | |||
| aaf77b4e20 | |||
| f5cc2e952b | |||
| eeab362567 | |||
| 834205c426 | |||
| fbad8ca62e | |||
| 1e4c6f13c5 | |||
| b7c2b3d4cb | |||
| 0d5b7d36f1 | |||
| 059886fede | |||
| db7dd0ca43 | |||
| f4c611b47d | |||
| 39c32646c5 | |||
| 1720fffbdc | |||
| b4d8e39d56 | |||
| 6c51cd0d1d | |||
| cb9cdc508a | |||
| 7d037a206f | |||
| ace10ab4be | |||
| bc0a7b6ac3 | |||
| e77e2045e3 | |||
| abbd55c740 | |||
| bf5e80a462 | |||
| 121deaae5f | |||
| 80317232ba | |||
| 22f815dcd1 | |||
| fb96c3ab47 | |||
| 3b15ad51a1 | |||
| 11c41e7381 | |||
| 358d8a54ff | |||
| 3c8fedce68 | |||
| 5066336c75 | |||
| 1744b5b9d0 | |||
| 0807744577 | |||
| 59f871d3ec | |||
| fed351a2fc | |||
| 0c15476dd2 | |||
| 94ef76c67e | |||
| bd5bf6f94f | |||
| 1fbf454c3c | |||
| 07b62fe5c1 | |||
| 7fbf6ee2e8 | |||
| ba66fc30c5 | |||
| 45b7ed3220 | |||
| 20f1c4c0ae | |||
| 97b6fc5e2b | |||
| 44d8c30187 | |||
| e3957bf08b | |||
| acfe0aba21 | |||
| 6247b5411b | |||
| 5cc0b0a011 | |||
| 1fed2fb18c | |||
| 8a0e7a4e3d | |||
| 29a784c6c6 | |||
| 409a3ee194 | |||
| 54caa3e01a | |||
| e1a723a39f | |||
| 463ea35d7c | |||
| f751c91c68 | |||
| ad24c8771a | |||
| 6f82e2c3ed | |||
| f4b39071f0 | |||
| 621c968f3f | |||
| aeb129e422 | |||
| 3050b546ac | |||
| 1429726ba6 | |||
| 4075581acd | |||
| 56774fd974 | |||
| 5e674d2299 | |||
| 06f5b6d6ff | |||
| b25b4e6c8f | |||
| 645e07dba8 | |||
| 46181fcaa2 | |||
| 8d7ae425f9 | |||
| 7d572334a1 | |||
| 5dab6f68e6 | |||
| d1c595d8db | |||
| eaa2635b51 | |||
| dc2d226ddb | |||
| 336a4e1f35 | |||
| 4d3b6b4f43 | |||
| a12601b49c | |||
| 15a895064e | |||
| 8bd1507ace | |||
| 89d7ec5d0b | |||
| 670e57735a | |||
| fa703c25e8 | |||
| f58161b1d1 | |||
| 8db2a37a59 | |||
| bfdb9c2624 | |||
| 240e984737 | |||
| fe128c18b1 | |||
| b208d8c40d | |||
| 556641e1f4 | |||
| 464eb671db | |||
| 12b8f1e3ef | |||
| ab199afe0d | |||
| fe1a498da0 | |||
| 4f9d55eb42 | |||
| 70f450f547 | |||
| 28fc7deefc | |||
| 428babf856 | |||
| b824ddf2e3 | |||
| 2396966740 | |||
| 23ca49128a | |||
| ec6bdede20 | |||
| 4ada2013d2 | |||
| 79afef6bc1 | |||
| e7000df89f | |||
| 59f77a64ea | |||
| 8be152666e | |||
| 10488854ce | |||
| 6586aafed8 | |||
| 4568a60be3 | |||
| 193bc8bb8e | |||
| ce381b7690 | |||
| b238428816 | |||
| 0ac37f50cf | |||
| 54b9389b77 | |||
| a183c26e51 | |||
| 01a03d164c | |||
| cdff1fde2d | |||
| c38b9998a6 | |||
| 77c1a335ad | |||
| 07a0fe6249 | |||
| 204bc46976 | |||
| b910506519 | |||
| 3cef39da17 | |||
| 3aea29bcb5 | |||
| dd0d19168b | |||
| 6727fcd111 | |||
| 9d347f4a5a | |||
| 084e48ddc2 | |||
| 31e89ce9a1 | |||
| baad3ae1c3 | |||
| 7c099cab94 | |||
| 811875dd2e | |||
| 127443d550 | |||
| d2064605bf | |||
| 4c6fb61ca8 | |||
| 608ba8bcb4 | |||
| b53c054dee | |||
| 05aa4b547f | |||
| 6afb61d25d | |||
| a7ce5c1ca6 | |||
| 952bd2bd93 | |||
| f9d33d4888 | |||
| 81d99f19d4 | |||
| 454a4257da | |||
| e513b42786 | |||
| b607e3c034 | |||
| d5c3f5012b | |||
| 21d045be59 | |||
| a9c1c34dc9 | |||
| 44ab0483b6 | |||
| 9eb0cc0b62 | |||
| 2db74867f5 | |||
| fd30baafb8 | |||
| 3623eef47f | |||
| 7b07bb7884 | |||
| 7946cd6614 | |||
| 51b6e30986 | |||
| 002df7b0f9 | |||
| 564cf0fed0 | |||
| dee9492d4c | |||
| 6ae026f7c5 | |||
| 6bcbe286f3 | |||
| 6f35f72607 | |||
| 3a7aa75538 | |||
| e4e7ac260a | |||
| b8aaa4bb42 | |||
| 7793e2694b | |||
| 83f2c72f29 | |||
| 1caeaee7f0 | |||
| f354134234 | |||
| 66219d30e0 | |||
| b9e3942ed8 | |||
| 2354cdc1d1 | |||
| d929438df9 | |||
| 1acaed1de7 | |||
| 16195f8a55 | |||
| d7fc8c178f | |||
| 2894e16706 | |||
| c2340f3432 | |||
| 3b7b3106db | |||
| cff92819f9 | |||
| 2f981d852b | |||
| 8eef74d776 | |||
| 60e46204dc | |||
| 6a5d783435 | |||
| 0223e076c4 | |||
| ce80c78319 | |||
| cc0085ae61 | |||
| f28e243b9d | |||
| 2e4532593f | |||
| 1f10905a03 | |||
| 88762db484 | |||
| 3b5ab0ac70 | |||
| 8903c9296b | |||
| 97858a3c94 | |||
| 0ec3e83518 | |||
| 8c007b5bf7 | |||
| 768236b0e2 | |||
| 495d78b885 | |||
| 34b1e515fe | |||
| 2ac1789312 | |||
| 79edbd3fa5 | |||
| f50d9994e2 | |||
| 1603d3085f | |||
| ccf7036f33 | |||
| a0a1a5e3c1 | |||
| fbf9120859 | |||
| 8a770beec3 | |||
| 6b31669765 | |||
| 26d72fc2d8 | |||
| 5eb56d0994 | |||
| dbc4a922d5 | |||
| 141f423842 | |||
| 667f2433ab | |||
| fd930ef548 | |||
| 7eadfb1a63 | |||
| 67cb07ac92 | |||
| 96d28c43fc | |||
| e57e3f5f0a | |||
| 7b99bd71da | |||
| 861a037321 | |||
| 84cbe6c434 | |||
| 2cbb811523 | |||
| 8ef4faa10f | |||
| f6a1c9bf52 | |||
| 5d9f6fb4f5 | |||
| 66840a8ecd | |||
| a8ee6b255a | |||
| bd73d1c533 | |||
| e33c0ebc42 | |||
| 57e4a35fee | |||
| d490b57410 | |||
| 0416602e5f | |||
| ddc27b2eb9 | |||
| 374deb147b | |||
| d2eabd1ad0 | |||
| efbc625cc3 | |||
| 91ae0b8cb0 | |||
| ddc5741b00 | |||
| 4729aca6b0 | |||
| bb4fc3a70c | |||
| 5d8084b650 | |||
| f316b892f5 | |||
| cbda1d7cd0 | |||
| 2f8e879976 | |||
| cc0ac5ae3c | |||
| 0185d24fb3 | |||
| 97dbdc9c31 | |||
| a07c66c9a3 | |||
| 308bd25bc0 | |||
| 70066a03b6 | |||
| a7f3872af3 | |||
| 22e10e675a | |||
| 89679e946d | |||
| 1d1bb9d3df | |||
| 8faf2b2595 | |||
| e47ad9700e | |||
| 372b19a057 | |||
| cbe156a868 | |||
| 181a3881e2 | |||
| 3eef03b303 | |||
| ad56e3165c | |||
| b1a96b6e75 | |||
| 56419b1b4e | |||
| 372f14a9c5 | |||
| e1ec56a120 | |||
| 5bb11249d6 | |||
| 9fbcca1ff2 | |||
| 323f2b2c3e | |||
| b971d38dd5 | |||
| 278f479a3a | |||
| 03aea5678d | |||
| b62b8ee7e6 | |||
| 63f55551e5 | |||
| b185fbc57d | |||
| ceb9d58e72 | |||
| a0bb515a4f | |||
| 2cfac2f18b | |||
| d412f538b2 | |||
| 94f90ad861 | |||
| 4a402e7937 | |||
| c226d6c391 | |||
| 67410e6c59 | |||
| 419c361147 | |||
| 3769a53ffa | |||
| ec4aaa3bfb | |||
| be52680fcd | |||
| 9d41ab9339 | |||
| f126fc3087 | |||
| 764377037c | |||
| 8e09eaab45 | |||
| 6523da186c | |||
| 6471fd8b6f | |||
| 247a74881a | |||
| 3ef09f0a5f | |||
| b924d331f9 | |||
| 14041b6012 | |||
| 2c6cc5ecec | |||
| ac022b1df0 | |||
| 0a2081de08 | |||
| 64a8e554c7 | |||
| 082d29fd2f | |||
| ba5cf9d002 | |||
| 57a55318df | |||
| e70f4f7a59 | |||
| 1d217fad67 | |||
| e95d46f085 | |||
| f4577878e1 | |||
| 1bd1e5c8e3 | |||
| c975dee965 | |||
| 9d690f4219 | |||
| 29ddb3f58d | |||
| 8626bc0b1c | |||
| c362cf6596 | |||
| 97264fc5ff | |||
| 494c4409c1 | |||
| d46e366c81 | |||
| 6afe33ee9c | |||
| 903c9e1cc3 | |||
| 3ef43fc3f5 | |||
| b1c3be05dd | |||
| efee23b4a7 | |||
| 06b67a7586 | |||
| 889a2dbf9d | |||
| 2f80fcc888 | |||
| f7ee479c1d | |||
| 94fa0981fe | |||
| 4c74afe438 | |||
| f76cea22de | |||
| 3d49110808 | |||
| 88a4579f7a | |||
| 241bde0333 | |||
| 73c7867cd6 | |||
| b35254f7ad | |||
| 213e78c956 | |||
| 7bf552c491 | |||
| 3bf9923f86 | |||
| a6a8a28f59 | |||
| 56a8e452bf | |||
| 6bec0bf70d | |||
| 5dc9c8f90e | |||
| e3290e12b1 | |||
| 9f37ce9e42 | |||
| 8904c0c811 | |||
| b0d021b7f2 | |||
| 0175f3b8a1 | |||
| 0fa9d5bf62 | |||
| 4919e38e3e | |||
| 2e99533f96 | |||
| f095645d89 | |||
| 757c83142e | |||
| 36d274ca9f | |||
| ec11b61f67 | |||
| 7765271d63 | |||
| 7c2464bba7 | |||
| 17e010f93c | |||
| 452d630a2a | |||
| f317a3e38f | |||
| f56195058e | |||
| 2e93dbb10c | |||
| f862456d73 | |||
| d99b0b2137 | |||
| 1d390f9aa7 | |||
| 514beb7940 | |||
| c7bdfce734 | |||
| e5fe4b06ad | |||
| 89b7c265d3 | |||
| 698c31943e | |||
| b70060d46e | |||
| 6ddc5ef53e | |||
| 212023c7e4 | |||
| b687f23c95 | |||
| 7a05d01554 |
@@ -32,6 +32,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
@@ -49,13 +55,15 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main-stable' }}
|
||||
type=ref,event=tag # Use the tag name as a tag for tag builds
|
||||
type=semver,pattern={{version}} # Generate semantic versioning tags for tag builds
|
||||
type=sha # Just in case none of the above applies
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
|
||||
@@ -15,9 +15,39 @@ Or fork & run on Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
|
||||
|
||||
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
|
||||
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [installation](docs/installation.md) 👉 [documentation](docs/README.md)
|
||||
|
||||
big-AGI is an open book; see the **[ready-to-ship and future ideas](https://github.com/users/enricoros/projects/4/views/2)** in our open roadmap
|
||||
> Note: bigger better features (incl. Beam-2) are being cooked outside of `main`.
|
||||
|
||||
[//]: # (big-AGI is an open book; see the **[ready-to-ship and future ideas](https://github.com/users/enricoros/projects/4/views/2)** in our open roadmap)
|
||||
|
||||
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
|
||||
|
||||
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
|
||||
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
|
||||
- 1.16.2: Proper support for Gemini models
|
||||
- 1.16.2: Added the latest Mistral model
|
||||
- 1.16.2: Tokenizer support for gpt-4o
|
||||
- 1.16.2: Updates to Beam
|
||||
- 1.16.1: Support for the new OpenAI GPT-4o 2024-05-13 model
|
||||
|
||||
### What's New in 1.16.0 · May 9, 2024 · Crystal Clear
|
||||
|
||||
- [Beam](https://big-agi.com/blog/beam-multi-model-ai-reasoning) core and UX improvements based on user feedback
|
||||
- Chat cost estimation 💰 (enable it in Labs / hover the token counter)
|
||||
- Save/load chat files with Ctrl+S / Ctrl+O on desktop
|
||||
- Major enhancements to the Auto-Diagrams tool
|
||||
- YouTube Transcriber Persona for chatting with video content, [#500](https://github.com/enricoros/big-AGI/pull/500)
|
||||
- Improved formula rendering (LaTeX), and dark-mode diagrams, [#508](https://github.com/enricoros/big-AGI/issues/508), [#520](https://github.com/enricoros/big-AGI/issues/520)
|
||||
- Models update: **Anthropic**, **Groq**, **Ollama**, **OpenAI**, **OpenRouter**, **Perplexity**
|
||||
- Code soft-wrap, chat text selection toolbar, 3x faster on Apple silicon, and more [#517](https://github.com/enricoros/big-AGI/issues/517), [507](https://github.com/enricoros/big-AGI/pull/507)
|
||||
|
||||
#### 3,000 Commits Milestone · April 7, 2024
|
||||
|
||||

|
||||
|
||||
- 🥇 Today we <b>celebrate commit 3000</b> in just over one year, and going stronger 🚀
|
||||
- 📢️ Thanks everyone for your support and words of love for Big-AGI, we are committed to creating the best AI experiences for everyone.
|
||||
|
||||
### What's New in 1.15.0 · April 1, 2024 · Beam
|
||||
|
||||
@@ -26,9 +56,11 @@ big-AGI is an open book; see the **[ready-to-ship and future ideas](https://gith
|
||||
- Message **Starring ⭐**: star important messages within chats, to attach them later. [#476](https://github.com/enricoros/big-AGI/issues/476)
|
||||
- Enhanced the default Persona
|
||||
- Fixes to Gemini models and SVGs, improvements to UI and icons
|
||||
- 1.15.1: Support for Gemini Pro 1.5 and OpenAI Turbo models
|
||||
- Beast release, over 430 commits, 10,000+ lines changed: [release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.15.0), and changes [v1.14.1...v1.15.0](https://github.com/enricoros/big-AGI/compare/v1.14.1...v1.15.0)
|
||||
|
||||
### What's New in 1.14.1 · March 7, 2024 · Modelmorphic
|
||||
<details>
|
||||
<summary>What's New in 1.14.1 · March 7, 2024 · Modelmorphic</summary>
|
||||
|
||||
- **Anthropic** [Claude-3](https://www.anthropic.com/news/claude-3-family) model family support. [#443](https://github.com/enricoros/big-AGI/issues/443)
|
||||
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
|
||||
@@ -38,7 +70,10 @@ big-AGI is an open book; see the **[ready-to-ship and future ideas](https://gith
|
||||
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
|
||||
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
|
||||
|
||||
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
|
||||
|
||||
@@ -50,6 +85,8 @@ https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385
|
||||
- Better looking chats with improved spacing, fonts, and menus
|
||||
- More: new video player, [LM Studio tutorial](https://github.com/enricoros/big-AGI/blob/main/docs/config-local-lmstudio.md) (thanks @aj47), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/deploy-database.md) (thanks @ranfysvalle02), and speedups
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline</summary>
|
||||
|
||||
@@ -106,7 +143,7 @@ You can easily configure 100s of AI models in big-AGI:
|
||||
|
||||
| **AI models** | _supported vendors_ |
|
||||
|:--------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Opensource Servers | [LocalAI](https://localai.com) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
|
||||
| Opensource Servers | [LocalAI](https://localai.io/) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
|
||||
| Local Servers | [LM Studio](https://lmstudio.ai/) |
|
||||
| Multimodal services | [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
|
||||
| Language services | [Anthropic](https://anthropic.com) · [Groq](https://wow.groq.com/) · [Mistral](https://mistral.ai/) · [OpenRouter](https://openrouter.ai/) · [Perplexity](https://www.perplexity.ai/) · [Together AI](https://www.together.ai/) |
|
||||
@@ -141,6 +178,22 @@ Add extra functionality with these integrations:
|
||||
|
||||
<br/>
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
To get started with big-AGI, follow our comprehensive [Installation Guide](docs/installation.md).
|
||||
The guide covers various installation options, whether you're spinning it up on
|
||||
your local computer, deploying on Vercel, on Cloudflare, or rolling it out
|
||||
through Docker.
|
||||
|
||||
Whether you're a developer, system integrator, or enterprise user, you'll find step-by-step instructions
|
||||
to set up big-AGI quickly and easily.
|
||||
|
||||
[](docs/installation.md)
|
||||
|
||||
Or bring your API keys and jump straight into our free instance on [big-AGI.com](https://big-agi.com).
|
||||
|
||||
<br/>
|
||||
|
||||
# 🌟 Get Involved!
|
||||
|
||||
[//]: # ([](https://discord.gg/MkH4qj2Jp9))
|
||||
@@ -150,86 +203,10 @@ Add extra functionality with these integrations:
|
||||
- [ ] ⭐ **Give us a star** on GitHub 👆
|
||||
- [ ] 🚀 **Do you like code**? You'll love this gem of a project! [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
|
||||
- [ ] 💡 Got a feature suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
|
||||
- [ ] ✨ Deploy your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
|
||||
- [ ] Check out some of the big-AGI [**community projects**](docs/customizations.md)
|
||||
|
||||
| Project | Features | GitHub |
|
||||
|---------|----------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| CoolAGI | Code Interpreter, Vision, Mind maps, and much more | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
|
||||
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
|
||||
- [ ] ✨ [Deploy](docs/installation.md) your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
|
||||
|
||||
<br/>
|
||||
|
||||
# 🧩 Develop
|
||||
|
||||
[//]: # ()
|
||||
|
||||
[//]: # ()
|
||||
|
||||
[//]: # ()
|
||||
|
||||
To download and run this Typescript/React/Next.js project locally, the only prerequisite is Node.js with the `npm` package manager.
|
||||
Clone this repo, install the dependencies (all local), and run the development server (which auto-watches the
|
||||
files for changes):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/enricoros/big-agi.git
|
||||
cd big-agi
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# You will see something like:
|
||||
#
|
||||
# ▲ Next.js 14.1.0
|
||||
# - Local: http://localhost:3000
|
||||
# ✓ Ready in 2.6s
|
||||
```
|
||||
|
||||
The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring
|
||||
a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions.
|
||||
|
||||
## 🛠️ Deploy from source
|
||||
|
||||
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
|
||||
after installing the required dependencies.
|
||||
|
||||
```bash
|
||||
# .. repeat the steps above up to `npm install`, then:
|
||||
npm run build
|
||||
next start --port 3000
|
||||
```
|
||||
|
||||
The app will be running on the specified port, e.g. `http://localhost:3000`.
|
||||
|
||||
Want to deploy with username/password? See the [Authentication](docs/deploy-authentication.md) guide.
|
||||
|
||||
## 🐳 Deploy with Docker
|
||||
|
||||
For more detailed information on deploying with Docker, please refer to the [docker deployment documentation](docs/deploy-docker.md).
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker build -t big-agi .
|
||||
docker run -d -p 3000:3000 big-agi
|
||||
```
|
||||
|
||||
Or run the official container:
|
||||
|
||||
- manually: `docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi`
|
||||
- or, with docker-compose: `docker-compose up` or see [the documentation](docs/deploy-docker.md) for a composer file with integrated browsing
|
||||
|
||||
## ☁️ Deploy on Cloudflare Pages
|
||||
|
||||
Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare.md).
|
||||
|
||||
## 🚀 Deploy on Vercel
|
||||
|
||||
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
|
||||
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/stargazers))
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/network))
|
||||
|
||||
@@ -5,13 +5,13 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerEdgeRoutes = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
router: appRouterEdge,
|
||||
endpoint: '/api/trpc-edge',
|
||||
router: appRouterEdge,
|
||||
req,
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? "<no-path>"}: ${error.message}`)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? 'unk-path'}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,19 +5,21 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerNodeRoutes = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
router: appRouterNode,
|
||||
endpoint: '/api/trpc-node',
|
||||
router: appRouterNode,
|
||||
req,
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}: ${error.message}`)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? 'unk-path'}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// NOTE: the following statement breaks the build on non-pro deployments, and conditionals don't work either
|
||||
// so we resorted to raising the timeout from 10s to 25s in the vercel.json file instead
|
||||
// export const maxDuration = 25;
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
|
||||
+8
-14
@@ -37,22 +37,16 @@ System integrators, administrators, whitelabelers: instead of using the public b
|
||||
|
||||
Step-by-step deployment and system configuration instructions.
|
||||
|
||||
- **Deploy Your Own**
|
||||
- straightforward: **Local development**, **Vercel 1-Click**
|
||||
- **[Cloudflare Deployment](deploy-cloudflare.md)**
|
||||
- **[Docker Deployment](deploy-docker.md)**: Containers for Local or Cloud deployments
|
||||
- **[Installation](installation.md)**: Set up your own instance of big-AGI and related products
|
||||
- build from source or use pre-built
|
||||
- locally, in the public cloud, or on your own servers
|
||||
|
||||
|
||||
- **Deployment Server Features**
|
||||
- **[Database Setup](deploy-database.md)**: Optional, only required to enable "Chat Link Sharing"
|
||||
- **[Environment Variables](environment-variables.md)**: 📌 Set server-side API keys and special features in your deployments
|
||||
- **[HTTP Basic Authentication](deploy-authentication.md)**: Optional, Secure your big-AGI instance with a username and password
|
||||
|
||||
## Customization & Derivative UIs
|
||||
|
||||
👏 Customize big-AGI to fit your needs.
|
||||
|
||||
- **[Customizing big-AGI](customizations.md)**: how to alter source code and server-side configuration
|
||||
- **Advanced Customizations**:
|
||||
- **[Source code alterations guide](customizations.md)**: source code primer and alterations guidelines
|
||||
- **[Basic Authentication](deploy-authentication.md)**: Optional, adds a username and password wall
|
||||
- **[Database Setup](deploy-database.md)**: Optional, enables "Chat Link Sharing"
|
||||
- **[Environment Variables](environment-variables.md)**: 📌 Pre-configures models and services
|
||||
|
||||
## Support and Community
|
||||
|
||||
|
||||
+30
-2
@@ -5,11 +5,39 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.16.0 - Mar 2024
|
||||
### 1.17.0 - Jun 2024
|
||||
|
||||
- milestone: [1.16.0](https://github.com/enricoros/big-agi/milestone/16)
|
||||
- milestone: [1.17.0](https://github.com/enricoros/big-agi/milestone/17)
|
||||
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
|
||||
|
||||
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
|
||||
|
||||
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
|
||||
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
|
||||
- 1.16.2: Proper support for Gemini models
|
||||
- 1.16.2: Added the latest Mistral model
|
||||
- 1.16.2: Tokenizer support for gpt-4o
|
||||
- 1.16.2: Updates to Beam
|
||||
- 1.16.1: Support for the new OpenAI GPT-4o 2024-05-13 model
|
||||
|
||||
### What's New in 1.16.0 · May 9, 2024 · Crystal Clear
|
||||
|
||||
- [Beam](https://big-agi.com/blog/beam-multi-model-ai-reasoning) core and UX improvements based on user feedback
|
||||
- Chat cost estimation 💰 (enable it in Labs / hover the token counter)
|
||||
- Save/load chat files with Ctrl+S / Ctrl+O on desktop
|
||||
- Major enhancements to the Auto-Diagrams tool
|
||||
- YouTube Transcriber Persona for chatting with video content, [#500](https://github.com/enricoros/big-AGI/pull/500)
|
||||
- Improved formula rendering (LaTeX), and dark-mode diagrams, [#508](https://github.com/enricoros/big-AGI/issues/508), [#520](https://github.com/enricoros/big-AGI/issues/520)
|
||||
- Models update: **Anthropic**, **Groq**, **Ollama**, **OpenAI**, **OpenRouter**, **Perplexity**
|
||||
- Code soft-wrap, chat text selection toolbar, 3x faster on Apple silicon, and more [#517](https://github.com/enricoros/big-AGI/issues/517), [507](https://github.com/enricoros/big-AGI/pull/507)
|
||||
- Developers: update the LLMs data structures
|
||||
|
||||
### What's New in 1.15.1 · April 10, 2024 (minor release, models support)
|
||||
|
||||
- Support for the newly released Gemini Pro 1.5 models
|
||||
- Support for the new OpenAI 2024-04-09 Turbo models
|
||||
- Resilience fixes after the large success of 1.15.0
|
||||
|
||||
### What's New in 1.15.0 · April 1, 2024 · Beam
|
||||
|
||||
- ⚠️ [**Beam**: the multi-model AI chat](https://big-agi.com/blog/beam-multi-model-ai-reasoning). find better answers, faster - a game-changer for brainstorming, decision-making, and creativity. [#443](https://github.com/enricoros/big-AGI/issues/443)
|
||||
|
||||
@@ -20,6 +20,9 @@ If you have an `API Endpoint` and `API Key`, you can configure big-AGI as follow
|
||||
The deployed models are now available in the application. If you don't have a configured
|
||||
Azure OpenAI service instance, continue with the next section.
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
|
||||
## Setting Up Azure
|
||||
|
||||
### Step 1: Azure Account & Subscription
|
||||
|
||||
@@ -68,7 +68,7 @@ The chat agent won't be able to access the web sites if the browserless containe
|
||||
- MAX_CONCURRENT_SESSIONS=10
|
||||
```
|
||||
|
||||
You can then add the proyy lines to your `.env` file.
|
||||
You can then add the proxy lines to your `.env` file.
|
||||
|
||||
```
|
||||
https_proxy=http://PROXY-IP:PROXY-PORT
|
||||
@@ -115,4 +115,4 @@ If you encounter any issues or have questions about configuring the browse funct
|
||||
|
||||
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
|
||||
|
||||
Last updated on Feb 27, 2024 ([edit on GitHub](https://github.com/enricoros/big-AGI/edit/main/docs/config-feature-browse.md))
|
||||
Last updated on Feb 27, 2024 ([edit on GitHub](https://github.com/enricoros/big-AGI/edit/main/docs/config-feature-browse.md))
|
||||
|
||||
@@ -37,6 +37,9 @@ Check the URL and modify if different.
|
||||
2. Enter the API URL: `http://localhost:1234` (modify if different)
|
||||
3. Refresh by clicking on the `Models` button to load models from LM Studio
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Missing @mui/material**: Execute `npm install @mui/material` or `yarn add @mui/material`
|
||||
|
||||
@@ -36,6 +36,9 @@ Follow the guide at: https://localai.io/basics/getting_started/
|
||||
- Load the models (click on `Models 🔄`)
|
||||
- Select the model and chat
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
|
||||
### Integration: Models Gallery
|
||||
|
||||
If the running LocalAI instance is configured with a [Model Gallery](https://localai.io/models/):
|
||||
|
||||
@@ -13,7 +13,7 @@ _Last updated Dec 16, 2023_
|
||||
|
||||
1. **Ensure Ollama API Server is Running**: Follow the official instructions to get Ollama up and running on your machine
|
||||
- For detailed instructions on setting up the Ollama API server, please refer to the
|
||||
[Ollama download page](https://ollama.ai/download) and [instructions for linux](https://github.com/jmorganca/ollama/blob/main/docs/linux.md).
|
||||
[Ollama download page](https://ollama.ai/download) and [instructions for linux](https://github.com/jmorganca/ollama/blob/main/docs/linux.md).
|
||||
2. **Add Ollama as a Model Source**: In `big-AGI`, navigate to the **Models** section, select **Add a model source**, and choose **Ollama**
|
||||
3. **Enter Ollama Host URL**: Provide the Ollama Host URL where the API server is accessible (e.g., `http://localhost:11434`)
|
||||
4. **Refresh Model List**: Once connected, refresh the list of available models to include the Ollama models
|
||||
@@ -22,6 +22,9 @@ _Last updated Dec 16, 2023_
|
||||
you'll have to press the 'Pull' button again, until a green message appears.
|
||||
5. **Chat with Ollama models**: select an Ollama model and begin chatting with AI personas
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
|
||||
**Visual Configuration Guide**:
|
||||
|
||||
* After adding the `Ollama` model vendor, entering the IP address of an Ollama server, and refreshing models:<br/>
|
||||
@@ -37,7 +40,7 @@ _Last updated Dec 16, 2023_
|
||||
|
||||
### ⚠️ Network Troubleshooting
|
||||
|
||||
If you get errors about the server having trouble connecting with Ollama, please see
|
||||
If you get errors about the server having trouble connecting with Ollama, please see
|
||||
[this message](https://github.com/enricoros/big-AGI/issues/276#issuecomment-1858591483) on Issue #276.
|
||||
|
||||
And in brief, make sure the Ollama endpoint is accessible from the servers where you run big-AGI (which could
|
||||
@@ -69,15 +72,19 @@ Then, edit the nginx configuration file `/etc/nginx/sites-enabled/default` and a
|
||||
|
||||
```nginx
|
||||
location /ollama/ {
|
||||
proxy_pass http://localhost:11434;
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
|
||||
# Disable buffering for the streaming responses (SSE)
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Disable buffering for the streaming responses
|
||||
chunked_transfer_encoding off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Longer timeouts
|
||||
proxy_read_timeout 3600;
|
||||
proxy_connect_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -25,15 +25,15 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
|
||||
- Stop the Web UI as we need to modify the startup flags to enable the OpenAI API
|
||||
2. Enable the **openai extension**
|
||||
- Edit `CMD_FLAGS.txt`
|
||||
- Make sure that `--listen --api` is present and uncommented
|
||||
- Make sure that `--listen --api` is present and uncommented
|
||||
3. Restart text-generation-webui
|
||||
- Double-click on "start"
|
||||
- You should see something like:
|
||||
- You should see something like:
|
||||
```
|
||||
2023-12-07 21:51:21 INFO:Loading the extension "openai"...
|
||||
2023-12-07 21:51:21 INFO:OpenAI-compatible API URL:
|
||||
|
||||
http://0.0.0.0:5000
|
||||
|
||||
http://0.0.0.0:5000
|
||||
...
|
||||
INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)
|
||||
Running on local URL: http://0.0.0.0:7860
|
||||
|
||||
@@ -22,6 +22,9 @@ This document details the process of integrating OpenRouter with big-AGI.
|
||||

|
||||
4. OpenAI GPT4-32k and other models will now be accessible and selectable in the application.
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
|
||||
### Pricing
|
||||
|
||||
OpenRouter independently manages its service and pricing and is not affiliated with big-AGI.
|
||||
|
||||
@@ -66,7 +66,7 @@ Test your application thoroughly using local development (refer to README.md for
|
||||
|
||||
We introduced the `/info/debug` page that provides a detailed overview of the application's environment, including the API keys, environment variables, and other configuration settings.
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
## Community Projects - Share Your Project
|
||||
|
||||
@@ -74,12 +74,12 @@ After deployment, share your project with the community. We will link to your pr
|
||||
|
||||
| Project | Features | GitHub |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| 🚀 CoolAGI: Where AI meets Imagination<br/> | Code Interpreter, Vision, Mind maps, Web Searches, Advanced Data Analytics, Large Data Handling and more! | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
|
||||
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
|
||||
| 🚀 CoolAGI: Where AI meets Imagination<br/> | Code Interpreter, Vision, Mind maps, Web Searches, Advanced Data Analytics, Large Data Handling and more! | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
|
||||
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
|
||||
|
||||
For public projects, update your README.md with your modifications and submit a pull request to add your project to our list, aiding in its discovery.
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ As of Feb 27, 2024, this feature is in development.
|
||||
|
||||
## Configurations
|
||||
|
||||
| Scope | Default | Description / Instructions |
|
||||
| Scope | Default | Description / Instructions |
|
||||
|-----------------------------------------------------------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------------|
|
||||
| Your source builds of big-AGI | None | **Vercel**: enable Vercel Analytics from the dashboard. · **Google Analytics**: set environment variable at build time. |
|
||||
| Your docker builds of big-AGI | None | **Vercel**: n/a. · **Google Analytics**: set environment variable at `docker build` time. |
|
||||
|
||||
@@ -9,7 +9,7 @@ Docker ensures faster development cycles, easier collaboration, and seamless env
|
||||
```bash
|
||||
git clone https://github.com/enricoros/big-agi.git
|
||||
cd big-agi
|
||||
```
|
||||
```
|
||||
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile:
|
||||
```bash
|
||||
docker build -t big-agi .
|
||||
|
||||
@@ -91,7 +91,7 @@ requiring the user to enter an API key
|
||||
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, to enable platforms such as [config-aws-bedrock.md](config-aws-bedrock.md) | Optional |
|
||||
| `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional |
|
||||
| `GROQ_API_KEY` | The API key for Groq Cloud | Optional |
|
||||
| `LOCALAI_API_HOST` | Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080 | Optional |
|
||||
| `LOCALAI_API_HOST` | Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080 | Optional |
|
||||
| `LOCALAI_API_KEY` | The (Optional) API key for LocalAI | Optional |
|
||||
| `MISTRAL_API_KEY` | The API key for Mistral | Optional |
|
||||
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-local-ollama.md](config-local-ollama) | |
|
||||
@@ -128,7 +128,7 @@ Enable the app to Talk, Draw, and Google things up.
|
||||
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
|
||||
| **Browse** | |
|
||||
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing (pade downloadeing), etc. |
|
||||
| **Backend** | |
|
||||
| **Backend** | |
|
||||
| `BACKEND_ANALYTICS` | Semicolon-separated list of analytics flags (see backend.analytics.ts). Flags: `domain` logs the responding domain. |
|
||||
| `HTTP_BASIC_AUTH_USERNAME` | See the [Authentication](deploy-authentication.md) guide. Username for HTTP Basic Authentication. |
|
||||
| `HTTP_BASIC_AUTH_PASSWORD` | Password for HTTP Basic Authentication. |
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Installation Guide
|
||||
|
||||
Welcome to the big-AGI Installation Guide - Whether you're a developer
|
||||
eager to explore, a system integrator, or an enterprise looking for a
|
||||
white-label solution, this comprehensive guide ensures a smooth setup
|
||||
process for your own instance of big-AGI and related products.
|
||||
|
||||
**Try big-AGI** - You don't need to install anything if you want to play with big-AGI
|
||||
and have your API keys to various model services. You can access our free instance on [big-AGI.com](https://big-agi.com).
|
||||
The free instance runs the latest `main-stable` branch from this repository.
|
||||
|
||||
## 🧩 Build-your-own
|
||||
|
||||
If you want to change the code, have a deeper configuration,
|
||||
add your own models, or run your own instance, follow the steps below.
|
||||
|
||||
### Local Development
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Node.js and npm installed on your machine.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clone the big-AGI repository:
|
||||
```bash
|
||||
git clone https://github.com/enricoros/big-AGI.git
|
||||
cd big-AGI
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Your big-AGI instance is now running at `http://localhost:3000`.
|
||||
|
||||
### Local Production build
|
||||
|
||||
The production build is optimized for performance and follows
|
||||
the same steps 1 and 2 as for [local development](#local-development).
|
||||
|
||||
3. Build the production version:
|
||||
```bash
|
||||
# .. repeat the steps above up to `npm install`, then:
|
||||
npm run build
|
||||
```
|
||||
4. Start the production server (`npx` may be optional):
|
||||
```bash
|
||||
npx next start --port 3000
|
||||
```
|
||||
Your big-AGI production instance is on `http://localhost:3000`.
|
||||
|
||||
### Advanced Customization
|
||||
|
||||
Want to pre-enable models, customize the interface, or deploy with username/password or alter code to your needs?
|
||||
Check out the [Customizations Guide](README.md) for detailed instructions.
|
||||
|
||||
## ☁️ Cloud Deployment Options
|
||||
|
||||
To deploy big-AGI on a public server, you have several options. Choose the one that best fits your needs.
|
||||
|
||||
### Deploy on Vercel
|
||||
|
||||
Install big-AGI on Vercel with just a few clicks.
|
||||
|
||||
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
|
||||
|
||||
### Deploy on Cloudflare
|
||||
|
||||
Deploy on Cloudflare's global network by installing big-AGI on
|
||||
Cloudflare Pages. Check out the [Cloudflare Installation Guide](deploy-cloudflare.md)
|
||||
for step-by-step instructions.
|
||||
|
||||
### Docker Deployments
|
||||
|
||||
Containerize your big-AGI installation using Docker for portability and scalability.
|
||||
Our [Docker Deployment Guide](deploy-docker.md) will walk you through the process,
|
||||
or follow the steps below for a quick start.
|
||||
|
||||
1. (optional) Build the Docker image - if you do not want to use the [pre-built Docker images](https://github.com/enricoros/big-AGI/pkgs/container/big-agi):
|
||||
```bash
|
||||
docker build -t big-agi .
|
||||
```
|
||||
2. Run the Docker container with either:
|
||||
```bash
|
||||
# 2A. if you built the image yourself:
|
||||
docker run -d -p 3000:3000 big-agi
|
||||
|
||||
# 2B. or use the pre-built image:
|
||||
docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi
|
||||
|
||||
# 2C. or use docker-compose:
|
||||
docker-compose up
|
||||
```
|
||||
Access your big-AGI instance at `http://localhost:3000`.
|
||||
|
||||
### Midori AI Subsystem for Docker Deployment
|
||||
|
||||
Follow the instructions found on [Midori AI Subsystem Site](https://io.midori-ai.xyz/subsystem/manager/)
|
||||
for your host OS. After completing the setup process, install the Big-AGI docker backend to the Midori AI Subsystem.
|
||||
|
||||
## Enterprise-Grade Installation
|
||||
|
||||
For businesses seeking a fully-managed, scalable solution, consider our managed installations.
|
||||
Enjoy all the features of big-AGI without the hassle of infrastructure management. [hello@big-agi.com](mailto:hello@big-agi.com) to learn more.
|
||||
|
||||
## Support
|
||||
|
||||
Join our vibrant community of developers, researchers, and AI enthusiasts. Share your projects, get help, and collaborate with others.
|
||||
|
||||
- [Discord Community](https://discord.gg/MkH4qj2Jp9)
|
||||
- [Twitter](https://twitter.com/yourusername)
|
||||
|
||||
For any questions or inquiries, please don't hesitate to [reach out to our team](mailto:hello@big-agi.com).
|
||||
@@ -0,0 +1,5 @@
|
||||
From root:
|
||||
```bash
|
||||
BIG_AGI_BUILD=standalone next build
|
||||
electron . --enable-logging
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
background: #2e2c29;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 5px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 5px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: url('tray-icon.png') no-repeat center center;
|
||||
background-size: contain;
|
||||
animation: counter-spin 3.33s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes counter-spin {
|
||||
0% { transform: rotate(360deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader-container">
|
||||
<div class="spinner"></div>
|
||||
<div class="logo"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,178 @@
|
||||
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeTheme, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const startServer = require('./server.js');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
const port = 3000;
|
||||
|
||||
async function createWindow() {
|
||||
try {
|
||||
console.log('Starting server...');
|
||||
await startServer(port);
|
||||
console.log('Server started successfully');
|
||||
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
// // Set up a loading screen
|
||||
// loadingScreen = new BrowserWindow({
|
||||
// // width: 150,
|
||||
// // height: 150,
|
||||
// frame: false,
|
||||
// transparent: false,
|
||||
// alwaysOnTop: true,
|
||||
// webPreferences: {
|
||||
// nodeIntegration: true,
|
||||
// },
|
||||
// backgroundColor: '#2e2c29',
|
||||
// });
|
||||
//
|
||||
// loadingScreen.loadFile(path.join(__dirname, 'loading.html'));
|
||||
// loadingScreen.center();
|
||||
// console.log('Loading screen created');
|
||||
|
||||
console.log('Preload script path:', path.join(__dirname, 'preload.js'));
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: Math.min(1280, width * 0.8),
|
||||
height: Math.min(800, height * 0.8),
|
||||
minWidth: 430,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
sandbox: false,
|
||||
devTools: false,
|
||||
},
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff',
|
||||
show: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
icon: path.join(__dirname, 'tray-icon.png'),
|
||||
// New "insane" features:
|
||||
// transparent: true, // Enable window transparency
|
||||
vibrancy: 'under-window', // Add vibrancy effect (macOS only)
|
||||
visualEffectState: 'active', // Keep vibrancy active even when not focused (macOS only)
|
||||
roundedCorners: true, // Enable rounded corners (macOS only)
|
||||
// thickFrame: false, // Use a thinner frame on Windows
|
||||
autoHideMenuBar: true, // Auto-hide the menu bar, press Alt to show it
|
||||
scrollBounce: true, // Enable bounce effect when scrolling (macOS only)
|
||||
});
|
||||
|
||||
mainWindow.removeMenu();
|
||||
mainWindow.setTitle('Your Professional App Name');
|
||||
|
||||
console.log('Attempting to load main window URL...');
|
||||
await mainWindow.loadURL(`http://localhost:${port}`);
|
||||
console.log('Main window URL loaded successfully');
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
console.log('Main window ready to show');
|
||||
// if (loadingScreen) {
|
||||
// loadingScreen.close();
|
||||
// }
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
createTray();
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
|
||||
// Handle window state
|
||||
let isQuitting = false;
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
// Adjust window behavior
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow.webContents.send('window-maximized');
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow.webContents.send('window-unmaximized');
|
||||
});
|
||||
|
||||
|
||||
// Warn if preloads fail
|
||||
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
|
||||
console.error('Preload error:', preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
|
||||
console.error('Failed to load:', errorCode, errorDescription);
|
||||
});
|
||||
|
||||
|
||||
// Handle external links
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in createWindow:', err);
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(path.join(__dirname, 'tray-icon.png'));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Show App', click: () => mainWindow.show() },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() },
|
||||
]);
|
||||
tray.setToolTip('Your Professional App Name');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on('click', () => {
|
||||
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
console.log('App is ready, creating window...');
|
||||
createWindow().catch((err) => {
|
||||
console.error('Failed to create window:', err);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', function() {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', function() {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// IPC handlers for window controls
|
||||
ipcMain.on('minimize-window', () => mainWindow.minimize());
|
||||
ipcMain.on('maximize-window', () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on('close-window', () => mainWindow.close());
|
||||
|
||||
|
||||
// Auto-updater events
|
||||
autoUpdater.on('update-available', () => {
|
||||
mainWindow.webContents.send('update_available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
mainWindow.webContents.send('update_downloaded');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const { contextBridge, desktopCapturer, ipcRenderer } = require('electron');
|
||||
const { readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Main bridge
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
sendEvent: (event) => ipcRenderer.send('app-event', event),
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on('update_available', callback),
|
||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update_downloaded', callback),
|
||||
});
|
||||
|
||||
|
||||
// Screen Capture: inject renderer.js into the web page
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('Screen Capture: Injecting renderer.js into the web page');
|
||||
const rendererScript = document.createElement('script');
|
||||
rendererScript.text = readFileSync(join(__dirname, 'renderer.js'), 'utf8');
|
||||
document.body.appendChild(rendererScript);
|
||||
});
|
||||
|
||||
// Screen Capture: expose desktopCapturer to the web page
|
||||
contextBridge.exposeInMainWorld('myCustomGetDisplayMedia', async () => {
|
||||
console.log('Screen Capture: Calling desktopCapturer.getSources');
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
});
|
||||
|
||||
console.log('Available sources:', sources);
|
||||
|
||||
// you should create some kind of UI to prompt the user
|
||||
// to select the correct source like Google Chrome does
|
||||
// this is just for testing purposes
|
||||
return sources[0];
|
||||
});
|
||||
|
||||
console.log('Preload script loaded');
|
||||
@@ -0,0 +1,30 @@
|
||||
// https://github.com/aabuhijleh/override-getDisplayMedia/blob/main/renderer.js
|
||||
|
||||
// This file is required by the index.html file and will
|
||||
// be executed in the renderer process for that window.
|
||||
// No Node.js APIs are available in this process because
|
||||
// `nodeIntegration` is turned off. Use `preload.js` to
|
||||
// selectively enable features needed in the rendering
|
||||
// process.
|
||||
|
||||
// override getDisplayMedia
|
||||
navigator.mediaDevices.getDisplayMedia = async () => {
|
||||
const selectedSource = await globalThis.myCustomGetDisplayMedia();
|
||||
|
||||
// create MediaStream
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
minWidth: 1280,
|
||||
maxWidth: 1280,
|
||||
minHeight: 720,
|
||||
maxHeight: 720,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const path = require('path');
|
||||
|
||||
// const dev = process.env.NODE_ENV !== 'production';
|
||||
const dir = path.join(__dirname, '..'); // This points to the root of your project
|
||||
const app = next({ dev: false, dir });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
function startServer(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
app.prepare()
|
||||
.then(() => {
|
||||
const server = createServer((req, res) => {
|
||||
// Basic request logging
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||
|
||||
// Simple rate limiting
|
||||
if (rateLimiter(req)) {
|
||||
res.statusCode = 429;
|
||||
res.end('Too Many Requests');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
});
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) reject(err);
|
||||
console.log(`> Ready on http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(err => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter
|
||||
const MAX_REQUESTS_PER_MINUTE = 100;
|
||||
const requestCounts = new Map();
|
||||
|
||||
function rateLimiter(req) {
|
||||
const ip = req.socket.remoteAddress;
|
||||
const now = Date.now();
|
||||
const windowStart = now - 60000; // 1 minute ago
|
||||
|
||||
const requestTimestamps = requestCounts.get(ip) || [];
|
||||
const requestsInWindow = requestTimestamps.filter(timestamp => timestamp > windowStart);
|
||||
|
||||
if (requestsInWindow.length >= MAX_REQUESTS_PER_MINUTE) {
|
||||
return true; // Rate limit exceeded
|
||||
}
|
||||
|
||||
requestTimestamps.push(now);
|
||||
requestCounts.set(ip, requestTimestamps);
|
||||
|
||||
return false; // Rate limit not exceeded
|
||||
}
|
||||
|
||||
module.exports = startServer;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 993 B |
+11
-3
@@ -13,7 +13,7 @@ let nextConfig = {
|
||||
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
|
||||
...buildType && {
|
||||
output: buildType,
|
||||
distDir: 'dist',
|
||||
// distDir: 'dist',
|
||||
|
||||
// disable image optimization for exports
|
||||
images: { unoptimized: true },
|
||||
@@ -27,7 +27,7 @@ let nextConfig = {
|
||||
serverComponentsExternalPackages: ['puppeteer-core'],
|
||||
},
|
||||
|
||||
webpack: (config, _options) => {
|
||||
webpack: (config, { isServer }) => {
|
||||
// @mui/joy: anything material gets redirected to Joy
|
||||
config.resolve.alias['@mui/material'] = '@mui/joy';
|
||||
|
||||
@@ -37,9 +37,17 @@ let nextConfig = {
|
||||
layers: true,
|
||||
};
|
||||
|
||||
// fix warnings for async functions in the browser (https://github.com/vercel/next.js/issues/64792)
|
||||
if (!isServer) {
|
||||
config.output.environment = { ...config.output.environment, asyncFunction: true };
|
||||
}
|
||||
|
||||
// prevent too many small chunks (40kb min) on 'client' packs (not 'server' or 'edge-server')
|
||||
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize)
|
||||
// noinspection JSUnresolvedReference
|
||||
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize) {
|
||||
// noinspection JSUnresolvedReference
|
||||
config.optimization.splitChunks.minSize = 40 * 1024;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
Generated
+4992
-876
File diff suppressed because it is too large
Load Diff
+73
-41
@@ -1,18 +1,23 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "node electron/server.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production node electron/server.js",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local",
|
||||
"electron": "electron .",
|
||||
"electron-dev": "concurrently \"npm run dev\" \"electron .\"",
|
||||
"electron-build": "next build && electron-builder",
|
||||
"electron-start": "npm run build && electron ."
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/server/prisma/schema.prisma"
|
||||
@@ -21,67 +26,94 @@
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@mui/joy": "^5.0.0-beta.32",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@next/third-parties": "^14.1.4",
|
||||
"@prisma/client": "^5.11.0",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/icons-material": "^5.16.0",
|
||||
"@mui/joy": "^5.0.0-beta.47",
|
||||
"@mui/material": "^5.16.0",
|
||||
"@next/bundle-analyzer": "^14.2.4",
|
||||
"@next/third-parties": "^14.2.4",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.9.2",
|
||||
"@tanstack/react-query": "~4.36.1",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/next": "10.44.1",
|
||||
"@trpc/react-query": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"@vercel/speed-insights": "^1.0.10",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.50.1",
|
||||
"@trpc/client": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/next": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/react-query": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/server": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/speed-insights": "^1.0.12",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dexie": "^4.0.7",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"electron-updater": "^6.2.1",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"next": "^14.1.4",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "~14.2.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "4.0.379",
|
||||
"pdfjs-dist": "4.4.168",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-player": "^2.15.1",
|
||||
"react-resizable-panels": "^2.0.13",
|
||||
"react-player": "^2.16.0",
|
||||
"react-resizable-panels": "^2.0.20",
|
||||
"react-timeago": "^7.2.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sharp": "^0.33.2",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.33.4",
|
||||
"superjson": "^2.2.1",
|
||||
"tesseract.js": "^5.0.5",
|
||||
"tiktoken": "^1.0.13",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
"tesseract.js": "^5.1.0",
|
||||
"tiktoken": "^1.0.15",
|
||||
"turndown": "^7.2.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/puppeteer": "0.0.5",
|
||||
"@types/node": "^20.11.30",
|
||||
"@cloudflare/puppeteer": "0.0.11",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/turndown": "^5.0.4",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^31.1.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.11.0",
|
||||
"typescript": "^5.4.3"
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"prettier": "^3.3.2",
|
||||
"prisma": "^5.16.1",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.yourcompany.yourappname",
|
||||
"productName": "Your App Name",
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
".next/**/*",
|
||||
"public/**/*",
|
||||
"next.config.js"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "electron"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'katex/dist/katex.min.css';
|
||||
import '~/common/styles/CodePrism.css';
|
||||
import '~/common/styles/GithubMarkdown.css';
|
||||
import '~/common/styles/NProgress.css';
|
||||
import '~/common/styles/agi.effects.css';
|
||||
import '~/common/styles/app.styles.css';
|
||||
|
||||
import { ProviderBackendCapabilities } from '~/common/providers/ProviderBackendCapabilities';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Brand } from '~/common/app.config';
|
||||
import { ROUTE_APP_CHAT, ROUTE_INDEX } from '~/common/app.routes';
|
||||
|
||||
// apps access
|
||||
import { incrementalNewsVersion } from '../../src/apps/news/news.version';
|
||||
import { incrementalNewsVersion, useAppNewsStateStore } from '../../src/apps/news/news.version';
|
||||
|
||||
// capabilities access
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities';
|
||||
@@ -25,13 +25,14 @@ import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapa
|
||||
// stores access
|
||||
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
// utils access
|
||||
import { clientHostName, isChromeDesktop, isFirefox, isIPhoneUser, isMacUser, isPwa, isVercelFromFrontend } from '~/common/util/pwaUtils';
|
||||
import { getGA4MeasurementId } from '~/common/components/GoogleAnalytics';
|
||||
import { prettyTimestampForFilenames } from '~/common/util/timeUtils';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
|
||||
@@ -80,7 +81,8 @@ function AppDebug() {
|
||||
const chatsCount = useChatStore.getState().conversations?.length;
|
||||
const uxLabsExperiments = Object.entries(useUXLabsStore.getState()).filter(([_k, v]) => v === true).map(([k, _]) => k).join(', ');
|
||||
const { folders, enableFolders } = useFolderStore.getState();
|
||||
const { lastSeenNewsVersion, usageCount } = useAppStateStore.getState();
|
||||
const { lastSeenNewsVersion } = useAppNewsStateStore.getState();
|
||||
const { usageCount } = useAppStateStore.getState();
|
||||
|
||||
|
||||
// derived state
|
||||
@@ -127,7 +129,7 @@ function AppDebug() {
|
||||
const handleDownload = async () => {
|
||||
fileSave(
|
||||
new Blob([JSON.stringify({ client: cClient, agi: cProduct, backend: cBackend }, null, 2)], { type: 'application/json' }),
|
||||
{ fileName: `big-agi-debug-${new Date().toISOString().replace(/:/g, '-')}.json`, extensions: ['.json'] },
|
||||
{ fileName: `big-agi_debug_${prettyTimestampForFilenames()}.json`, extensions: ['.json'] },
|
||||
)
|
||||
.then(() => setSaved(true))
|
||||
.catch(e => console.error('Error saving debug.json', e));
|
||||
|
||||
@@ -13,7 +13,7 @@ import { withLayout } from '~/common/layout/withLayout';
|
||||
function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
|
||||
// external state
|
||||
const { data, isError, error, isLoading } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
|
||||
const { data, isError, error, isPending } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
|
||||
enabled: !!props.openRouterCode,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
@@ -56,7 +56,7 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
Welcome Back
|
||||
</Typography>
|
||||
|
||||
{isLoading && <Typography level='body-sm'>Loading...</Typography>}
|
||||
{isPending && <Typography level='body-sm'>Loading...</Typography>}
|
||||
|
||||
{isErrorInput && <InlineError error='There was an issue retrieving the code from OpenRouter.' />}
|
||||
|
||||
|
||||
@@ -77,9 +77,12 @@ function AppShareTarget() {
|
||||
setIsDownloading(true);
|
||||
callBrowseFetchPage(intentURL)
|
||||
.then(page => {
|
||||
if (page.stopReason !== 'error')
|
||||
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + page.content + '\n```\n');
|
||||
else
|
||||
if (page.stopReason !== 'error') {
|
||||
let pageContent = page.content.markdown || page.content.text || page.content.html || '';
|
||||
if (pageContent)
|
||||
pageContent = '\n\n```' + intentURL + '\n' + pageContent + '\n```\n';
|
||||
queueComposerTextAndLaunchApp(pageContent);
|
||||
} else
|
||||
setErrorMessage('Could not read any data' + page.error ? ': ' + page.error : '');
|
||||
})
|
||||
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppTokens } from '../src/apps/tokens/AppTokens';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function PersonasPage() {
|
||||
return withLayout({ type: 'optima' }, <AppTokens />);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
+28
-3
@@ -3,9 +3,16 @@
|
||||
"short_name": "big-AGI",
|
||||
"theme_color": "#32383E",
|
||||
"background_color": "#9FA6AD",
|
||||
"description": "Personal AGI App",
|
||||
"description": "Your Generative AI Suite",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"AI",
|
||||
"tool",
|
||||
"utilities"
|
||||
],
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"start_url": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
@@ -24,6 +31,17 @@
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "/link/share_target",
|
||||
"accept": {
|
||||
"application/big-agi": [
|
||||
".agi",
|
||||
".agi.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/link/share_target",
|
||||
"method": "GET",
|
||||
@@ -33,5 +51,12 @@
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Call",
|
||||
"url": "/call",
|
||||
"description": "Call a Persona"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+18
-12
@@ -8,20 +8,21 @@ import { BeamView } from '~/modules/beam/BeamView';
|
||||
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { createDConversation, createDMessage, DConversation, DMessage } from '~/common/state/store-chats';
|
||||
import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation';
|
||||
import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
|
||||
function initTestConversation(): DConversation {
|
||||
const conversation = createDConversation();
|
||||
conversation.messages.push(createDMessage('system', 'You are a helpful assistant.'));
|
||||
conversation.messages.push(createDMessage('user', 'Hello, who are you? (please expand...)'));
|
||||
conversation.messages.push(createDMessageTextContent('system', 'You are a helpful assistant.')); // Beam Test - seed1
|
||||
conversation.messages.push(createDMessageTextContent('user', 'Hello, who are you? (please expand...)')); // Beam Test - seed2
|
||||
return conversation;
|
||||
}
|
||||
|
||||
function initTestBeamStore(messages: DMessage[], beamStore: BeamStoreApi = createBeamVanillaStore()): BeamStoreApi {
|
||||
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (text) => alert(text));
|
||||
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (content) => alert(content));
|
||||
return beamStore;
|
||||
}
|
||||
|
||||
@@ -30,8 +31,16 @@ export function AppBeam() {
|
||||
|
||||
// state
|
||||
const [showDebug, setShowDebug] = React.useState(false);
|
||||
const conversation = React.useRef<DConversation>(initTestConversation());
|
||||
const beamStoreApi = React.useRef(initTestBeamStore(conversation.current.messages)).current;
|
||||
|
||||
const [conversation, setConversation] = React.useState<DConversation>(() => initTestConversation());
|
||||
const [beamStoreApi] = React.useState(() => createBeamVanillaStore());
|
||||
|
||||
|
||||
// reinit the beam store if the conversation changes
|
||||
React.useEffect(() => {
|
||||
initTestBeamStore(conversation.messages, beamStoreApi);
|
||||
}, [beamStoreApi, conversation]);
|
||||
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
@@ -44,7 +53,7 @@ export function AppBeam() {
|
||||
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
beamStoreApi.getState().terminate();
|
||||
beamStoreApi.getState().terminateKeepingSettings();
|
||||
}, [beamStoreApi]);
|
||||
|
||||
|
||||
@@ -56,10 +65,7 @@ export function AppBeam() {
|
||||
</Button>
|
||||
|
||||
{/* 'open' */}
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={() => {
|
||||
conversation.current = initTestConversation();
|
||||
initTestBeamStore(conversation.current.messages, beamStoreApi);
|
||||
}}>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={() => setConversation(initTestConversation())}>
|
||||
.open
|
||||
</Button>
|
||||
|
||||
@@ -67,7 +73,7 @@ export function AppBeam() {
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={handleClose}>
|
||||
.close
|
||||
</Button>
|
||||
</>, [beamStoreApi, handleClose, showDebug]), null, 'AppBeam');
|
||||
</>, [handleClose, showDebug]), null, 'AppBeam');
|
||||
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
|
||||
import { Container, Sheet } from '@mui/joy';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
|
||||
import { CallWizard } from './CallWizard';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptim
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
import { navigateBack } from '~/common/app.routes';
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { DConversation, DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
|
||||
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
|
||||
import { animationShadowRingLimey } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
@@ -60,7 +60,7 @@ const ContactCardConversationCall = (props: { conversation: DConversation, onCon
|
||||
function CallContactCard(props: {
|
||||
persona: MockPersona,
|
||||
callGrayUI: boolean,
|
||||
conversations: DConversation[],
|
||||
conversations: Readonly<DConversation[]>,
|
||||
setCallIntent: (intent: AppCallIntent) => void,
|
||||
}) {
|
||||
|
||||
@@ -189,7 +189,7 @@ function CallContactCard(props: {
|
||||
|
||||
|
||||
function useConversationsByPersona() {
|
||||
const conversations = useChatStore(state => state.conversations, shallow);
|
||||
const conversations = useChatStore(state => state.conversations);
|
||||
|
||||
return React.useMemo(() => {
|
||||
// group by personaId
|
||||
|
||||
+30
-19
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -11,18 +11,20 @@ import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTo
|
||||
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
|
||||
import { useChatLLMDropdown } from '../chat/components/layout-bar/useLLMDropdown';
|
||||
|
||||
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../data';
|
||||
import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client';
|
||||
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle } from '~/common/stores/chat/chat.conversation';
|
||||
import { createDMessageTextContent, DMessage, messageFragmentsReduceText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
|
||||
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
|
||||
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
@@ -99,7 +101,7 @@ export function Telephone(props: {
|
||||
|
||||
// external state
|
||||
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
|
||||
const { chatTitle, reMessages } = useChatStore(state => {
|
||||
const { chatTitle, reMessages } = useChatStore(useShallow(state => {
|
||||
const conversation = props.callIntent.conversationId
|
||||
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
|
||||
: null;
|
||||
@@ -107,7 +109,7 @@ export function Telephone(props: {
|
||||
chatTitle: conversation ? conversationTitle(conversation) : null,
|
||||
reMessages: conversation ? conversation.messages : null,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
|
||||
const personaCallStarters = persona?.call?.starters ?? undefined;
|
||||
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
|
||||
@@ -118,9 +120,9 @@ export function Telephone(props: {
|
||||
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
|
||||
setSpeechInterim(result.done ? null : { ...result });
|
||||
if (result.done) {
|
||||
const transcribed = result.transcript.trim();
|
||||
if (transcribed.length >= 1)
|
||||
setCallMessages(messages => [...messages, createDMessage('user', transcribed)]);
|
||||
const userSpeechTranscribed = result.transcript.trim();
|
||||
if (userSpeechTranscribed.length >= 1)
|
||||
setCallMessages(messages => [...messages, createDMessageTextContent('user', userSpeechTranscribed)]); // [state] append user:speech
|
||||
}
|
||||
}, []);
|
||||
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000);
|
||||
@@ -136,11 +138,11 @@ export function Telephone(props: {
|
||||
|
||||
// pickup / hangup
|
||||
React.useEffect(() => {
|
||||
!isRinging && playSoundUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
|
||||
!isRinging && AudioPlayer.playUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
|
||||
}, [isRinging, isConnected]);
|
||||
|
||||
// ringtone
|
||||
usePlaySoundUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
AudioPlayer.usePlayUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
|
||||
|
||||
/// CONNECTED
|
||||
@@ -169,7 +171,8 @@ export function Telephone(props: {
|
||||
const phoneMessages = personaCallStarters || ['Hello?', 'Hey!'];
|
||||
const firstMessage = phoneMessages[Math.floor(Math.random() * phoneMessages.length)];
|
||||
|
||||
setCallMessages([createDMessage('assistant', firstMessage)]);
|
||||
setCallMessages([createDMessageTextContent('assistant', firstMessage)]); // [state] set assistant:hello message
|
||||
|
||||
// fire/forget
|
||||
void EXPERIMENTAL_speakTextStream(firstMessage, personaVoiceId);
|
||||
|
||||
@@ -179,22 +182,30 @@ export function Telephone(props: {
|
||||
// [E] persona streaming response - upon new user message
|
||||
React.useEffect(() => {
|
||||
// only act when we have a new user message
|
||||
if (!isConnected || callMessages.length < 1 || callMessages[callMessages.length - 1].role !== 'user')
|
||||
if (!isConnected || callMessages.length < 1)
|
||||
return;
|
||||
switch (callMessages[callMessages.length - 1].text) {
|
||||
|
||||
// Voice commands
|
||||
const lastUserMessage = callMessages[callMessages.length - 1];
|
||||
if (lastUserMessage.role !== 'user')
|
||||
return;
|
||||
switch (messageFragmentsReduceText(lastUserMessage.fragments)) {
|
||||
// do not respond
|
||||
case 'Stop.':
|
||||
return;
|
||||
|
||||
// command: close the call
|
||||
case 'Goodbye.':
|
||||
setStage('ended');
|
||||
setTimeout(launchAppChat, 2000);
|
||||
return;
|
||||
|
||||
// command: regenerate answer
|
||||
case 'Retry.':
|
||||
case 'Try again.':
|
||||
setCallMessages(messages => messages.slice(0, messages.length - 2));
|
||||
return;
|
||||
|
||||
// command: restart chat
|
||||
case 'Restart.':
|
||||
setCallMessages([]);
|
||||
@@ -206,7 +217,7 @@ export function Telephone(props: {
|
||||
|
||||
// temp fix: when the chat has no messages, only assume a single system message
|
||||
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
|
||||
? reMessages
|
||||
? reMessages.map(message => ({ role: message.role, text: messageSingleTextOrThrow(message) }))
|
||||
: personaSystemMessage
|
||||
? [{ role: 'system', text: personaSystemMessage }]
|
||||
: [];
|
||||
@@ -217,7 +228,7 @@ export function Telephone(props: {
|
||||
{ role: 'system', content: 'You are having a phone call. Your response style is brief and to the point, and according to your personality, defined below.' },
|
||||
...chatMessages.map(message => ({ role: message.role, content: message.text })),
|
||||
{ role: 'system', content: 'You are now on the phone call related to the chat above. Respect your personality and answer with short, friendly and accurate thoughtful lines.' },
|
||||
...callMessages.map(message => ({ role: message.role, content: message.text })),
|
||||
...callMessages.map(message => ({ role: message.role, content: messageSingleTextOrThrow(message) })),
|
||||
];
|
||||
|
||||
// perform completion
|
||||
@@ -225,7 +236,7 @@ export function Telephone(props: {
|
||||
let finalText = '';
|
||||
let error: any | null = null;
|
||||
setPersonaTextInterim('💭...');
|
||||
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
|
||||
llmStreamingChatGenerate(chatLLMId, callPrompt, 'call', callMessages[0].id, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
|
||||
const text = textSoFar?.trim();
|
||||
if (text) {
|
||||
finalText = text;
|
||||
@@ -237,7 +248,7 @@ export function Telephone(props: {
|
||||
}).finally(() => {
|
||||
setPersonaTextInterim(null);
|
||||
if (finalText || error)
|
||||
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
|
||||
setCallMessages(messages => [...messages, createDMessageTextContent('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]); // [state] append assistant:call_response
|
||||
// fire/forget
|
||||
if (finalText?.length >= 1)
|
||||
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
|
||||
@@ -339,7 +350,7 @@ export function Telephone(props: {
|
||||
{callMessages.map((message) =>
|
||||
<CallMessage
|
||||
key={message.id}
|
||||
text={message.text}
|
||||
text={messageSingleTextOrThrow(message)}
|
||||
variant={message.role === 'assistant' ? 'solid' : 'soft'}
|
||||
color={message.role === 'assistant' ? 'neutral' : 'primary'}
|
||||
role={message.role}
|
||||
|
||||
+174
-240
@@ -1,11 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { useTheme } from '@mui/joy';
|
||||
|
||||
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
|
||||
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
|
||||
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
|
||||
import { downloadConversation, openAndLoadConversations } from '~/modules/trade/trade.client';
|
||||
import { getChatLLMId, useChatLLM } from '~/modules/llms/store-llms';
|
||||
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
@@ -14,59 +17,61 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { DConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments } from '~/common/stores/chat/chat.fragments';
|
||||
import { GlobalShortcutDefinition, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
import { PreferencesTab, useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
|
||||
import { getUXLabsHighPerformance, useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessageMetadata, duplicateDMessageMetadata } from '~/common/stores/chat/chat.message';
|
||||
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
|
||||
import { themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatBarAltBeam } from './components/ChatBarAltBeam';
|
||||
import { ChatBarAltTitle } from './components/ChatBarAltTitle';
|
||||
import { ChatBarDropdowns } from './components/ChatBarDropdowns';
|
||||
import { ChatBarAltBeam } from './components/layout-bar/ChatBarAltBeam';
|
||||
import { ChatBarAltTitle } from './components/layout-bar/ChatBarAltTitle';
|
||||
import { ChatBarDropdowns } from './components/layout-bar/ChatBarDropdowns';
|
||||
import { ChatBeamWrapper } from './components/ChatBeamWrapper';
|
||||
import { ChatDrawerMemo } from './components/ChatDrawer';
|
||||
import { ChatDrawerMemo } from './components/layout-drawer/ChatDrawer';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
|
||||
import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { getInstantAppChatPanesCount, usePanesManager } from './components/panes/usePanesManager';
|
||||
import { usePanesManager } from './components/panes/usePanesManager';
|
||||
|
||||
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
|
||||
import { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
|
||||
import { runAssistantUpdatingState } from './editors/chat-stream';
|
||||
import { runBrowseGetPageUpdatingState } from './editors/browse-load';
|
||||
import { runImageGenerationUpdatingState } from './editors/image-generate';
|
||||
import { runReActUpdatingState } from './editors/react-tangent';
|
||||
import type { ChatExecuteMode } from './execute-mode/execute-mode.types';
|
||||
|
||||
import { _handleExecute } from './editors/_handleExecute';
|
||||
import { gcChatImageAssets } from './editors/image-generate';
|
||||
|
||||
|
||||
// what to say when a chat is new and has no title
|
||||
export const CHAT_NOVEL_TITLE = 'Chat';
|
||||
|
||||
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
*/
|
||||
export type ChatModeId =
|
||||
| 'generate-text'
|
||||
| 'generate-text-beam'
|
||||
| 'append-user'
|
||||
| 'generate-image'
|
||||
| 'generate-react';
|
||||
|
||||
|
||||
export interface AppChatIntent {
|
||||
initialConversationId: string | null;
|
||||
}
|
||||
|
||||
|
||||
const composerOpenSx: SxProps = {
|
||||
zIndex: 21, // just to allocate a surface, and potentially have a shadow
|
||||
backgroundColor: themeBgAppChatComposer,
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
};
|
||||
|
||||
const composerClosedSx: SxProps = {
|
||||
display: 'none',
|
||||
};
|
||||
|
||||
|
||||
export function AppChat() {
|
||||
|
||||
// state
|
||||
@@ -90,7 +95,7 @@ export function AppChat() {
|
||||
|
||||
const showAltTitleBar = useUXLabsStore(state => DEV_MODE_SETTINGS && state.labsChatBarAlt === 'title');
|
||||
|
||||
const { openLlmOptions } = useOptimaLayout();
|
||||
const { openLlmOptions, openModelsSetup, openPreferencesTab } = useOptimaLayout();
|
||||
|
||||
const { chatLLM } = useChatLLM();
|
||||
|
||||
@@ -107,19 +112,23 @@ export function AppChat() {
|
||||
setFocusedPaneIndex,
|
||||
} = usePanesManager();
|
||||
|
||||
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
|
||||
return pane.conversationId ? ConversationsManager.getHandler(pane.conversationId) : null;
|
||||
}), [chatPanes]);
|
||||
const { paneUniqueConversationIds, paneHandlers, paneBeamStores } = React.useMemo(() => {
|
||||
const paneConversationIds: (DConversationId | null)[] = chatPanes.map(pane => pane.conversationId || null);
|
||||
const paneHandlers = paneConversationIds.map(cId => cId ? ConversationsManager.getHandler(cId) : null);
|
||||
const paneBeamStores = paneHandlers.map(handler => handler?.getBeamStore() ?? null);
|
||||
const paneUniqueConversationIds = Array.from(new Set(paneConversationIds.filter(Boolean))) as DConversationId[];
|
||||
return {
|
||||
paneHandlers: paneHandlers,
|
||||
paneBeamStores: paneBeamStores,
|
||||
paneUniqueConversationIds: paneUniqueConversationIds,
|
||||
};
|
||||
}, [chatPanes]);
|
||||
|
||||
const beamsStores = React.useMemo(() => chatHandlers.map(handler => {
|
||||
return handler?.getBeamStore() ?? null;
|
||||
}), [chatHandlers]);
|
||||
|
||||
const beamsOpens = useAreBeamsOpen(beamsStores);
|
||||
const beamsOpens = useAreBeamsOpen(paneBeamStores);
|
||||
const beamOpenStoreInFocusedPane = React.useMemo(() => {
|
||||
const open = focusedPaneIndex !== null ? (beamsOpens?.[focusedPaneIndex] ?? false) : false;
|
||||
return open ? beamsStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, beamsStores, focusedPaneIndex]);
|
||||
return open ? paneBeamStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, focusedPaneIndex, paneBeamStores]);
|
||||
|
||||
const {
|
||||
// focused
|
||||
@@ -151,7 +160,7 @@ export function AppChat() {
|
||||
|
||||
const isMultiPane = chatPanes.length >= 2;
|
||||
const isMultiAddable = chatPanes.length < 4;
|
||||
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
|
||||
const isMultiConversationId = paneUniqueConversationIds.length >= 2;
|
||||
const willMulticast = isComposerMulticast && isMultiConversationId;
|
||||
const disableNewButton = isFocusedChatEmpty && !isMultiPane;
|
||||
|
||||
@@ -186,160 +195,64 @@ export function AppChat() {
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
const handleExecuteAndOutcome = React.useCallback(async (chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, callerNameDebug: string) => {
|
||||
const outcome = await _handleExecute(chatExecuteMode, conversationId, callerNameDebug);
|
||||
if (outcome === 'err-no-chatllm')
|
||||
openModelsSetup();
|
||||
else if (outcome === 'err-t2i-unconfigured')
|
||||
openPreferencesTab(PreferencesTab.Draw);
|
||||
else if (outcome === 'err-no-persona')
|
||||
addSnackbar({ key: 'chat-no-persona', message: 'No persona selected.', type: 'issue' });
|
||||
else if (outcome === 'err-no-conversation')
|
||||
addSnackbar({ key: 'chat-no-conversation', message: 'No active conversation.', type: 'issue' });
|
||||
else if (outcome === 'err-no-last-message')
|
||||
addSnackbar({ key: 'chat-no-conversation', message: 'No conversation history.', type: 'issue' });
|
||||
return outcome === true;
|
||||
}, [openModelsSetup, openPreferencesTab]);
|
||||
|
||||
// Update the system message from the active persona to the history
|
||||
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
|
||||
// 1. all the callers need to pass a new array
|
||||
// 2. all the exit points need to call setMessages
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
cHandler.inlineUpdatePurposeInHistory(history, chatLLMId);
|
||||
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
|
||||
|
||||
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
|
||||
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
|
||||
if (lastMessage?.role === 'user') {
|
||||
const chatCommand = extractChatCommand(lastMessage.text)[0];
|
||||
if (chatCommand && chatCommand.type === 'cmd') {
|
||||
switch (chatCommand.providerId) {
|
||||
case 'ass-browse':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
|
||||
// [multicast] send the message to all the panes
|
||||
const uniqueConversationIds = willMulticast
|
||||
? Array.from(new Set([conversationId, ...paneUniqueConversationIds]))
|
||||
: [conversationId];
|
||||
|
||||
case 'ass-t2i':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'ass-react':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
|
||||
|
||||
case 'chat-alter':
|
||||
// /clear
|
||||
if (chatCommand.command === '/clear') {
|
||||
if (chatCommand.params === 'all')
|
||||
return cHandler.messagesReplace([]);
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: this command requires the \'all\' parameter to confirm the operation.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
// /assistant, /system
|
||||
Object.assign(lastMessage, {
|
||||
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
|
||||
sender: 'Bot',
|
||||
text: chatCommand.params || '',
|
||||
} satisfies Partial<DMessage>);
|
||||
return cHandler.messagesReplace(history);
|
||||
|
||||
case 'cmd-help':
|
||||
const chatCommandsText = findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Available Chat Commands:\n' + chatCommandsText, undefined, 'help', false);
|
||||
return;
|
||||
|
||||
case 'mode-beam':
|
||||
if (chatCommand.isError)
|
||||
return cHandler.messagesReplace(history);
|
||||
// remove '/beam ', as we want to be a user chat message
|
||||
Object.assign(lastMessage, { text: chatCommand.params || '' });
|
||||
cHandler.messagesReplace(history);
|
||||
return ConversationsManager.getHandler(conversationId).beamInvoke(history, [], null);
|
||||
|
||||
default:
|
||||
return cHandler.messagesReplace([...history, createDMessage('assistant', 'This command is not supported.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
if (!getConversationSystemPurposeId(conversationId)) {
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: no Persona selected.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
switch (chatModeId) {
|
||||
case 'generate-text':
|
||||
cHandler.messagesReplace(history);
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'generate-text-beam':
|
||||
cHandler.messagesReplace(history);
|
||||
return cHandler.beamInvoke(history, [], null);
|
||||
|
||||
case 'append-user':
|
||||
return cHandler.messagesReplace(history);
|
||||
|
||||
case 'generate-image':
|
||||
if (!lastMessage?.text) break;
|
||||
// also add a 'fake' user message with the '/draw' command
|
||||
cHandler.messagesReplace(history.map(message => (message.id !== lastMessage.id) ? message : {
|
||||
...message,
|
||||
text: `/draw ${lastMessage.text}`,
|
||||
}));
|
||||
return await runImageGenerationUpdatingState(cHandler, lastMessage.text);
|
||||
|
||||
case 'generate-react':
|
||||
if (!lastMessage?.text) break;
|
||||
cHandler.messagesReplace(history);
|
||||
return await runReActUpdatingState(cHandler, lastMessage.text, chatLLMId);
|
||||
}
|
||||
|
||||
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
|
||||
console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage);
|
||||
cHandler.messagesReplace(history);
|
||||
}, []);
|
||||
|
||||
const handleComposerAction = React.useCallback((chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
|
||||
// validate inputs
|
||||
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
|
||||
addSnackbar({
|
||||
key: 'chat-composer-action-invalid',
|
||||
message: 'Only a single text part is supported for now.',
|
||||
type: 'issue',
|
||||
overrides: {
|
||||
autoHideDuration: 2000,
|
||||
},
|
||||
});
|
||||
// validate conversation existence
|
||||
const uniqueConverations = uniqueConversationIds.map(cId => getConversation(cId)).filter(Boolean) as DConversation[];
|
||||
if (!uniqueConverations.length)
|
||||
return false;
|
||||
}
|
||||
const userText = multiPartMessage[0].text;
|
||||
|
||||
// multicast: send the message to all the panes
|
||||
const uniqueIds = new Set([conversationId]);
|
||||
if (willMulticast)
|
||||
chatPanes.forEach(pane => pane.conversationId && uniqueIds.add(pane.conversationId));
|
||||
|
||||
// we loop to handle both the normal and multicast modes
|
||||
let enqueued = false;
|
||||
for (const _cId of uniqueIds) {
|
||||
const _conversation = getConversation(_cId);
|
||||
if (_conversation) {
|
||||
// start execution fire/forget
|
||||
void _handleExecute(chatModeId, _cId, [..._conversation.messages, createDMessage('user', userText)]);
|
||||
enqueued = true;
|
||||
}
|
||||
}
|
||||
return enqueued;
|
||||
}, [chatPanes, willMulticast, _handleExecute]);
|
||||
for (const conversation of uniqueConverations) {
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
await _handleExecute('generate-text', conversationId, history);
|
||||
}, [_handleExecute]);
|
||||
// create the user:message
|
||||
// NOTE: this can lead to multiple chat messages with data refs that are referring to the same dblobs,
|
||||
// however, we already got transferred ownership of the dblobs at this point.
|
||||
const userMessage = createDMessageFromFragments('user', duplicateDMessageFragments(fragments)); // [chat] create user:message
|
||||
if (metadata) userMessage.metadata = duplicateDMessageMetadata(metadata);
|
||||
|
||||
ConversationsManager.getHandler(conversation.id).messageAppend(userMessage); // [chat] append user message in each conversation
|
||||
|
||||
// fire/forget
|
||||
void handleExecuteAndOutcome(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [paneUniqueConversationIds, handleExecuteAndOutcome, willMulticast]);
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId) => {
|
||||
await handleExecuteAndOutcome('generate-content', conversationId, 'chat-execute-history'); // replace with 'history', then 'generate-text'
|
||||
}, [handleExecuteAndOutcome]);
|
||||
|
||||
const handleMessageRegenerateLastInFocusedPane = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedPaneConversationId);
|
||||
if (focusedConversation?.messages?.length) {
|
||||
if (focusedPaneConversationId && focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
const history = lastMessage.role === 'assistant' ? focusedConversation.messages.slice(0, -1) : [...focusedConversation.messages];
|
||||
return await _handleExecute('generate-text', focusedConversation.id, history);
|
||||
if (lastMessage.role === 'assistant')
|
||||
ConversationsManager.getHandler(focusedPaneConversationId).historyTruncateTo(lastMessage.id, -1);
|
||||
await handleExecuteAndOutcome('generate-content', focusedConversation.id, 'chat-regenerate-last'); // truncate if assistant, then gen-text
|
||||
}
|
||||
}, [_handleExecute, focusedPaneConversationId]);
|
||||
}, [focusedPaneConversationId, handleExecuteAndOutcome]);
|
||||
|
||||
const handleMessageBeamLastInFocusedPane = React.useCallback(async () => {
|
||||
// Ctrl + Shift + B
|
||||
@@ -355,16 +268,15 @@ export function AppChat() {
|
||||
|
||||
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
|
||||
|
||||
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
|
||||
const handleImagineFromText = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation)
|
||||
return;
|
||||
const imaginedPrompt = await imaginePromptFromText(messageText) || 'An error sign.';
|
||||
return await _handleExecute('generate-image', conversationId, [
|
||||
...conversation.messages,
|
||||
createDMessage('user', imaginedPrompt),
|
||||
]);
|
||||
}, [_handleExecute]);
|
||||
const imaginedPrompt = await imaginePromptFromText(messageText, conversationId) || 'An error sign.';
|
||||
const imaginePrompMessage = createDMessageTextContent('user', imaginedPrompt);
|
||||
ConversationsManager.getHandler(conversationId).messageAppend(imaginePrompMessage); // [chat] append user:imagine prompt
|
||||
await handleExecuteAndOutcome('generate-image', conversationId, 'chat-imagine-from-text'); // append message for 'imagine', then generate-image
|
||||
}, [handleExecuteAndOutcome]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
|
||||
await speakText(text);
|
||||
@@ -398,6 +310,32 @@ export function AppChat() {
|
||||
setTradeConfig({ dir: 'export', conversationId, exportAll });
|
||||
}, []);
|
||||
|
||||
const handleFileOpenConversation = React.useCallback(() => {
|
||||
openAndLoadConversations(true)
|
||||
.then((outcome) => {
|
||||
// activate the last (most recent) imported conversation
|
||||
if (outcome?.activateConversationId) {
|
||||
showNextTitleChange.current = true;
|
||||
handleOpenConversationInFocusedPane(outcome.activateConversationId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
addSnackbar({ key: 'chat-import-fail', message: 'Could not open the file.', type: 'issue' });
|
||||
});
|
||||
}, [handleOpenConversationInFocusedPane]);
|
||||
|
||||
const handleFileSaveConversation = React.useCallback((conversationId: DConversationId | null) => {
|
||||
const conversation = getConversation(conversationId);
|
||||
conversation && downloadConversation(conversation, 'json')
|
||||
.then(() => {
|
||||
addSnackbar({ key: 'chat-save-as-ok', message: 'File saved.', type: 'success' });
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (err?.name !== 'AbortError')
|
||||
addSnackbar({ key: 'chat-save-as-fail', message: `Could not save the file. ${err?.message || ''}`, type: 'issue' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConversationBranch = React.useCallback((srcConversationId: DConversationId, messageId: string | null): DConversationId | null => {
|
||||
// clone data
|
||||
const branchedConversationId = branchConversation(srcConversationId, messageId);
|
||||
@@ -420,7 +358,7 @@ export function AppChat() {
|
||||
|
||||
const handleConfirmedClearConversation = React.useCallback(() => {
|
||||
if (clearConversationId) {
|
||||
ConversationsManager.getHandler(clearConversationId).messagesReplace([]);
|
||||
ConversationsManager.getHandler(clearConversationId).historyClear();
|
||||
setClearConversationId(null);
|
||||
}
|
||||
}, [clearConversationId]);
|
||||
@@ -438,6 +376,9 @@ export function AppChat() {
|
||||
handleOpenConversationInFocusedPane(nextConversationId);
|
||||
|
||||
setDeleteConversationIds(null);
|
||||
|
||||
// run GC for dblobs in this conversation
|
||||
void gcChatImageAssets(); // fire/forget
|
||||
}, [deleteConversations, handleOpenConversationInFocusedPane]);
|
||||
|
||||
const handleConfirmedDeleteConversations = React.useCallback(() => {
|
||||
@@ -453,11 +394,13 @@ export function AppChat() {
|
||||
openLlmOptions(chatLLMId);
|
||||
}, [openLlmOptions]);
|
||||
|
||||
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
|
||||
const shortcuts = React.useMemo((): GlobalShortcutDefinition[] => [
|
||||
// focused conversation
|
||||
['b', true, true, false, handleMessageBeamLastInFocusedPane],
|
||||
['r', true, true, false, handleMessageRegenerateLastInFocusedPane],
|
||||
['n', true, false, true, handleConversationNewInFocusedPane],
|
||||
['o', true, false, false, handleFileOpenConversation],
|
||||
['s', true, false, false, () => handleFileSaveConversation(focusedPaneConversationId)],
|
||||
['b', true, false, true, () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationBranch(focusedPaneConversationId, null))],
|
||||
['x', true, false, true, () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationClear(focusedPaneConversationId))],
|
||||
['d', true, false, true, () => focusedPaneConversationId && handleDeleteConversations([focusedPaneConversationId], false)],
|
||||
@@ -467,7 +410,7 @@ export function AppChat() {
|
||||
['o', true, true, false, handleOpenChatLlmOptions],
|
||||
['+', true, true, false, useUIPreferencesStore.getState().increaseContentScaling],
|
||||
['-', true, true, false, useUIPreferencesStore.getState().decreaseContentScaling],
|
||||
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleFileOpenConversation, handleFileSaveConversation, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
|
||||
@@ -488,7 +431,7 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
activeConversationId={focusedPaneConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
|
||||
chatPanesConversationIds={paneUniqueConversationIds}
|
||||
disableNewButton={disableNewButton}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
@@ -498,7 +441,7 @@ export function AppChat() {
|
||||
onConversationsImportDialog={handleConversationImportDialog}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[activeFolderId, chatPanes, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile],
|
||||
[activeFolderId, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile, paneUniqueConversationIds],
|
||||
);
|
||||
|
||||
const focusedMenuItems = React.useMemo(() =>
|
||||
@@ -530,9 +473,9 @@ export function AppChat() {
|
||||
{chatPanes.map((pane, idx) => {
|
||||
const _paneIsFocused = idx === focusedPaneIndex;
|
||||
const _paneConversationId = pane.conversationId;
|
||||
const _paneChatHandler = chatHandlers[idx] ?? null;
|
||||
const _paneChatBeamStore = beamsStores[idx] ?? null;
|
||||
const _paneChatBeamIsOpen = !!beamsOpens?.[idx];
|
||||
const _paneChatHandler = paneHandlers[idx] ?? null;
|
||||
const _paneBeamStore = paneBeamStores[idx] ?? null;
|
||||
const _paneBeamIsOpen = !!beamsOpens?.[idx] && !!_paneBeamStore;
|
||||
const _panesCount = chatPanes.length;
|
||||
const _keyAndId = `chat-pane-${pane.paneId}`;
|
||||
const _sepId = `sep-pane-${idx}`;
|
||||
@@ -580,47 +523,46 @@ export function AppChat() {
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottomInitial
|
||||
sx={_paneChatBeamIsOpen ? { display: 'none' } : undefined}
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={_paneConversationId}
|
||||
conversationHandler={_paneChatHandler}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
|
||||
fitScreen={isMobile || isMultiPane}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
minHeight: '100%', // ensures filling of the blank space on newer chats
|
||||
}}
|
||||
/>
|
||||
{!_paneBeamIsOpen && (
|
||||
<ChatMessageList
|
||||
conversationId={_paneConversationId}
|
||||
conversationHandler={_paneChatHandler}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
|
||||
fitScreen={isMobile || isMultiPane}
|
||||
isMobile={isMobile}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleImagineFromText}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*<Ephemerals*/}
|
||||
{/* conversationId={_paneConversationId}*/}
|
||||
{/* sx={{*/}
|
||||
{/* // TODO: Fixme post panels?*/}
|
||||
{/* // flexGrow: 0.1,*/}
|
||||
{/* flexShrink: 0.5,*/}
|
||||
{/* overflowY: 'auto',*/}
|
||||
{/* minHeight: 64,*/}
|
||||
{/* }}*/}
|
||||
{/*/>*/}
|
||||
{_paneBeamIsOpen && (
|
||||
<ChatBeamWrapper
|
||||
beamStore={_paneBeamStore}
|
||||
isMobile={isMobile}
|
||||
inlineSx={{
|
||||
flexGrow: 1,
|
||||
// minHeight: 'calc(100vh - 69px - var(--AGI-Nav-width))',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visibility and actions are handled via Context */}
|
||||
<ScrollToBottomButton />
|
||||
|
||||
</ScrollToBottom>
|
||||
|
||||
{(_paneChatBeamIsOpen && !!_paneChatBeamStore) && (
|
||||
<ChatBeamWrapper beamStore={_paneChatBeamStore} isMobile={isMobile} />
|
||||
)}
|
||||
|
||||
</Panel>
|
||||
|
||||
{/* Panel Separators & Resizers */}
|
||||
@@ -639,22 +581,14 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
chatLLM={chatLLM}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
conversationId={focusedPaneConversationId}
|
||||
targetConversationId={focusedPaneConversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
|
||||
isDeveloperMode={isFocusedChatDeveloper}
|
||||
onAction={handleComposerAction}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextImagine={handleImagineFromText}
|
||||
setIsMulticast={setIsComposerMulticast}
|
||||
sx={beamOpenStoreInFocusedPane ? {
|
||||
display: 'none',
|
||||
} : {
|
||||
zIndex: 21, // just to allocate a surface, and potentially have a shadow
|
||||
backgroundColor: themeBgAppChatComposer,
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
sx={beamOpenStoreInFocusedPane ? composerClosedSx : composerOpenSx}
|
||||
/>
|
||||
|
||||
{/* Diagrams */}
|
||||
|
||||
@@ -3,18 +3,18 @@ import ClearIcon from '@mui/icons-material/Clear';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsAlter: ICommandsProvider = {
|
||||
id: 'chat-alter',
|
||||
id: 'cmd-chat-alter',
|
||||
rank: 25,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/assistant',
|
||||
alternatives: ['/a'],
|
||||
arguments: ['text'],
|
||||
arguments: ['text...'],
|
||||
description: 'Injects assistant response',
|
||||
}, {
|
||||
primary: '/system',
|
||||
alternatives: ['/s'],
|
||||
arguments: ['text'],
|
||||
arguments: ['text...'],
|
||||
description: 'Injects system message',
|
||||
}, {
|
||||
primary: '/clear',
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBeam: ICommandsProvider = {
|
||||
id: 'mode-beam',
|
||||
id: 'cmd-mode-beam',
|
||||
rank: 9,
|
||||
|
||||
getCommands: () => useUXLabsStore.getState().labsBeam ? [{
|
||||
getCommands: () => [{
|
||||
primary: '/beam',
|
||||
arguments: ['prompt'],
|
||||
description: 'Combine the smarts of models',
|
||||
Icon: ChatBeamIcon,
|
||||
}] : [],
|
||||
}],
|
||||
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import LanguageIcon from '@mui/icons-material/Language';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBrowse: ICommandsProvider = {
|
||||
id: 'ass-browse',
|
||||
id: 'cmd-ass-browse',
|
||||
rank: 20,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -2,8 +2,12 @@ import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export function textToDrawCommand(text: string): string {
|
||||
return `/draw ${text}`;
|
||||
}
|
||||
|
||||
export const CommandsDraw: ICommandsProvider = {
|
||||
id: 'ass-t2i',
|
||||
id: 'cmd-ass-t2i',
|
||||
rank: 10,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -3,7 +3,7 @@ import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsReact: ICommandsProvider = {
|
||||
id: 'ass-react',
|
||||
id: 'cmd-mode-react',
|
||||
rank: 15,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -8,20 +8,20 @@ import { CommandsHelp } from './CommandsHelp';
|
||||
import { CommandsReact } from './CommandsReact';
|
||||
|
||||
|
||||
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help' | 'mode-beam';
|
||||
export type CommandsProviderId = 'cmd-ass-browse' | 'cmd-ass-t2i' | 'cmd-chat-alter' | 'cmd-help' | 'cmd-mode-beam' | 'cmd-mode-react';
|
||||
|
||||
type TextCommandPiece =
|
||||
| { type: 'text'; value: string; }
|
||||
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isError?: boolean };
|
||||
| { type: 'nocmd'; value: string; }
|
||||
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isErrorNoArgs?: boolean };
|
||||
|
||||
|
||||
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
|
||||
'ass-browse': CommandsBrowse,
|
||||
'ass-react': CommandsReact,
|
||||
'ass-t2i': CommandsDraw,
|
||||
'chat-alter': CommandsAlter,
|
||||
'cmd-ass-browse': CommandsBrowse,
|
||||
'cmd-ass-t2i': CommandsDraw,
|
||||
'cmd-chat-alter': CommandsAlter,
|
||||
'cmd-help': CommandsHelp,
|
||||
'mode-beam': CommandsBeam,
|
||||
'cmd-mode-beam': CommandsBeam,
|
||||
'cmd-mode-react': CommandsReact,
|
||||
};
|
||||
|
||||
export function findAllChatCommands(): ChatCommand[] {
|
||||
@@ -31,12 +31,18 @@ export function findAllChatCommands(): ChatCommand[] {
|
||||
.flat();
|
||||
}
|
||||
|
||||
export function helpPrettyChatCommands() {
|
||||
return findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
const inputTrimmed = input.trim();
|
||||
|
||||
// quick exit: command does not start with '/'
|
||||
if (!inputTrimmed.startsWith('/'))
|
||||
return [{ type: 'text', value: input }];
|
||||
return [{ type: 'nocmd', value: input }];
|
||||
|
||||
// Find the first space to separate the command from its parameters (if any)
|
||||
const firstSpaceIndex = inputTrimmed.indexOf(' ');
|
||||
@@ -56,7 +62,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
providerId: provider.id,
|
||||
command: potentialCommand,
|
||||
params: textAfterCommand || undefined,
|
||||
isError: !textAfterCommand || undefined,
|
||||
isErrorNoArgs: !textAfterCommand,
|
||||
}];
|
||||
|
||||
// command without arguments, treat any text after as a separate text piece
|
||||
@@ -67,7 +73,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
params: undefined,
|
||||
}];
|
||||
textAfterCommand && pieces.push({
|
||||
type: 'text',
|
||||
type: 'nocmd',
|
||||
value: textAfterCommand,
|
||||
});
|
||||
return pieces;
|
||||
@@ -77,7 +83,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
|
||||
// No command found, return the entire input as text
|
||||
return [{
|
||||
type: 'text',
|
||||
type: 'nocmd',
|
||||
value: input,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Modal, ModalClose } from '@mui/joy';
|
||||
|
||||
import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
|
||||
import { BeamView } from '~/modules/beam/BeamView';
|
||||
|
||||
import { themeZIndexBeamView } from '~/common/app.theme';
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
|
||||
|
||||
/*const overlaySx: SxProps = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: themeZIndexBeamView, // stay on top of Message > Chips (:1), and Overlays (:2) - note: Desktop Drawer (:26)
|
||||
}*/
|
||||
|
||||
|
||||
export function ChatBeamWrapper(props: {
|
||||
beamStore: BeamStoreApi,
|
||||
isMobile: boolean,
|
||||
inlineSx?: SxProps,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -36,16 +45,14 @@ export function ChatBeamWrapper(props: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
}}>
|
||||
{beamView}
|
||||
<ScrollToBottom disableAutoStick>
|
||||
{beamView}
|
||||
</ScrollToBottom>
|
||||
<ModalClose sx={{ color: 'white', backgroundColor: 'background.surface', boxShadow: 'xs', mr: 2 }} />
|
||||
</Box>
|
||||
</Modal>
|
||||
) : (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: themeZIndexBeamView, // stay on top of Message > Chips (:1), and Overlays (:2) - note: Desktop Drawer (:26)
|
||||
}}>
|
||||
<Box sx={props.inlineSx}>
|
||||
{beamView}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,13 @@ import { Box, List } from '@mui/joy';
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { createDMessage, DConversationId, DMessage, DMessageUserFlag, getConversation, messageToggleUserFlag, useChatStore } from '~/common/state/store-chats';
|
||||
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { createDMessageTextContent, DMessageId, DMessageUserFlag, messageToggleUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
|
||||
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useEphemerals } from '~/common/chats/EphemeralsStore';
|
||||
@@ -20,7 +23,7 @@ import { ChatMessage, ChatMessageMemo } from './message/ChatMessage';
|
||||
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
|
||||
import { Ephemerals } from './Ephemerals';
|
||||
import { PersonaSelector } from './persona-selector/PersonaSelector';
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
import { useChatAutoSuggestHTMLUI, useChatShowSystemMessages } from '../store-app-chat';
|
||||
|
||||
|
||||
/**
|
||||
@@ -32,9 +35,10 @@ export function ChatMessageList(props: {
|
||||
capabilityHasT2I: boolean,
|
||||
chatLLMContextTokens: number | null,
|
||||
fitScreen: boolean,
|
||||
isMobile: boolean,
|
||||
isMessageSelectionMode: boolean,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId) => Promise<void>,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
|
||||
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
|
||||
onTextSpeak: (selectedText: string) => Promise<void>,
|
||||
@@ -50,43 +54,43 @@ export function ChatMessageList(props: {
|
||||
// external state
|
||||
const { notifyBooting } = useScrollToBottom();
|
||||
const { openPreferencesTab } = useOptimaLayout();
|
||||
const danger_experimentalHtmlWebUi = useChatAutoSuggestHTMLUI();
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const optionalTranslationWarning = useBrowserTranslationWarning();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(useShallow(state => {
|
||||
const { conversationMessages, historyTokenCount } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
conversationMessages: conversation ? conversation.messages : [],
|
||||
historyTokenCount: conversation ? conversation.tokenCount : 0,
|
||||
deleteMessage: state.deleteMessage,
|
||||
editMessage: state.editMessage,
|
||||
setMessages: state.setMessages,
|
||||
};
|
||||
}));
|
||||
const ephemerals = useEphemerals(props.conversationHandler);
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
// derived state
|
||||
const { conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
|
||||
|
||||
// text actions
|
||||
|
||||
const handleRunExample = React.useCallback(async (examplePrompt: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', examplePrompt)]);
|
||||
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.messageAppend(createDMessageTextContent('user', examplePrompt)); // [chat] append user:persona question
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
const handleMessageAssistantFrom = React.useCallback(async (messageId: string, offset: number) => {
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (messages) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
|
||||
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
|
||||
const handleMessageAssistantFrom = React.useCallback(async (messageId: DMessageId, offset: number) => {
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.historyTruncateTo(messageId, offset);
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationId, onConversationExecuteHistory]);
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
const handleMessageBeam = React.useCallback(async (messageId: string) => {
|
||||
const handleMessageBeam = React.useCallback(async (messageId: DMessageId) => {
|
||||
// Right-click menu Beam
|
||||
if (!conversationId || !props.conversationHandler) return;
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
@@ -110,33 +114,41 @@ export function ChatMessageList(props: {
|
||||
}
|
||||
}, [conversationId, props.conversationHandler]);
|
||||
|
||||
const handleMessageBranch = React.useCallback((messageId: string) => {
|
||||
const handleMessageBranch = React.useCallback((messageId: DMessageId) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleMessageTruncate = React.useCallback((messageId: string) => {
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (conversationId && messages) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1);
|
||||
setMessages(conversationId, truncatedHistory);
|
||||
}
|
||||
}, [conversationId, setMessages]);
|
||||
const handleMessageTruncate = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.historyTruncateTo(messageId, 0);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageDelete = React.useCallback((messageId: string) => {
|
||||
conversationId && deleteMessage(conversationId, messageId);
|
||||
}, [conversationId, deleteMessage]);
|
||||
const handleMessageDelete = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.messagesDelete([messageId]);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
|
||||
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
|
||||
}, [conversationId, editMessage]);
|
||||
const handleMessageAppendFragment = React.useCallback((messageId: DMessageId, fragment: DMessageFragment) => {
|
||||
props.conversationHandler?.messageFragmentAppend(messageId, fragment, false, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageToggleUserFlag = React.useCallback((messageId: string, userFlag: DMessageUserFlag) => {
|
||||
conversationId && editMessage(conversationId, messageId, (message) => ({
|
||||
const handleMessageDeleteFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId) => {
|
||||
props.conversationHandler?.messageFragmentDelete(messageId, fragmentId, false, true);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageReplaceFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => {
|
||||
props.conversationHandler?.messageFragmentReplace(messageId, fragmentId, newFragment, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageToggleUserFlag = React.useCallback((messageId: DMessageId, userFlag: DMessageUserFlag) => {
|
||||
props.conversationHandler?.messageEdit(messageId, (message) => ({
|
||||
userFlags: messageToggleUserFlag(message, userFlag),
|
||||
}), false);
|
||||
}, [conversationId, editMessage]);
|
||||
}), false, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
const handleReplyTo = React.useCallback((_messageId: DMessageId, text: string) => {
|
||||
props.conversationHandler?.getOverlayStore().getState().setReplyToText(text);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: DMessageId, text: string) => {
|
||||
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
}, [conversationId, onTextDiagram]);
|
||||
|
||||
@@ -169,36 +181,35 @@ export function ChatMessageList(props: {
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectMessage = (messageId: string, selected: boolean) => {
|
||||
const handleSelectMessage = (messageId: DMessageId, selected: boolean) => {
|
||||
const newSelected = new Set(selectedMessages);
|
||||
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectionDelete = () => {
|
||||
if (conversationId)
|
||||
for (const selectedMessage of selectedMessages)
|
||||
deleteMessage(conversationId, selectedMessage);
|
||||
const handleSelectionDelete = React.useCallback(() => {
|
||||
props.conversationHandler?.messagesDelete(Array.from(selectedMessages));
|
||||
setSelectedMessages(new Set());
|
||||
};
|
||||
}, [props.conversationHandler, selectedMessages]);
|
||||
|
||||
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
useGlobalShortcuts([[props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
props.setIsMessageSelectionMode(false);
|
||||
});
|
||||
}]]);
|
||||
|
||||
|
||||
// text-diff functionality: only diff the last message and when it's complete (not typing), and they're similar in size
|
||||
// text-diff functionality: only diff the last complete message, and they're similar in size
|
||||
|
||||
const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
|
||||
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
|
||||
if (msgB?.text && msgA?.text && !msgB?.typing) {
|
||||
const textA = msgA.text, textB = msgB.text;
|
||||
const lenA = textA.length, lenB = textB.length;
|
||||
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
|
||||
return { diffTargetMessage: msgB, diffPrevText: textA };
|
||||
}
|
||||
return { diffTargetMessage: undefined, diffPrevText: undefined };
|
||||
}, [conversationMessages]);
|
||||
// const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
|
||||
// const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
|
||||
// const textB = msgB ? singleTextOrThrow(msgB) : undefined;
|
||||
// const textA = msgA ? singleTextOrThrow(msgA) : undefined;
|
||||
// if (textB && textA && !msgB?.pendingIncomplete) {
|
||||
// const lenA = textA.length, lenB = textB.length;
|
||||
// if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
|
||||
// return { diffTargetMessage: msgB, diffPrevText: textA };
|
||||
// }
|
||||
// return { diffTargetMessage: undefined, diffPrevText: undefined };
|
||||
// }, [conversationMessages]);
|
||||
|
||||
|
||||
// scroll to the very bottom of a new chat
|
||||
@@ -224,13 +235,16 @@ export function ChatMessageList(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{
|
||||
p: 0, ...(props.sx || {}),
|
||||
// this makes sure that the the window is scrolled to the bottom (column-reverse)
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
<List role='chat-messages-list' sx={{
|
||||
p: 0,
|
||||
...(props.sx || {}),
|
||||
|
||||
// fix for the double-border on the last message (one by the composer, one to the bottom of the message)
|
||||
// marginBottom: '-1px',
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
|
||||
{optionalTranslationWarning}
|
||||
@@ -247,8 +261,8 @@ export function ChatMessageList(props: {
|
||||
|
||||
{filteredMessages.map((message, idx, { length: count }) => {
|
||||
|
||||
// Optimization: if the component is going to change (e.g. the message is typing), we don't want to memoize it to not throw garbage in memory
|
||||
const ChatMessageMemoOrNot = message.typing ? ChatMessage : ChatMessageMemo;
|
||||
// Optimization: only memo complete components, or we'd be memoizing garbage
|
||||
const ChatMessageMemoOrNot = !message.pendingIncomplete ? ChatMessageMemo : ChatMessage;
|
||||
|
||||
return props.isMessageSelectionMode ? (
|
||||
|
||||
@@ -264,21 +278,26 @@ export function ChatMessageList(props: {
|
||||
<ChatMessageMemoOrNot
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
// diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
fitScreen={props.fitScreen}
|
||||
isMobile={props.isMobile}
|
||||
isBottom={idx === count - 1}
|
||||
isImagining={isImagining}
|
||||
isSpeaking={isSpeaking}
|
||||
showUnsafeHtml={danger_experimentalHtmlWebUi}
|
||||
onMessageAssistantFrom={handleMessageAssistantFrom}
|
||||
onMessageBeam={handleMessageBeam}
|
||||
onMessageBranch={handleMessageBranch}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onMessageFragmentAppend={handleMessageAppendFragment}
|
||||
onMessageFragmentDelete={handleMessageDeleteFragment}
|
||||
onMessageFragmentReplace={handleMessageReplaceFragment}
|
||||
onMessageToggleUserFlag={handleMessageToggleUserFlag}
|
||||
onMessageTruncate={handleMessageTruncate}
|
||||
onReplyTo={handleReplyTo}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
onTextImagine={capabilityHasT2I ? handleTextImagine : undefined}
|
||||
onTextSpeak={isSpeakable ? handleTextSpeak : undefined}
|
||||
/>
|
||||
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DConversationId } from '~/common/state/store-chats';
|
||||
import { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { lineHeightChatTextMd } from '~/common/app.theme';
|
||||
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ChatModeId } from '../../AppChat';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
interface ChatModeDescription {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
highlight?: boolean;
|
||||
shortcut?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
requiresTTI?: boolean;
|
||||
}
|
||||
|
||||
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
'generate-text': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'generate-text-beam': {
|
||||
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
|
||||
description: 'Combine multiple models', // Smarter: combine...
|
||||
shortcut: 'Ctrl + Enter',
|
||||
hideOnDesktop: true,
|
||||
},
|
||||
'append-user': {
|
||||
label: 'Write',
|
||||
description: 'Append a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'generate-image': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
requiresTTI: true,
|
||||
},
|
||||
'generate-react': {
|
||||
label: 'Reason + Act', // · α
|
||||
description: 'Answer questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
if (shortcut === 'ENTER')
|
||||
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
export function ChatModeMenu(props: {
|
||||
isMobile: boolean,
|
||||
anchorEl: HTMLAnchorElement | null,
|
||||
onClose: () => void,
|
||||
chatModeId: ChatModeId,
|
||||
onSetChatModeId: (chatMode: ChatModeId) => void,
|
||||
capabilityHasTTI: boolean,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const labsBeam = useUXLabsStore(state => state.labsBeam);
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
placement='top-end'
|
||||
open anchorEl={props.anchorEl} onClose={props.onClose}
|
||||
sx={{ minWidth: 320 }}
|
||||
>
|
||||
|
||||
{/*<MenuItem color='neutral' selected>*/}
|
||||
{/* Conversation Mode*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/**/}
|
||||
{/*<ListDivider />*/}
|
||||
|
||||
{/* ChatMode items */}
|
||||
{Object.entries(ChatModeItems)
|
||||
.filter(([key, _data]) => key !== 'generate-text-beam' || labsBeam)
|
||||
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
|
||||
.map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatModeId} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatModeId || !!data.shortcut) && (
|
||||
<KeyStroke combo={platformAwareKeystrokes(fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline))} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { fileOpen, FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
@@ -15,29 +15,33 @@ import SendIcon from '@mui/icons-material/Send';
|
||||
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import type { ChatModeId } from '../../AppChat';
|
||||
import { useChatMicTimeoutMsValue } from '../../store-app-chat';
|
||||
|
||||
import type { DLLM } from '~/modules/llms/store-llms';
|
||||
import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DMessageMetadata, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversationId, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { copyToClipboard, supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments, isContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { estimateTextTokens, glueForMessageTokens, marshallWrapDocFragments } from '~/common/stores/chat/chat.tokens';
|
||||
import { getConversation, isValidConversation, useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { isMacUser } from '~/common/util/pwaUtils';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { playSoundUrl } from '~/common/util/audioUtils';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useChatOverlayStore } from '~/common/chats/store-chat-overlay';
|
||||
import { useDebouncer } from '~/common/components/useDebouncer';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
@@ -46,12 +50,14 @@ import { providerCommands } from './actile/providerCommands';
|
||||
import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage';
|
||||
import { useActileManager } from './actile/useActileManager';
|
||||
|
||||
import type { AttachmentId } from './attachments/store-attachments';
|
||||
import { Attachments } from './attachments/Attachments';
|
||||
import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
|
||||
import { useAttachments } from './attachments/useAttachments';
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/LLMAttachmentsList';
|
||||
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
|
||||
import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts';
|
||||
|
||||
import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types';
|
||||
import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './composer.types';
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
@@ -62,7 +68,7 @@ import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './buttons/ButtonMic';
|
||||
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
|
||||
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
|
||||
import { ChatModeMenu } from './ChatModeMenu';
|
||||
import { ReplyToBubble } from '../message/ReplyToBubble';
|
||||
import { TokenBadgeMemo } from './TokenBadge';
|
||||
import { TokenProgressbarMemo } from './TokenProgressbar';
|
||||
import { useComposerStartupText } from './store-composer';
|
||||
@@ -94,31 +100,34 @@ export function Composer(props: {
|
||||
isMobile?: boolean;
|
||||
chatLLM: DLLM | null;
|
||||
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
conversationId: DConversationId | null;
|
||||
targetConversationId: DConversationId | null;
|
||||
capabilityHasT2I: boolean;
|
||||
isMulticast: boolean | null;
|
||||
isDeveloperMode: boolean;
|
||||
onAction: (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart) => boolean;
|
||||
onAction: (conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean;
|
||||
onTextImagine: (conversationId: DConversationId, text: string) => void;
|
||||
setIsMulticast: (on: boolean) => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
|
||||
const [micContinuation, setMicContinuation] = React.useState(false);
|
||||
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const {
|
||||
chatExecuteMode,
|
||||
chatExecuteModeSendColor, chatExecuteModeSendLabel,
|
||||
chatExecuteMenuComponent, chatExecuteMenuShown, showChatExecuteMenu,
|
||||
} = useChatExecuteMode(props.capabilityHasT2I, !!props.isMobile);
|
||||
|
||||
// external state
|
||||
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
|
||||
const { labsAttachScreenCapture, labsBeam, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
const { labsAttachScreenCapture, labsCameraDesktop, labsShowCost } = useUXLabsStore(useShallow(state => ({
|
||||
labsAttachScreenCapture: state.labsAttachScreenCapture,
|
||||
labsBeam: state.labsBeam,
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
}), shallow);
|
||||
labsShowCost: state.labsShowCost,
|
||||
})));
|
||||
const timeToShowTips = useAppStateStore(state => state.usageCount > 2);
|
||||
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
|
||||
const { novel: explainAltEnter, touch: touchAltEnter } = useUICounter('composer-alt-enter');
|
||||
@@ -126,43 +135,65 @@ export function Composer(props: {
|
||||
const [startupText, setStartupText] = useComposerStartupText();
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
|
||||
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
|
||||
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, abortConversationTemp } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.targetConversationId);
|
||||
return {
|
||||
assistantAbortible: conversation ? !!conversation.abortController : false,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
tokenCount: conversation ? conversation.tokenCount : 0,
|
||||
stopTyping: state.stopTyping,
|
||||
abortConversationTemp: state.abortConversationTemp,
|
||||
};
|
||||
}, shallow);
|
||||
const { inComposer: browsingInComposer } = useBrowseCapability();
|
||||
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoMessage, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
|
||||
useAttachments(browsingInComposer && !composeText.startsWith('/'));
|
||||
}));
|
||||
|
||||
// external overlay state (extra conversationId-dependent state)
|
||||
const conversationOverlayStore = props.targetConversationId
|
||||
? ConversationsManager.getHandler(props.targetConversationId)?.getOverlayStore() || null
|
||||
: null;
|
||||
|
||||
// composer-overlay: for the reply-to state, comes from the conversation overlay
|
||||
const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({
|
||||
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
|
||||
})));
|
||||
|
||||
// don't load URLs if the user is typing a command or there's no capability
|
||||
const enableLoadURLsInComposer = useBrowseCapability().inComposer && !composeText.startsWith('/');
|
||||
|
||||
// attachments-overlay: comes from the attachments slice of the conversation overlay
|
||||
const {
|
||||
/* items */ attachmentDrafts,
|
||||
/* append */ attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoFragments, attachAppendFile,
|
||||
/* take */ attachmentsRemoveAll, attachmentsTakeAllFragments, attachmentsTakeFragmentsByType,
|
||||
} = useAttachmentDrafts(conversationOverlayStore, enableLoadURLsInComposer);
|
||||
|
||||
// attachments derived state
|
||||
const llmAttachmentDrafts = useLLMAttachmentDrafts(attachmentDrafts, props.chatLLM);
|
||||
|
||||
|
||||
// derived state
|
||||
|
||||
const { composerTextAreaRef, targetConversationId, onAction, onTextImagine } = props;
|
||||
const isMobile = !!props.isMobile;
|
||||
const isDesktop = !props.isMobile;
|
||||
const chatLLMId = props.chatLLM?.id || null;
|
||||
const noConversation = !targetConversationId;
|
||||
const noLLM = !props.chatLLM;
|
||||
const showLLMAttachments = chatExecuteModeCanAttach(chatExecuteMode);
|
||||
|
||||
// attachments derived state
|
||||
|
||||
const llmAttachments = useLLMAttachments(_attachments, chatLLMId);
|
||||
|
||||
// tokens derived state
|
||||
|
||||
const tokensComposerText = React.useMemo(() => {
|
||||
if (!debouncedText || !chatLLMId)
|
||||
return 0;
|
||||
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
|
||||
}, [chatLLMId, debouncedText]);
|
||||
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
|
||||
if (tokensComposer > 0)
|
||||
tokensComposer += 4; // every user message has this many surrounding tokens (note: shall depend on llm..)
|
||||
const tokensComposerTextDebounced = React.useMemo(() => {
|
||||
return (debouncedText && props.chatLLM)
|
||||
? estimateTextTokens(debouncedText, props.chatLLM, 'composer text')
|
||||
: 0;
|
||||
}, [props.chatLLM, debouncedText]);
|
||||
let tokensComposer = tokensComposerTextDebounced + (llmAttachmentDrafts.llmTokenCountApprox || 0);
|
||||
if (props.chatLLM && tokensComposer > 0)
|
||||
tokensComposer += glueForMessageTokens(props.chatLLM);
|
||||
const tokensHistory = _historyTokenCount;
|
||||
const tokensReponseMax = (props.chatLLM?.options as LLMOptionsOpenAI /* FIXME: BIG ASSUMPTION */)?.llmResponseTokens || 0;
|
||||
const tokenLimit = props.chatLLM?.contextTokens || 0;
|
||||
const tokenPriceIn = props.chatLLM?.pricing?.chatIn;
|
||||
const tokenPriceOut = props.chatLLM?.pricing?.chatOut;
|
||||
|
||||
|
||||
// Effect: load initial text if queued up (e.g. by /link/share_targe)
|
||||
@@ -174,81 +205,96 @@ export function Composer(props: {
|
||||
}, [setComposeText, setStartupText, startupText]);
|
||||
|
||||
|
||||
// Overlay actions
|
||||
|
||||
const handleReplyToClear = React.useCallback(() => {
|
||||
conversationOverlayStore?.getState().setReplyToText(null);
|
||||
}, [conversationOverlayStore]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (replyToGenerateText)
|
||||
setTimeout(() => composerTextAreaRef.current?.focus(), 1 /* prevent focus theft */);
|
||||
}, [composerTextAreaRef, replyToGenerateText]);
|
||||
|
||||
|
||||
// Primary button
|
||||
|
||||
const { conversationId, onAction } = props;
|
||||
const handleClear = React.useCallback(() => {
|
||||
setComposeText('');
|
||||
attachmentsRemoveAll();
|
||||
handleReplyToClear();
|
||||
}, [attachmentsRemoveAll, handleReplyToClear, setComposeText]);
|
||||
|
||||
const handleSendAction = React.useCallback((_chatModeId: ChatModeId, composerText: string): boolean => {
|
||||
if (!conversationId)
|
||||
|
||||
const handleSendAction = React.useCallback(async (_chatExecuteMode: ChatExecuteMode, composerText: string): Promise<boolean> => {
|
||||
if (!isValidConversation(targetConversationId)) return false;
|
||||
|
||||
// validate some chat mode inputs
|
||||
const isDraw = _chatExecuteMode === 'generate-image';
|
||||
const isBlank = !composerText.trim();
|
||||
if (isDraw && isBlank)
|
||||
return false;
|
||||
|
||||
// get attachments
|
||||
const multiPartMessage = llmAttachments.getAttachmentsOutputs(composerText || null);
|
||||
if (!multiPartMessage.length)
|
||||
return false;
|
||||
// prepare the fragments: content (if any) and attachments (if allowed, and any)
|
||||
const fragments: (DMessageContentFragment | DMessageAttachmentFragment)[] = [];
|
||||
if (composerText)
|
||||
fragments.push(createTextContentFragment(composerText));
|
||||
|
||||
// send the message
|
||||
const enqueued = onAction(_chatModeId, conversationId, multiPartMessage);
|
||||
if (enqueued) {
|
||||
clearAttachments();
|
||||
setComposeText('');
|
||||
const canAttach = chatExecuteModeCanAttach(_chatExecuteMode);
|
||||
if (canAttach) {
|
||||
const attachmentFragments = await attachmentsTakeAllFragments('global', 'app-chat');
|
||||
fragments.push(...attachmentFragments);
|
||||
}
|
||||
|
||||
if (!fragments.length) {
|
||||
// addSnackbar({ key: 'chat-composer-empty', message: 'Nothing to send', type: 'info' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// send the message - NOTE: if successful, the ownership of the fragments is transferred to the receiver, so we just clear them
|
||||
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
|
||||
const enqueued = onAction(targetConversationId, _chatExecuteMode, fragments, metadata);
|
||||
if (enqueued)
|
||||
handleClear();
|
||||
return enqueued;
|
||||
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
|
||||
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
|
||||
|
||||
const handleSendClicked = React.useCallback(() => {
|
||||
handleSendAction(chatModeId, composeText);
|
||||
}, [chatModeId, composeText, handleSendAction]);
|
||||
|
||||
const handleSendTextBeamClicked = React.useCallback(() => {
|
||||
labsBeam && handleSendAction('generate-text-beam', composeText);
|
||||
}, [composeText, handleSendAction, labsBeam]);
|
||||
const handleSendClicked = React.useCallback(async () => {
|
||||
await handleSendAction(chatExecuteMode, composeText); // 'chat/write/...' button
|
||||
}, [chatExecuteMode, composeText, handleSendAction]);
|
||||
|
||||
const handleSendTextBeamClicked = React.useCallback(async () => {
|
||||
await handleSendAction('beam-content', composeText); // 'beam' button
|
||||
}, [composeText, handleSendAction]);
|
||||
|
||||
const handleStopClicked = React.useCallback(() => {
|
||||
!!props.conversationId && stopTyping(props.conversationId);
|
||||
}, [props.conversationId, stopTyping]);
|
||||
targetConversationId && abortConversationTemp(targetConversationId);
|
||||
}, [abortConversationTemp, targetConversationId]);
|
||||
|
||||
|
||||
// Secondary buttons
|
||||
|
||||
const handleCallClicked = React.useCallback(() => {
|
||||
props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
}, [props.conversationId, systemPurposeId]);
|
||||
targetConversationId && systemPurposeId && launchAppCall(targetConversationId, systemPurposeId);
|
||||
}, [systemPurposeId, targetConversationId]);
|
||||
|
||||
const handleDrawOptionsClicked = React.useCallback(() => {
|
||||
openPreferencesTab(PreferencesTab.Draw);
|
||||
}, [openPreferencesTab]);
|
||||
|
||||
const handleTextImagineClicked = React.useCallback(() => {
|
||||
if (!composeText || !props.conversationId)
|
||||
return;
|
||||
props.onTextImagine(props.conversationId, composeText);
|
||||
if (!composeText || !targetConversationId) return;
|
||||
onTextImagine(targetConversationId, composeText);
|
||||
setComposeText('');
|
||||
}, [composeText, props, setComposeText]);
|
||||
|
||||
|
||||
// Mode menu
|
||||
|
||||
const handleModeSelectorHide = React.useCallback(() => {
|
||||
setChatModeMenuAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
}, [handleModeSelectorHide]);
|
||||
}, [composeText, onTextImagine, setComposeText, targetConversationId]);
|
||||
|
||||
|
||||
// Actiles
|
||||
|
||||
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
|
||||
if (props.composerTextAreaRef.current) {
|
||||
const textArea = props.composerTextAreaRef.current;
|
||||
if (composerTextAreaRef.current) {
|
||||
const textArea = composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
const cursorPos = textArea.selectionStart;
|
||||
|
||||
@@ -265,36 +311,39 @@ export function Composer(props: {
|
||||
const newCursorPos = commandStart + item.label.length + 1;
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
}, [props.composerTextAreaRef, setComposeText]);
|
||||
}, [composerTextAreaRef, setComposeText]);
|
||||
|
||||
const onActileMessageAttach = React.useCallback((item: StarredMessageItem) => {
|
||||
const onActileEmbedMessage = React.useCallback(async ({ conversationId, messageId }: StarredMessageItem) => {
|
||||
// get the message
|
||||
const conversation = getConversation(item.conversationId);
|
||||
const messageToAttach = conversation?.messages.find(m => m.id === item.messageId);
|
||||
if (conversation && messageToAttach && messageToAttach.text) {
|
||||
// Testing with this serialization for LLM. Note it will still be within a multi-part message,
|
||||
// this could be in a titled markdown block. Don't know yet how this fares with different LLMs.
|
||||
const chatTitle = conversationTitle(conversation);
|
||||
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageToAttach.text.trim()}\n`;
|
||||
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageToAttach.text.slice(0, 10)}...`);
|
||||
const conversation = getConversation(conversationId);
|
||||
const messageToEmbed = conversation?.messages.find(m => m.id === messageId);
|
||||
if (conversation && messageToEmbed) {
|
||||
const fragmentsCopy = duplicateDMessageFragments(messageToEmbed.fragments)
|
||||
.filter(isContentFragment);
|
||||
if (fragmentsCopy.length) {
|
||||
const chatTitle = conversationTitle(conversation);
|
||||
const messageText = messageFragmentsReduceText(fragmentsCopy);
|
||||
const label = `${chatTitle} > ${messageText.slice(0, 10)}...`;
|
||||
await attachAppendEgoFragments(fragmentsCopy, label, chatTitle, conversationId, messageId);
|
||||
}
|
||||
}
|
||||
}, [attachAppendEgoMessage]);
|
||||
}, [attachAppendEgoFragments]);
|
||||
|
||||
const actileProviders = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileMessageAttach)];
|
||||
}, [onActileCommandPaste, onActileMessageAttach]);
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
|
||||
}, [onActileCommandPaste, onActileEmbedMessage]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef);
|
||||
|
||||
|
||||
// Text typing
|
||||
// Type...
|
||||
|
||||
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setComposeText(e.target.value);
|
||||
isMobile && actileInterceptTextChange(e.target.value);
|
||||
}, [actileInterceptTextChange, isMobile, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleTextareaKeyDown = React.useCallback(async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// disable keyboard handling if the actile is visible
|
||||
if (actileInterceptKeydown(e))
|
||||
return;
|
||||
@@ -304,15 +353,15 @@ export function Composer(props: {
|
||||
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey) {
|
||||
touchAltEnter();
|
||||
handleSendAction('append-user', composeText);
|
||||
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
|
||||
touchAltEnter();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
|
||||
if (labsBeam && ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey))) {
|
||||
touchCtrlEnter();
|
||||
handleSendAction('generate-text-beam', composeText);
|
||||
if ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey)) {
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
touchCtrlEnter();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -321,12 +370,12 @@ export function Composer(props: {
|
||||
touchShiftEnter();
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
if (!assistantAbortible)
|
||||
handleSendAction(chatModeId, composeText);
|
||||
await handleSendAction(chatExecuteMode, composeText); // enter -> send
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, labsBeam, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
@@ -352,26 +401,26 @@ export function Composer(props: {
|
||||
nextText = nextText ? nextText + ' ' + transcript : transcript;
|
||||
|
||||
// auto-send (mic continuation mode) if requested
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantAbortible;
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !noConversation; //&& assistantAbortible;
|
||||
const notUserStop = result.doneReason !== 'manual';
|
||||
if (autoSend) {
|
||||
if (notUserStop)
|
||||
playSoundUrl('/sounds/mic-off-mid.mp3');
|
||||
handleSendAction(chatModeId, nextText);
|
||||
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
|
||||
void handleSendAction(chatExecuteMode, nextText); // fire/forget
|
||||
} else {
|
||||
if (!micContinuation && notUserStop)
|
||||
playSoundUrl('/sounds/mic-off-mid.mp3');
|
||||
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
|
||||
if (nextText) {
|
||||
props.composerTextAreaRef.current?.focus();
|
||||
composerTextAreaRef.current?.focus();
|
||||
setComposeText(nextText);
|
||||
}
|
||||
}
|
||||
}, [chatModeId, composeText, handleSendAction, micContinuation, props.composerTextAreaRef, props.conversationId, setComposeText]);
|
||||
}, [chatExecuteMode, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]);
|
||||
|
||||
const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } =
|
||||
useSpeechRecognition(onSpeechResultCallback, chatMicTimeoutMs || 2000);
|
||||
|
||||
useGlobalShortcut('m', true, false, false, toggleRecording);
|
||||
useGlobalShortcuts([['m', true, false, false, toggleRecording]]);
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
|
||||
@@ -395,7 +444,7 @@ export function Composer(props: {
|
||||
}, [toggleRecording, micContinuationTrigger]);
|
||||
|
||||
|
||||
// Attachments
|
||||
// Attachment Up
|
||||
|
||||
const handleAttachCtrlV = React.useCallback((event: React.ClipboardEvent) => {
|
||||
if (attachAppendDataTransfer(event.clipboardData, 'paste', false) === 'as_files')
|
||||
@@ -406,12 +455,12 @@ export function Composer(props: {
|
||||
void attachAppendFile('camera', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
|
||||
|
||||
const handleAttachScreenCapture = React.useCallback((file: File) => {
|
||||
void attachAppendFile('screencapture', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
|
||||
|
||||
const handleAttachFilePicker = React.useCallback(async () => {
|
||||
try {
|
||||
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
|
||||
@@ -423,25 +472,24 @@ export function Composer(props: {
|
||||
}
|
||||
}, [attachAppendFile]);
|
||||
|
||||
useGlobalShortcut(supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems);
|
||||
useGlobalShortcuts([[supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems]]);
|
||||
|
||||
const handleAttachmentInlineText = React.useCallback((attachmentId: AttachmentId) => {
|
||||
setComposeText(currentText => {
|
||||
const attachmentOutputs = llmAttachments.getAttachmentOutputs(currentText, attachmentId);
|
||||
const inlinedText = getTextBlockText(attachmentOutputs) || '';
|
||||
removeAttachment(attachmentId);
|
||||
return inlinedText;
|
||||
});
|
||||
}, [llmAttachments, removeAttachment, setComposeText]);
|
||||
|
||||
const handleAttachmentsInlineText = React.useCallback(() => {
|
||||
setComposeText(currentText => {
|
||||
const attachmentsOutputs = llmAttachments.getAttachmentsOutputs(currentText);
|
||||
const inlinedText = getTextBlockText(attachmentsOutputs) || '';
|
||||
clearAttachments();
|
||||
return inlinedText;
|
||||
});
|
||||
}, [clearAttachments, llmAttachments, setComposeText]);
|
||||
// Attachments Down
|
||||
|
||||
const handleAttachmentDraftsAction = React.useCallback((attachmentDraftIdOrAll: AttachmentDraftId | null, action: LLMAttachmentDraftsAction) => {
|
||||
switch (action) {
|
||||
case 'copy-text':
|
||||
const copyFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, false);
|
||||
const copyString = marshallWrapDocFragments(null, copyFragments, false, '\n\n---\n\n');
|
||||
copyToClipboard(copyString, attachmentDraftIdOrAll ? 'Attachment Text' : 'Attachments Text');
|
||||
break;
|
||||
case 'inline-text':
|
||||
const inlineFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, true);
|
||||
setComposeText(currentText => marshallWrapDocFragments(currentText, inlineFragments, 'markdown-code', '\n\n'));
|
||||
break;
|
||||
}
|
||||
}, [attachmentsTakeFragmentsByType, setComposeText]);
|
||||
|
||||
|
||||
// Drag & Drop
|
||||
@@ -470,7 +518,7 @@ export function Composer(props: {
|
||||
|
||||
const handleOverlayDragOver = React.useCallback((e: React.DragEvent) => {
|
||||
eatDragEvent(e);
|
||||
// this makes sure we don't "transfer" (or move) the attachment, but we tell the sender we'll copy it
|
||||
// this makes sure we don't "transfer" (or move) the item, but we tell the sender we'll copy it
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, [eatDragEvent]);
|
||||
|
||||
@@ -489,31 +537,22 @@ export function Composer(props: {
|
||||
}, [attachAppendDataTransfer, eatDragEvent, setComposeText]);
|
||||
|
||||
|
||||
const isText = chatModeId === 'generate-text';
|
||||
const isTextBeam = chatModeId === 'generate-text-beam';
|
||||
const isAppend = chatModeId === 'append-user';
|
||||
const isReAct = chatModeId === 'generate-react';
|
||||
const isDraw = chatModeId === 'generate-image';
|
||||
const isText = chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1';
|
||||
const isTextBeam = chatExecuteMode === 'beam-content';
|
||||
const isAppend = chatExecuteMode === 'append-user';
|
||||
const isReAct = chatExecuteMode === 'react-content';
|
||||
const isDraw = chatExecuteMode === 'generate-image';
|
||||
|
||||
const showChatExtras = isText;
|
||||
const showChatReplyTo = !!replyToGenerateText;
|
||||
const showChatExtras = isText && !showChatReplyTo;
|
||||
|
||||
const buttonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
const sendButtonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
|
||||
const buttonColor: ColorPaletteProp =
|
||||
assistantAbortible ? 'warning'
|
||||
: isReAct ? 'success'
|
||||
: isTextBeam ? 'primary'
|
||||
: isDraw ? 'warning'
|
||||
: 'primary';
|
||||
const sendButtonColor: ColorPaletteProp = assistantAbortible ? 'warning' : chatExecuteModeSendColor;
|
||||
|
||||
const buttonText =
|
||||
isAppend ? 'Write'
|
||||
: isReAct ? 'ReAct'
|
||||
: isTextBeam ? 'Beam'
|
||||
: isDraw ? 'Draw'
|
||||
: 'Chat';
|
||||
const sendButtonLabel = chatExecuteModeSendLabel;
|
||||
|
||||
const buttonIcon =
|
||||
const sendButtonIcon =
|
||||
micContinuation ? <AutoModeIcon />
|
||||
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
|
||||
: isReAct ? <PsychologyIcon />
|
||||
@@ -525,15 +564,16 @@ export function Composer(props: {
|
||||
isDraw ? 'Describe an idea or a drawing...'
|
||||
: isReAct ? 'Multi-step reasoning question...'
|
||||
: isTextBeam ? 'Beam: combine the smarts of models...'
|
||||
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
|
||||
: props.capabilityHasT2I ? 'Chat · /beam · /draw · drop files...'
|
||||
: 'Chat · /react · drop files...';
|
||||
: showChatReplyTo ? 'Chat about this'
|
||||
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
|
||||
: props.capabilityHasT2I ? 'Chat · /beam · /draw · drop files...'
|
||||
: 'Chat · /react · drop files...';
|
||||
if (isDesktop && timeToShowTips) {
|
||||
if (explainShiftEnter)
|
||||
textPlaceholder += !enterIsNewline ? '\n\n💡 Shift + Enter to add a new line' : '\n\n💡 Shift + Enter to send';
|
||||
else if (explainAltEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Alt + Enter to just append the message');
|
||||
else if (labsBeam && explainCtrlEnter)
|
||||
else if (explainCtrlEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Ctrl + Enter to beam');
|
||||
}
|
||||
|
||||
@@ -541,45 +581,50 @@ export function Composer(props: {
|
||||
<Box aria-label='User Message' component='section' sx={props.sx}>
|
||||
<Grid container spacing={{ xs: 1, md: 2 }}>
|
||||
|
||||
{/* [Mobile: top, Desktop: left] */}
|
||||
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'flex-start' }}>
|
||||
|
||||
{/* Start buttons column */}
|
||||
<Box sx={{
|
||||
flexGrow: 0,
|
||||
display: 'grid', gap: 1,
|
||||
}}>
|
||||
{isMobile ? <>
|
||||
{/* [Mobile, Col1] Mic, Insert Multi-modal content, and Broadcast buttons */}
|
||||
{isMobile && (
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [mobile] Mic button */}
|
||||
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{/* [mobile] [+] button */}
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
{showLLMAttachments && (
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* [Mobile] MultiChat button */}
|
||||
{props.isMulticast !== null && <ButtonMultiChatMemo isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
</> : <>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [Desktop, Col1] Insert Multi-modal content buttons */}
|
||||
{isDesktop && showLLMAttachments && (
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
|
||||
|
||||
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
|
||||
{/* Attach*/}
|
||||
@@ -597,28 +642,30 @@ export function Composer(props: {
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
|
||||
|
||||
</>}
|
||||
</Box>
|
||||
</Box>)}
|
||||
|
||||
{/* [ Textarea + Overlays + Mic | Attachments ] */}
|
||||
|
||||
{/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
// layout
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachments)
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachment drafts)
|
||||
}}>
|
||||
|
||||
{/* Textarea + Mic buttons + Mic/Drag overlay */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Text Edit + Mic buttons + MicOverlay & DragOverlay */}
|
||||
<Box sx={{ position: 'relative' /* for overlays */ }}>
|
||||
|
||||
{/* Edit box with inner Token Progress bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box sx={{ position: 'relative' /* for TokenBadge & TokenProgress */ }}>
|
||||
|
||||
<Textarea
|
||||
variant='outlined'
|
||||
color={isDraw ? 'warning' : isReAct ? 'success' : undefined}
|
||||
autoFocus
|
||||
minRows={isMobile ? 4 : 5}
|
||||
minRows={isMobile ? 4 : showChatReplyTo ? 4 : 5}
|
||||
maxRows={isMobile ? 8 : 10}
|
||||
placeholder={textPlaceholder}
|
||||
value={composeText}
|
||||
@@ -629,6 +676,7 @@ export function Composer(props: {
|
||||
onPasteCapture={handleAttachCtrlV}
|
||||
// onFocusCapture={handleFocusModeOn}
|
||||
// onBlurCapture={handleFocusModeOff}
|
||||
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToClear} className='reply-to-bubble' />}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'send',
|
||||
@@ -636,21 +684,21 @@ export function Composer(props: {
|
||||
...(isSpeechEnabled && { pr: { md: 5 } }),
|
||||
// mb: 0.5, // no need; the outer container already has enough p (for TokenProgressbar)
|
||||
},
|
||||
ref: props.composerTextAreaRef,
|
||||
ref: composerTextAreaRef,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': { backgroundColor: 'background.popup' },
|
||||
'&:focus-within': { backgroundColor: 'background.popup', '.reply-to-bubble': { backgroundColor: 'background.popup' } },
|
||||
lineHeight: lineHeightTextareaMd,
|
||||
}} />
|
||||
|
||||
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
|
||||
<TokenProgressbarMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} />
|
||||
{!showChatReplyTo && tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
|
||||
<TokenProgressbarMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} />
|
||||
)}
|
||||
|
||||
{!!tokenLimit && (
|
||||
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} showExcess absoluteBottomRight />
|
||||
{!showChatReplyTo && tokenLimit > 0 && (
|
||||
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
)}
|
||||
|
||||
</Box>
|
||||
@@ -678,20 +726,32 @@ export function Composer(props: {
|
||||
{/* overlay: Mic */}
|
||||
{micIsRunning && (
|
||||
<Card
|
||||
color='primary' variant='soft' invertedColors
|
||||
color='primary' variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
|
||||
// alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.solidBg',
|
||||
borderRadius: 'sm',
|
||||
zIndex: zIndexComposerOverlayMic,
|
||||
px: 1.5, py: 1,
|
||||
pl: 1.5,
|
||||
pr: { xs: 1.5, md: 5 },
|
||||
py: 0.625,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<Typography sx={{
|
||||
color: 'primary.softColor',
|
||||
lineHeight: lineHeightTextareaMd,
|
||||
'& .interim': {
|
||||
textDecoration: 'underline',
|
||||
textDecorationThickness: '0.25em',
|
||||
textDecorationColor: 'rgba(var(--joy-palette-primary-mainChannel) / 0.1)',
|
||||
textDecorationSkipInk: 'none',
|
||||
textUnderlineOffset: '0.25em',
|
||||
},
|
||||
}}>
|
||||
<Typography>
|
||||
{speechInterimResult.transcript}{' '}
|
||||
<span style={{ opacity: 0.8 }}>{speechInterimResult.interimTranscript}</span>
|
||||
<span className={speechInterimResult.interimTranscript !== 'Listening...' ? 'interim' : undefined}>{speechInterimResult.interimTranscript}</span>
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
@@ -715,18 +775,20 @@ export function Composer(props: {
|
||||
</Box>
|
||||
|
||||
{/* Render any Attachments & menu items */}
|
||||
<Attachments
|
||||
llmAttachments={llmAttachments}
|
||||
onAttachmentInlineText={handleAttachmentInlineText}
|
||||
onAttachmentsClear={clearAttachments}
|
||||
onAttachmentsInlineText={handleAttachmentsInlineText}
|
||||
/>
|
||||
{!!conversationOverlayStore && showLLMAttachments && (
|
||||
<LLMAttachmentsList
|
||||
attachmentDraftsStoreApi={conversationOverlayStore}
|
||||
llmAttachmentDrafts={llmAttachmentDrafts}
|
||||
onAttachmentDraftsAction={handleAttachmentDraftsAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
</Box></Grid>
|
||||
|
||||
|
||||
{/* [Mobile: bottom, Desktop: right] */}
|
||||
<Grid xs={12} md={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' } as const}>
|
||||
|
||||
@@ -736,7 +798,7 @@ export function Composer(props: {
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (showChatExtras
|
||||
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
|
||||
? <ButtonCallMemo isMobile disabled={noConversation || noLLM} onClick={handleCallClicked} />
|
||||
: isDraw
|
||||
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
@@ -744,28 +806,28 @@ export function Composer(props: {
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
<ButtonGroup
|
||||
variant={buttonVariant}
|
||||
color={buttonColor}
|
||||
variant={sendButtonVariant}
|
||||
color={sendButtonColor}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: (isMobile && buttonVariant === 'outlined') ? 'background.popup' : undefined,
|
||||
boxShadow: (isMobile && buttonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
|
||||
backgroundColor: (isMobile && sendButtonVariant === 'outlined') ? 'background.popup' : undefined,
|
||||
boxShadow: (isMobile && sendButtonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${sendButtonColor}-mainChannel) / 20%)`,
|
||||
}}
|
||||
>
|
||||
{!assistantAbortible ? (
|
||||
<Button
|
||||
key='composer-act'
|
||||
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
fullWidth disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
|
||||
onClick={handleSendClicked}
|
||||
endDecorator={buttonIcon}
|
||||
endDecorator={sendButtonIcon}
|
||||
sx={{ '--Button-gap': '1rem' }}
|
||||
>
|
||||
{micContinuation && 'Voice '}{buttonText}
|
||||
{micContinuation && 'Voice '}{sendButtonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key='composer-stop'
|
||||
fullWidth variant='soft' disabled={!props.conversationId}
|
||||
fullWidth variant='soft' disabled={noConversation}
|
||||
onClick={handleStopClicked}
|
||||
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
|
||||
sx={{ animation: `${animationEnterBelow} 0.1s ease-out` }}
|
||||
@@ -776,14 +838,14 @@ export function Composer(props: {
|
||||
|
||||
{/* [Beam] Open Beam */}
|
||||
{/*{isText && <Tooltip title='Open Beam'>*/}
|
||||
{/* <IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleSendTextBeamClicked}>*/}
|
||||
{/* <IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleSendTextBeamClicked}>*/}
|
||||
{/* <ChatBeamIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/*</Tooltip>}*/}
|
||||
|
||||
{/* [Draw] Imagine */}
|
||||
{isDraw && !!composeText && <Tooltip title='Imagine a drawing prompt'>
|
||||
<IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleTextImagineClicked}>
|
||||
<IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleTextImagineClicked}>
|
||||
<AutoAwesomeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
@@ -791,17 +853,18 @@ export function Composer(props: {
|
||||
{/* Mode expander */}
|
||||
<IconButton
|
||||
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
|
||||
disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor}
|
||||
onClick={handleModeSelectorShow}
|
||||
disabled={noConversation || noLLM || chatExecuteMenuShown}
|
||||
onClick={showChatExecuteMenu}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* [desktop] secondary-top buttons */}
|
||||
{labsBeam && isDesktop && showChatExtras && !assistantAbortible && (
|
||||
{isDesktop && showChatExtras && !assistantAbortible && (
|
||||
<ButtonBeamMemo
|
||||
disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
|
||||
hasContent={!!composeText}
|
||||
onClick={handleSendTextBeamClicked}
|
||||
/>
|
||||
)}
|
||||
@@ -811,11 +874,11 @@ export function Composer(props: {
|
||||
{/* [desktop] Multicast switch (under the Chat button) */}
|
||||
{isDesktop && props.isMulticast !== null && <ButtonMultiChatMemo multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{/* [desktop] secondary bottom-buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{showChatExtras && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
{showChatExtras && <ButtonCallMemo disabled={noConversation || noLLM} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -827,20 +890,13 @@ export function Composer(props: {
|
||||
|
||||
</Grid>
|
||||
|
||||
{/* Mode selector */}
|
||||
{!!chatModeMenuAnchor && (
|
||||
<ChatModeMenu
|
||||
isMobile={isMobile}
|
||||
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
|
||||
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
|
||||
capabilityHasTTI={props.capabilityHasT2I}
|
||||
/>
|
||||
)}
|
||||
{/* Execution Mode Menu */}
|
||||
{chatExecuteMenuComponent}
|
||||
|
||||
{/* Camera */}
|
||||
{/* Camera (when open) */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Actile */}
|
||||
{/* Actile (when open) */}
|
||||
{actileComponent}
|
||||
|
||||
</Box>
|
||||
|
||||
@@ -3,41 +3,81 @@ import * as React from 'react';
|
||||
import { Badge, Box, ColorPaletteProp, Tooltip } from '@mui/joy';
|
||||
|
||||
|
||||
function alignRight(value: number, columnSize: number = 7) {
|
||||
function alignRight(value: number, columnSize: number = 8) {
|
||||
const str = value.toLocaleString();
|
||||
return str.padStart(columnSize);
|
||||
}
|
||||
|
||||
function formatCost(cost: number) {
|
||||
return cost < 1
|
||||
? (cost * 100).toFixed(cost < 0.010 ? 2 : 1) + ' ¢'
|
||||
: '$ ' + cost.toFixed(2);
|
||||
}
|
||||
|
||||
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number): {
|
||||
color: ColorPaletteProp, message: string, remainingTokens: number
|
||||
|
||||
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number, tokenPriceIn?: number, tokenPriceOut?: number): {
|
||||
color: ColorPaletteProp,
|
||||
message: string,
|
||||
remainingTokens: number,
|
||||
costMax?: number,
|
||||
costMin?: number,
|
||||
} {
|
||||
const usedTokens = directTokens + (historyTokens || 0) + (responseMaxTokens || 0);
|
||||
const remainingTokens = tokenLimit - usedTokens;
|
||||
const usedInputTokens = directTokens + (historyTokens || 0);
|
||||
const usedMaxTokens = usedInputTokens + (responseMaxTokens || 0);
|
||||
const remainingTokens = tokenLimit - usedMaxTokens;
|
||||
const gteLimit = (remainingTokens <= 0 && tokenLimit > 0);
|
||||
|
||||
// message
|
||||
let message: string = gteLimit ? '⚠️ ' : '';
|
||||
|
||||
// costs
|
||||
let costMax: number | undefined = undefined;
|
||||
let costMin: number | undefined = undefined;
|
||||
|
||||
// no limit: show used tokens only
|
||||
if (!tokenLimit) {
|
||||
message += `Requested: ${usedTokens.toLocaleString()} tokens`;
|
||||
message += `Requested: ${usedMaxTokens.toLocaleString()} tokens`;
|
||||
}
|
||||
// has full information (d + i < l)
|
||||
else if (historyTokens || responseMaxTokens) {
|
||||
message +=
|
||||
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
|
||||
`▶ ${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
|
||||
` = Model max tokens: ${alignRight(tokenLimit)}\n` +
|
||||
` - This message: ${alignRight(directTokens)}\n` +
|
||||
` - History: ${alignRight(historyTokens || 0)}\n` +
|
||||
` - Max response: ${alignRight(responseMaxTokens || 0)}`;
|
||||
|
||||
// add the price, if available
|
||||
if (tokenPriceIn || tokenPriceOut) {
|
||||
costMin = tokenPriceIn ? usedInputTokens * tokenPriceIn / 1E6 : undefined;
|
||||
const costOutMax = (tokenPriceOut && responseMaxTokens) ? responseMaxTokens * tokenPriceOut / 1E6 : undefined;
|
||||
if (costMin || costOutMax) {
|
||||
message += `\n\n\n▶ Chat Turn Cost (max, approximate)\n`;
|
||||
|
||||
if (costMin) message += '\n' +
|
||||
` Input tokens: ${alignRight(usedInputTokens)}\n` +
|
||||
` Input Price $/M: ${tokenPriceIn!.toFixed(2).padStart(8)}\n` +
|
||||
` Input cost: ${('$' + costMin!.toFixed(4)).padStart(8)}\n`;
|
||||
|
||||
if (costOutMax) message += '\n' +
|
||||
` Max output tokens: ${alignRight(responseMaxTokens!)}\n` +
|
||||
` Output Price $/M: ${tokenPriceOut!.toFixed(2).padStart(8)}\n` +
|
||||
` Max output cost: ${('$' + costOutMax!.toFixed(4)).padStart(8)}\n`;
|
||||
|
||||
if (costMin) message += '\n' +
|
||||
` > Min turn cost: ${formatCost(costMin).padStart(8)}`;
|
||||
costMax = (costMin && costOutMax) ? costMin + costOutMax : undefined;
|
||||
if (costMax) message += '\n' +
|
||||
` < Max turn cost: ${formatCost(costMax).padStart(8)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cleaner mode: d + ? < R (total is the remaining in this case)
|
||||
else {
|
||||
message +=
|
||||
`${(tokenLimit + usedTokens).toLocaleString()} available tokens after deleting this\n\n` +
|
||||
`${(tokenLimit + usedMaxTokens).toLocaleString()} available tokens after deleting this\n\n` +
|
||||
` = Currently free: ${alignRight(tokenLimit)}\n` +
|
||||
` + This message: ${alignRight(usedTokens)}`;
|
||||
` + This message: ${alignRight(usedMaxTokens)}`;
|
||||
}
|
||||
|
||||
const color: ColorPaletteProp =
|
||||
@@ -47,23 +87,21 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, h
|
||||
? 'warning'
|
||||
: 'primary';
|
||||
|
||||
return { color, message, remainingTokens };
|
||||
return { color, message, remainingTokens, costMax, costMin };
|
||||
}
|
||||
|
||||
|
||||
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.JSX.Element }) =>
|
||||
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.ReactElement }) =>
|
||||
<Tooltip
|
||||
placement={props.placement}
|
||||
variant={props.color !== 'primary' ? 'solid' : 'soft'} color={props.color}
|
||||
title={props.message
|
||||
? <Box sx={{ p: 2, whiteSpace: 'pre' }}>
|
||||
{props.message}
|
||||
</Box>
|
||||
: null
|
||||
}
|
||||
title={props.message ? <Box sx={{ p: 2, whiteSpace: 'pre' }}>{props.message}</Box> : null}
|
||||
sx={{
|
||||
fontFamily: 'code',
|
||||
boxShadow: 'xl',
|
||||
// fontSize: '0.8125rem',
|
||||
border: '1px solid',
|
||||
borderColor: `${props.color}.outlinedColor`,
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
@@ -76,38 +114,80 @@ export const TokenTooltip = (props: { message: string | null, color: ColorPalett
|
||||
export const TokenBadgeMemo = React.memo(TokenBadge);
|
||||
|
||||
function TokenBadge(props: {
|
||||
direct: number, history?: number, responseMax?: number, limit: number,
|
||||
showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean,
|
||||
direct: number,
|
||||
history?: number,
|
||||
responseMax?: number,
|
||||
limit: number,
|
||||
|
||||
tokenPriceIn?: number,
|
||||
tokenPriceOut?: number,
|
||||
|
||||
enableHover?: boolean,
|
||||
showCost?: boolean
|
||||
showExcess?: boolean,
|
||||
absoluteBottomRight?: boolean,
|
||||
inline?: boolean,
|
||||
}) {
|
||||
|
||||
const { message, color, remainingTokens } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
|
||||
// state
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
|
||||
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
|
||||
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
|
||||
? Math.abs(remainingTokens)
|
||||
: props.direct;
|
||||
const { message, color, remainingTokens, costMax, costMin } =
|
||||
tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax, props.tokenPriceIn, props.tokenPriceOut);
|
||||
|
||||
|
||||
// handlers
|
||||
const handleHoverEnter = React.useCallback(() => setIsHovering(true), []);
|
||||
|
||||
const handleHoverLeave = React.useCallback(() => setIsHovering(false), []);
|
||||
|
||||
|
||||
let badgeValue: string;
|
||||
|
||||
const showAltCosts = !!props.showCost && !!costMax && costMin !== undefined;
|
||||
if (showAltCosts) {
|
||||
badgeValue = (!props.enableHover || isHovering)
|
||||
? '< ' + formatCost(costMax)
|
||||
: '> ' + formatCost(costMin);
|
||||
} else {
|
||||
|
||||
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
|
||||
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
|
||||
? Math.abs(remainingTokens)
|
||||
: props.direct;
|
||||
|
||||
badgeValue = value.toLocaleString();
|
||||
}
|
||||
|
||||
const shallHide = !props.direct && remainingTokens >= 0 && !showAltCosts;
|
||||
if (shallHide) return null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='solid' color={color} max={100000}
|
||||
invisible={!props.direct && remainingTokens >= 0}
|
||||
badgeContent={
|
||||
<TokenTooltip color={color} message={message}>
|
||||
<span>{value.toLocaleString()}</span>
|
||||
</TokenTooltip>
|
||||
}
|
||||
sx={{
|
||||
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
|
||||
cursor: 'help',
|
||||
}}
|
||||
slotProps={{
|
||||
badge: {
|
||||
sx: {
|
||||
fontFamily: 'code',
|
||||
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
|
||||
<TokenTooltip color={color} message={message} placement='top-end'>
|
||||
<Badge
|
||||
variant='soft' color={color} max={1000000}
|
||||
// invisible={shallHide}
|
||||
onMouseEnter={props.enableHover ? handleHoverEnter : undefined}
|
||||
onMouseLeave={props.enableHover ? handleHoverLeave : undefined}
|
||||
badgeContent={badgeValue}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
|
||||
cursor: 'help',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
badge: {
|
||||
sx: {
|
||||
// the badge (not the tooltip)
|
||||
// boxShadow: 'sm',
|
||||
fontFamily: 'code',
|
||||
fontSize: 'xs',
|
||||
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TokenTooltip>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,15 @@ import { tokensPrettyMath, TokenTooltip } from './TokenBadge';
|
||||
*/
|
||||
export const TokenProgressbarMemo = React.memo(TokenProgressbar);
|
||||
|
||||
function TokenProgressbar(props: { direct: number, history: number, responseMax: number, limit: number }) {
|
||||
function TokenProgressbar(props: {
|
||||
direct: number,
|
||||
history: number,
|
||||
responseMax: number,
|
||||
limit: number,
|
||||
|
||||
tokenPriceIn?: number,
|
||||
tokenPriceOut?: number,
|
||||
}) {
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -40,7 +48,7 @@ function TokenProgressbar(props: { direct: number, history: number, responseMax:
|
||||
const overflowColor = theme.palette.danger.softColor;
|
||||
|
||||
// tooltip message/color
|
||||
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
|
||||
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax, props.tokenPriceIn, props.tokenPriceOut);
|
||||
|
||||
// sizes
|
||||
const containerHeight = 8;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { conversationTitle, DConversationId, messageHasUserFlag, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
@@ -27,7 +29,7 @@ export function providerStarredMessage(onMessageSeelect: (item: StarredMessageIt
|
||||
messageId: message.id,
|
||||
// looks
|
||||
key: message.id,
|
||||
label: conversationTitle(conversation) + ' - ' + message.text.slice(0, 32) + '...',
|
||||
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
|
||||
// description: message.text.slice(32, 100),
|
||||
Icon: undefined,
|
||||
} satisfies StarredMessageItem);
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, ListDivider, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
|
||||
import type { LLMAttachment } from './useLLMAttachments';
|
||||
import { useAttachmentsStore } from './store-attachments';
|
||||
|
||||
|
||||
// enable for debugging
|
||||
export const DEBUG_ATTACHMENTS = true;
|
||||
|
||||
|
||||
export function AttachmentMenu(props: {
|
||||
llmAttachment: LLMAttachment,
|
||||
menuAnchor: HTMLAnchorElement,
|
||||
isPositionFirst: boolean,
|
||||
isPositionLast: boolean,
|
||||
onAttachmentInlineText: (attachmentId: string) => void,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
|
||||
const isPositionFixed = props.isPositionFirst && props.isPositionLast;
|
||||
|
||||
const {
|
||||
attachment,
|
||||
attachmentOutputs,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputTextInlineable,
|
||||
tokenCountApprox,
|
||||
} = props.llmAttachment;
|
||||
|
||||
const {
|
||||
id: aId,
|
||||
input: aInput,
|
||||
converters: aConverters,
|
||||
converterIdx: aConverterIdx,
|
||||
outputs: aOutputs,
|
||||
} = attachment;
|
||||
|
||||
|
||||
// operations
|
||||
|
||||
const { onClose, onAttachmentInlineText } = props;
|
||||
|
||||
const handleInlineText = React.useCallback(() => {
|
||||
onClose();
|
||||
onAttachmentInlineText(aId);
|
||||
}, [aId, onAttachmentInlineText, onClose]);
|
||||
|
||||
const handleMoveUp = React.useCallback(() => {
|
||||
useAttachmentsStore.getState().moveAttachment(aId, -1);
|
||||
}, [aId]);
|
||||
|
||||
const handleMoveDown = React.useCallback(() => {
|
||||
useAttachmentsStore.getState().moveAttachment(aId, 1);
|
||||
}, [aId]);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
onClose();
|
||||
useAttachmentsStore.getState().removeAttachment(aId);
|
||||
}, [aId, onClose]);
|
||||
|
||||
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
|
||||
return useAttachmentsStore.getState().setConverterIdx(aId, converterIdx);
|
||||
}, [aId]);
|
||||
|
||||
// const handleSummarizeText = React.useCallback(() => {
|
||||
// onAttachmentSummarizeText(aId);
|
||||
// }, [aId, onAttachmentSummarizeText]);
|
||||
|
||||
const handleCopyOutputToClipboard = React.useCallback(() => {
|
||||
if (attachmentOutputs.length >= 1) {
|
||||
const concat = attachmentOutputs.map(output => {
|
||||
if (output.type === 'text-block')
|
||||
return output.text;
|
||||
else if (output.type === 'image-part')
|
||||
return output.base64Url;
|
||||
else
|
||||
return null;
|
||||
}).join('\n\n---\n\n');
|
||||
copyToClipboard(concat.trim(), 'Converted attachment');
|
||||
}
|
||||
}, [attachmentOutputs]);
|
||||
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
dense placement='top'
|
||||
open anchorEl={props.menuAnchor} onClose={props.onClose}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
{/* Move Arrows */}
|
||||
{!isPositionFixed && <Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MenuItem
|
||||
disabled={props.isPositionFirst}
|
||||
onClick={handleMoveUp}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={props.isPositionLast}
|
||||
onClick={handleMoveDown}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</MenuItem>
|
||||
</Box>}
|
||||
{!isPositionFixed && <ListDivider sx={{ mt: 0 }} />}
|
||||
|
||||
{/* Render Converters as menu items */}
|
||||
{/*{!isUnconvertible && <ListItem>*/}
|
||||
{/* <Typography level='body-md'>*/}
|
||||
{/* Attach as:*/}
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>}*/}
|
||||
{!isUnconvertible && aConverters.map((c, idx) =>
|
||||
<MenuItem
|
||||
disabled={c.disabled}
|
||||
key={'c-' + c.id}
|
||||
onClick={async () => idx !== aConverterIdx && await handleSetConverterIdx(idx)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Radio checked={idx === aConverterIdx} />
|
||||
</ListItemDecorator>
|
||||
{c.unsupported
|
||||
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
|
||||
: c.name}
|
||||
</MenuItem>,
|
||||
)}
|
||||
{!isUnconvertible && <ListDivider />}
|
||||
|
||||
{DEBUG_ATTACHMENTS && !!aInput && (
|
||||
<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
<Box>
|
||||
{!!aInput && <Typography level='body-xs'>
|
||||
🡐 {aInput.mimeType}, {aInput.dataSize.toLocaleString()} bytes
|
||||
</Typography>}
|
||||
{/*<Typography level='body-xs'>*/}
|
||||
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === aConverterIdx) ? '*' : ''}`)).join(', ')}*/}
|
||||
{/*</Typography>*/}
|
||||
<Typography level='body-xs'>
|
||||
🡒 {isOutputMissing ? 'empty' : aOutputs.map(output => `${output.type}, ${output.type === 'text-block' ? output.text.length.toLocaleString() : '(base64 image)'} bytes`).join(' · ')}
|
||||
</Typography>
|
||||
{!!tokenCountApprox && <Typography level='body-xs'>
|
||||
🡒 {tokenCountApprox.toLocaleString()} tokens
|
||||
</Typography>}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
)}
|
||||
{DEBUG_ATTACHMENTS && !!aInput && <ListDivider />}
|
||||
|
||||
{/* Destructive Operations */}
|
||||
{/*<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
|
||||
{/* Copy*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
|
||||
{/* Shrink*/}
|
||||
{/*</MenuItem>*/}
|
||||
<MenuItem onClick={handleInlineText} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRemove}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove
|
||||
</MenuItem>
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
|
||||
import type { AttachmentId } from './store-attachments';
|
||||
import type { LLMAttachments } from './useLLMAttachments';
|
||||
import { AttachmentItem } from './AttachmentItem';
|
||||
import { AttachmentMenu } from './AttachmentMenu';
|
||||
|
||||
|
||||
/**
|
||||
* Renderer of attachments, with menus, etc.
|
||||
*/
|
||||
export function Attachments(props: {
|
||||
llmAttachments: LLMAttachments,
|
||||
onAttachmentInlineText: (attachmentId: AttachmentId) => void,
|
||||
onAttachmentsClear: () => void,
|
||||
onAttachmentsInlineText: () => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [confirmClearAttachments, setConfirmClearAttachments] = React.useState<boolean>(false);
|
||||
const [itemMenu, setItemMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentId: AttachmentId } | null>(null);
|
||||
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
// derived state
|
||||
const { llmAttachments, onAttachmentsClear, onAttachmentInlineText, onAttachmentsInlineText } = props;
|
||||
|
||||
const { attachments, isOutputTextInlineable } = llmAttachments;
|
||||
|
||||
const hasAttachments = attachments.length >= 1;
|
||||
|
||||
// derived item menu state
|
||||
|
||||
const itemMenuAnchor = itemMenu?.anchor;
|
||||
const itemMenuAttachmentId = itemMenu?.attachmentId;
|
||||
const itemMenuAttachment = itemMenuAttachmentId ? attachments.find(la => la.attachment.id === itemMenu.attachmentId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachment ? attachments.indexOf(itemMenuAttachment) : -1;
|
||||
|
||||
|
||||
// item menu
|
||||
|
||||
const handleItemMenuToggle = React.useCallback((attachmentId: AttachmentId, anchor: HTMLAnchorElement) => {
|
||||
handleOverallMenuHide();
|
||||
setItemMenu(prev => prev?.attachmentId === attachmentId ? null : { anchor, attachmentId });
|
||||
}, []);
|
||||
|
||||
const handleItemMenuHide = React.useCallback(() => {
|
||||
setItemMenu(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// item menu operations
|
||||
|
||||
const handleAttachmentInlineText = React.useCallback((attachmentId: string) => {
|
||||
handleItemMenuHide();
|
||||
onAttachmentInlineText(attachmentId);
|
||||
}, [handleItemMenuHide, onAttachmentInlineText]);
|
||||
|
||||
|
||||
// menu
|
||||
|
||||
const handleOverallMenuHide = () => setOverallMenuAnchor(null);
|
||||
|
||||
const handleOverallMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
};
|
||||
|
||||
|
||||
// overall operations
|
||||
|
||||
const handleAttachmentsInlineText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentsInlineText();
|
||||
}, [onAttachmentsInlineText]);
|
||||
|
||||
const handleClearAttachments = () => setConfirmClearAttachments(true);
|
||||
|
||||
const handleClearAttachmentsConfirmed = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
setConfirmClearAttachments(false);
|
||||
onAttachmentsClear();
|
||||
}, [onAttachmentsClear]);
|
||||
|
||||
|
||||
// no components without attachments
|
||||
if (!hasAttachments)
|
||||
return null;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Attachments bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
{/* Horizontally scrollable Attachments */}
|
||||
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
|
||||
{attachments.map((llmAttachment) =>
|
||||
<AttachmentItem
|
||||
key={llmAttachment.attachment.id}
|
||||
llmAttachment={llmAttachment}
|
||||
menuShown={llmAttachment.attachment.id === itemMenuAttachmentId}
|
||||
onItemMenuToggle={handleItemMenuToggle}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
onClick={handleOverallMenuToggle}
|
||||
onContextMenu={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
position: 'absolute', right: 0, top: 0,
|
||||
backgroundColor: 'neutral.softDisabledBg',
|
||||
}}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Attachment Menu */}
|
||||
{!!itemMenuAnchor && !!itemMenuAttachment && (
|
||||
<AttachmentMenu
|
||||
llmAttachment={itemMenuAttachment}
|
||||
menuAnchor={itemMenuAnchor}
|
||||
isPositionFirst={itemMenuIndex === 0}
|
||||
isPositionLast={itemMenuIndex === attachments.length - 1}
|
||||
onAttachmentInlineText={handleAttachmentInlineText}
|
||||
onClose={handleItemMenuHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Overall Menu */}
|
||||
{!!overallMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='top-start'
|
||||
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
|
||||
>
|
||||
<MenuItem onClick={handleAttachmentsInlineText} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline <span style={{ opacity: 0.5 }}>text attachments</span>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClearAttachments}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Clear
|
||||
</MenuItem>
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
{/* 'Clear' Confirmation */}
|
||||
{confirmClearAttachments && (
|
||||
<ConfirmationModal
|
||||
open onClose={() => setConfirmClearAttachments(false)} onPositive={handleClearAttachmentsConfirmed}
|
||||
title='Confirm Removal'
|
||||
positiveActionText='Remove All'
|
||||
confirmationText={`This action will remove all (${attachments.length}) attachments. Do you want to proceed?`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { createBase36Uid } from '~/common/util/textUtils';
|
||||
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
|
||||
import { pdfToText } from '~/common/util/pdfUtils';
|
||||
|
||||
import type { Attachment, AttachmentConverter, AttachmentId, AttachmentInput, AttachmentSource } from './store-attachments';
|
||||
import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
|
||||
|
||||
// extensions to treat as plain text
|
||||
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
|
||||
|
||||
// mimetypes to treat as plain text
|
||||
const PLAIN_TEXT_MIMETYPES: string[] = [
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Attachment object.
|
||||
*/
|
||||
export function attachmentCreate(source: AttachmentSource, checkDuplicates: AttachmentId[]): Attachment {
|
||||
return {
|
||||
id: createBase36Uid(checkDuplicates),
|
||||
source: source,
|
||||
label: 'Loading...',
|
||||
ref: '',
|
||||
inputLoading: false,
|
||||
inputError: null,
|
||||
input: undefined,
|
||||
converters: [],
|
||||
converterIdx: null,
|
||||
outputsConverting: false,
|
||||
outputs: [],
|
||||
// metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously loads the input for an Attachment object.
|
||||
*
|
||||
* @param {Readonly<AttachmentSource>} source - The source of the attachment.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export async function attachmentLoadInputAsync(source: Readonly<AttachmentSource>, edit: (changes: Partial<Attachment>) => void) {
|
||||
edit({ inputLoading: true });
|
||||
|
||||
switch (source.media) {
|
||||
|
||||
// Download URL (page, file, ..) and attach as input
|
||||
case 'url':
|
||||
edit({ label: source.refUrl, ref: source.refUrl });
|
||||
try {
|
||||
const page = await callBrowseFetchPage(source.url);
|
||||
if (page.content) {
|
||||
edit({
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: page.content,
|
||||
dataSize: page.content.length,
|
||||
},
|
||||
});
|
||||
} else
|
||||
edit({ inputError: 'No content found at this link' });
|
||||
} catch (error: any) {
|
||||
edit({ inputError: `Issue downloading page: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
|
||||
}
|
||||
break;
|
||||
|
||||
// Attach file as input
|
||||
case 'file':
|
||||
edit({ label: source.refPath, ref: source.refPath });
|
||||
|
||||
// fix missing/wrong mimetypes
|
||||
let mimeType = source.fileWithHandle.type;
|
||||
if (!mimeType) {
|
||||
// see note on 'attachAppendDataTransfer'; this is a fallback for drag/drop missing Mimes sometimes
|
||||
console.warn('Assuming the attachment is text/plain. From:', source.origin, ', name:', source.refPath);
|
||||
mimeType = 'text/plain';
|
||||
} else {
|
||||
// possibly fix wrongly assigned mimetypes (from the extension alone)
|
||||
if (!mimeType.startsWith('text/') && PLAIN_TEXT_EXTENSIONS.some(ext => source.refPath.endsWith(ext)))
|
||||
mimeType = 'text/plain';
|
||||
}
|
||||
|
||||
// UX: just a hint of a loading state
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const fileArrayBuffer = await source.fileWithHandle.arrayBuffer();
|
||||
edit({
|
||||
input: {
|
||||
mimeType,
|
||||
data: fileArrayBuffer,
|
||||
dataSize: fileArrayBuffer.byteLength,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
edit({ inputError: `Issue loading file: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (source.textHtml && source.textPlain) {
|
||||
edit({
|
||||
label: 'Rich Text',
|
||||
ref: '',
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: source.textPlain,
|
||||
dataSize: source.textPlain!.length,
|
||||
altMimeType: 'text/html',
|
||||
altData: source.textHtml,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const text = source.textHtml || source.textPlain || '';
|
||||
edit({
|
||||
label: 'Text',
|
||||
ref: '',
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: text,
|
||||
dataSize: text.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ego':
|
||||
edit({
|
||||
label: source.label,
|
||||
ref: source.blockTitle,
|
||||
input: {
|
||||
mimeType: 'ego/message',
|
||||
data: source.textPlain,
|
||||
dataSize: source.textPlain.length,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ inputLoading: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible converters for an Attachment object based on its input type.
|
||||
*
|
||||
* @param {AttachmentSource['media']} sourceType - The media type of the attachment source.
|
||||
* @param {Readonly<AttachmentInput>} input - The input of the attachment.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export function attachmentDefineConverters(sourceType: AttachmentSource['media'], input: Readonly<AttachmentInput>, edit: (changes: Partial<Attachment>) => void) {
|
||||
|
||||
// return all the possible converters for the input
|
||||
const converters: AttachmentConverter[] = [];
|
||||
|
||||
switch (true) {
|
||||
|
||||
// plain text types
|
||||
case PLAIN_TEXT_MIMETYPES.includes(input.mimeType):
|
||||
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
|
||||
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const isHtmlTable = !!input.altData?.startsWith('<table');
|
||||
|
||||
// p1: Tables
|
||||
if (textOriginHtml && isHtmlTable) {
|
||||
converters.push({
|
||||
id: 'rich-text-table',
|
||||
name: 'Markdown Table',
|
||||
});
|
||||
}
|
||||
|
||||
// p2: Text
|
||||
converters.push({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
});
|
||||
|
||||
// p3: Html
|
||||
if (textOriginHtml) {
|
||||
converters.push({
|
||||
id: 'rich-text',
|
||||
name: 'HTML',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
// PDF
|
||||
case ['application/pdf', 'application/x-pdf', 'application/acrobat'].includes(input.mimeType):
|
||||
converters.push({ id: 'pdf-text', name: `PDF To Text` });
|
||||
converters.push({ id: 'pdf-images', name: `PDF To Images`, disabled: true });
|
||||
break;
|
||||
|
||||
// images
|
||||
case input.mimeType.startsWith('image/'):
|
||||
converters.push({ id: 'image', name: `Image (coming soon)` });
|
||||
converters.push({ id: 'image-ocr', name: 'As Text (OCR)' });
|
||||
break;
|
||||
|
||||
// EGO
|
||||
case input.mimeType === 'ego/message':
|
||||
converters.push({ id: 'ego-message-md', name: 'Message' });
|
||||
break;
|
||||
|
||||
// catch-all
|
||||
default:
|
||||
converters.push({ id: 'unhandled', name: `${input.mimeType}`, unsupported: true });
|
||||
converters.push({ id: 'text', name: 'As Text' });
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ converters });
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the input of an Attachment object based on the selected converter.
|
||||
*
|
||||
* @param {Readonly<Attachment>} attachment - The Attachment object to convert.
|
||||
* @param {number | null} converterIdx - The index of the selected conversion in the Attachment object's converters array.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export async function attachmentPerformConversion(attachment: Readonly<Attachment>, converterIdx: number | null, edit: (changes: Partial<Attachment>) => void) {
|
||||
|
||||
// set converter index
|
||||
converterIdx = (converterIdx !== null && converterIdx >= 0 && converterIdx < attachment.converters.length) ? converterIdx : null;
|
||||
edit({
|
||||
converterIdx: converterIdx,
|
||||
outputs: [],
|
||||
});
|
||||
|
||||
// get converter
|
||||
const { ref, input } = attachment;
|
||||
const converter = converterIdx !== null ? attachment.converters[converterIdx] : null;
|
||||
if (!converter || !input)
|
||||
return;
|
||||
|
||||
edit({
|
||||
outputsConverting: true,
|
||||
});
|
||||
|
||||
// input datacould be a string or an ArrayBuffer
|
||||
function inputDataToString(data: string | ArrayBuffer | null | undefined): string {
|
||||
if (typeof data === 'string')
|
||||
return data;
|
||||
if (data instanceof ArrayBuffer)
|
||||
return new TextDecoder().decode(data);
|
||||
return '';
|
||||
}
|
||||
|
||||
// apply converter to the input
|
||||
const outputs: ComposerOutputMultiPart = [];
|
||||
switch (converter.id) {
|
||||
|
||||
// text as-is
|
||||
case 'text':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: inputDataToString(input.data),
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
// html as-is
|
||||
case 'rich-text':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: input.altData!,
|
||||
title: ref || '\n<!DOCTYPE html>',
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
// html to markdown table
|
||||
case 'rich-text-table':
|
||||
let mdTable: string;
|
||||
try {
|
||||
mdTable = htmlTableToMarkdown(input.altData!, false);
|
||||
} catch (error) {
|
||||
// fallback to text/plain
|
||||
mdTable = inputDataToString(input.data);
|
||||
}
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: mdTable,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'pdf-text':
|
||||
if (!(input.data instanceof ArrayBuffer)) {
|
||||
console.log('Expected ArrayBuffer for PDF converter, got:', typeof input.data);
|
||||
break;
|
||||
}
|
||||
// duplicate the ArrayBuffer to avoid mutation
|
||||
const pdfData = new Uint8Array(input.data.slice(0));
|
||||
const pdfText = await pdfToText(pdfData);
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: pdfText,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'pdf-images':
|
||||
// TODO: extract all pages as individual images
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
// TODO: continue here
|
||||
/*outputs.push({
|
||||
type: 'image-part',
|
||||
base64Url: `data:notImplemented.yet:)`,
|
||||
collapsible: false,
|
||||
});*/
|
||||
break;
|
||||
|
||||
case 'image-ocr':
|
||||
if (!(input.data instanceof ArrayBuffer)) {
|
||||
console.log('Expected ArrayBuffer for Image OCR converter, got:', typeof input.data);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { recognize } = await import('tesseract.js');
|
||||
const buffer = Buffer.from(input.data);
|
||||
const result = await recognize(buffer, undefined, {
|
||||
errorHandler: e => console.error(e),
|
||||
logger: (message) => {
|
||||
if (message.status === 'recognizing text')
|
||||
console.log('OCR progress:', message.progress);
|
||||
},
|
||||
});
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: result.data.text,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ego-message-md':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: inputDataToString(input.data),
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'unhandled':
|
||||
// force the user to explicitly select 'as text' if they want to proceed
|
||||
break;
|
||||
}
|
||||
|
||||
// update
|
||||
edit({
|
||||
outputsConverting: false,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
|
||||
/// REDUCER
|
||||
|
||||
import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
|
||||
|
||||
const [reducerText, setReducerText] = React.useState('');
|
||||
const [reducerTextTokens, setReducerTextTokens] = React.useState(0);
|
||||
|
||||
{reducerText?.length >= 1 &&
|
||||
<ContentReducer
|
||||
initialText={reducerText} initialTokens={reducerTextTokens} tokenLimit={remainingTokens}
|
||||
onReducedText={handleReducedText} onClose={handleReducerClose}
|
||||
/>
|
||||
}
|
||||
const handleReducerClose = () => setReducerText('');
|
||||
|
||||
const handleReducedText = (text: string) => {
|
||||
handleReducerClose();
|
||||
setComposeText(_t => _t + text);
|
||||
};
|
||||
|
||||
const handleAttachFiles = async (files: FileList, overrideFileNames?: string[]): Promise<void> => {
|
||||
|
||||
// see how we fare on budget
|
||||
if (chatLLMId) {
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger') ?? 0;
|
||||
|
||||
// simple trigger for the reduction dialog
|
||||
if (newTextTokens > remainingTokens) {
|
||||
setReducerTextTokens(newTextTokens);
|
||||
setReducerText(newText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// within the budget, so just append
|
||||
setComposeText(text => expandPromptTemplate(PromptTemplates.Concatenate, { text: newText })(text));
|
||||
|
||||
|
||||
|
||||
*/
|
||||
@@ -1,208 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
import { attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync, attachmentPerformConversion } from './pipeline';
|
||||
|
||||
|
||||
// Attachment Types
|
||||
|
||||
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
|
||||
export type AttachmentSourceOriginFile = 'camera' | 'screencapture' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
|
||||
|
||||
export type AttachmentSource = {
|
||||
media: 'url';
|
||||
url: string;
|
||||
refUrl: string;
|
||||
} | {
|
||||
media: 'file';
|
||||
origin: AttachmentSourceOriginFile,
|
||||
fileWithHandle: FileWithHandle;
|
||||
refPath: string;
|
||||
} | {
|
||||
media: 'text';
|
||||
method: 'clipboard-read' | AttachmentSourceOriginDTO;
|
||||
textPlain?: string;
|
||||
textHtml?: string;
|
||||
} | {
|
||||
media: 'ego';
|
||||
method: 'ego-message';
|
||||
label: string;
|
||||
blockTitle: string;
|
||||
textPlain: string;
|
||||
};
|
||||
|
||||
|
||||
export type AttachmentInput = {
|
||||
mimeType: string; // Original MIME type of the file
|
||||
data: string | ArrayBuffer; // The original data of the attachment
|
||||
dataSize: number; // Size of the original data in bytes
|
||||
altMimeType?: string; // Alternative MIME type for the input
|
||||
altData?: string; // Alternative data for the input
|
||||
// preview?: AttachmentPreview; // Preview of the input
|
||||
};
|
||||
|
||||
|
||||
export type AttachmentConverterType =
|
||||
| 'text' | 'rich-text' | 'rich-text-table'
|
||||
| 'pdf-text' | 'pdf-images'
|
||||
| 'image' | 'image-ocr'
|
||||
| 'ego-message-md'
|
||||
| 'unhandled';
|
||||
|
||||
export type AttachmentConverter = {
|
||||
id: AttachmentConverterType;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
unsupported?: boolean;
|
||||
// outputType: ComposerOutputPartType; // The type of the output after conversion
|
||||
// isAutonomous: boolean; // Whether the conversion does not require user input
|
||||
// isAsync: boolean; // Whether the conversion is asynchronous
|
||||
// progress: number; // Conversion progress percentage (0..1)
|
||||
// errorMessage?: string; // Error message if the conversion failed
|
||||
}
|
||||
|
||||
|
||||
export type AttachmentId = string;
|
||||
|
||||
export type Attachment = {
|
||||
readonly id: AttachmentId;
|
||||
readonly source: AttachmentSource,
|
||||
label: string;
|
||||
ref: string; // will be used in ```ref\n...``` for instance
|
||||
|
||||
inputLoading: boolean;
|
||||
inputError: string | null;
|
||||
input?: AttachmentInput;
|
||||
|
||||
// options to convert the input
|
||||
converters: AttachmentConverter[]; // List of available converters for this attachment
|
||||
converterIdx: number | null; // Index of the selected converter
|
||||
|
||||
outputsConverting: boolean;
|
||||
outputs: ComposerOutputMultiPart; // undefined: not yet converted, []: conversion failed, [ {}+ ]: conversion succeeded
|
||||
|
||||
// metadata: {
|
||||
// size?: number; // Size of the attachment in bytes
|
||||
// creationDate?: Date; // Creation date of the file
|
||||
// modifiedDate?: Date; // Last modified date of the file
|
||||
// altText?: string; // Alternative text for images for screen readers
|
||||
// };
|
||||
};
|
||||
|
||||
|
||||
/*export type AttachmentPreview = {
|
||||
renderer: 'noPreview',
|
||||
title: string; // A title for the preview
|
||||
} | {
|
||||
renderer: 'textPreview'
|
||||
fileName: string; // The name of the file
|
||||
snippet: string; // A text snippet for documents
|
||||
tooltip?: string; // A tooltip for the preview
|
||||
} | {
|
||||
renderer: 'imagePreview'
|
||||
thumbnail: string; // A thumbnail preview for images, videos, etc.
|
||||
tooltip?: string; // A tooltip for the preview
|
||||
};*/
|
||||
|
||||
|
||||
/// Store
|
||||
|
||||
interface AttachmentsStore {
|
||||
|
||||
attachments: Attachment[];
|
||||
|
||||
createAttachment: (source: AttachmentSource) => Promise<void>;
|
||||
clearAttachments: () => void;
|
||||
removeAttachment: (attachmentId: AttachmentId) => void;
|
||||
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) => void;
|
||||
setConverterIdx: (attachmentId: AttachmentId, converterIdx: number | null) => Promise<void>;
|
||||
|
||||
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) => void;
|
||||
_getAttachment: (attachmentId: AttachmentId) => Attachment | undefined;
|
||||
|
||||
}
|
||||
|
||||
export const useAttachmentsStore = create<AttachmentsStore>()(
|
||||
(_set, _get) => ({
|
||||
|
||||
attachments: [],
|
||||
|
||||
createAttachment: async (source: AttachmentSource) => {
|
||||
const { attachments, _getAttachment, _editAttachment, setConverterIdx } = _get();
|
||||
|
||||
const attachment = attachmentCreate(source, attachments.map(a => a.id));
|
||||
|
||||
_set({
|
||||
attachments: [...attachments, attachment],
|
||||
});
|
||||
|
||||
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachment.id, changes);
|
||||
|
||||
// 1.Resolve the Input
|
||||
await attachmentLoadInputAsync(source, editFn);
|
||||
const loaded = _getAttachment(attachment.id);
|
||||
if (!loaded || !loaded.input)
|
||||
return;
|
||||
|
||||
// 2. Define the I->O Converters
|
||||
attachmentDefineConverters(source.media, loaded.input, editFn);
|
||||
const defined = _getAttachment(attachment.id);
|
||||
if (!defined || !defined.converters.length || defined.converterIdx !== null)
|
||||
return;
|
||||
|
||||
// 3. Select the first Converter
|
||||
const firstEnabledIndex = defined.converters.findIndex(_c => !_c.disabled);
|
||||
await setConverterIdx(attachment.id, firstEnabledIndex > -1 ? firstEnabledIndex : 0);
|
||||
},
|
||||
|
||||
clearAttachments: () => _set({
|
||||
attachments: [],
|
||||
}),
|
||||
|
||||
removeAttachment: (attachmentId: AttachmentId) =>
|
||||
_set(state => ({
|
||||
attachments: state.attachments.filter(attachment => attachment.id !== attachmentId),
|
||||
})),
|
||||
|
||||
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) =>
|
||||
_set(state => {
|
||||
const attachments = [...state.attachments];
|
||||
const currentIdx = attachments.findIndex(a => a.id === attachmentId);
|
||||
|
||||
// If the attachment is not found, or if trying to move beyond the array boundaries, no move is needed
|
||||
if (currentIdx === -1 || (currentIdx === 0 && delta === -1) || (currentIdx === attachments.length - 1 && delta === 1))
|
||||
return state;
|
||||
|
||||
// Swap the attachment with the adjacent one in the direction of delta
|
||||
const targetIdx = currentIdx + delta;
|
||||
[attachments[currentIdx], attachments[targetIdx]] = [attachments[targetIdx], attachments[currentIdx]];
|
||||
|
||||
return { attachments };
|
||||
}),
|
||||
|
||||
setConverterIdx: async (attachmentId: AttachmentId, converterIdx: number | null) => {
|
||||
const { _getAttachment, _editAttachment } = _get();
|
||||
const attachment = _getAttachment(attachmentId);
|
||||
if (!attachment || attachment.converterIdx === converterIdx)
|
||||
return;
|
||||
|
||||
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachmentId, changes);
|
||||
|
||||
await attachmentPerformConversion(attachment, converterIdx, editFn);
|
||||
},
|
||||
|
||||
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) =>
|
||||
_set(state => ({
|
||||
attachments: state.attachments.map((attachment: Attachment): Attachment =>
|
||||
attachment.id === attachmentId
|
||||
? { ...attachment, ...(typeof update === 'function' ? update(attachment) : update) }
|
||||
: attachment,
|
||||
),
|
||||
})),
|
||||
|
||||
_getAttachment: (attachmentId: AttachmentId) =>
|
||||
_get().attachments.find(a => a.id === attachmentId),
|
||||
|
||||
}),
|
||||
);
|
||||
@@ -1,149 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
|
||||
import type { Attachment, AttachmentId } from './store-attachments';
|
||||
import type { ComposerOutputMultiPart, ComposerOutputPartType } from '../composer.types';
|
||||
|
||||
|
||||
export interface LLMAttachments {
|
||||
attachments: LLMAttachment[];
|
||||
getAttachmentOutputs: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
|
||||
getAttachmentsOutputs: (initialTextBlockText: string | null) => ComposerOutputMultiPart;
|
||||
isOutputAttacheable: boolean;
|
||||
isOutputTextInlineable: boolean;
|
||||
tokenCountApprox: number;
|
||||
}
|
||||
|
||||
export interface LLMAttachment {
|
||||
attachment: Attachment;
|
||||
attachmentOutputs: ComposerOutputMultiPart;
|
||||
isUnconvertible: boolean;
|
||||
isOutputMissing: boolean;
|
||||
isOutputAttachable: boolean;
|
||||
isOutputTextInlineable: boolean;
|
||||
tokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function useLLMAttachments(attachments: Attachment[], chatLLMId: DLLMId | null): LLMAttachments {
|
||||
return React.useMemo(() => {
|
||||
|
||||
// HACK: in the future, switch to LLM capabilities (LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, etc.)
|
||||
const supportsImages = !!chatLLMId?.endsWith('-vision-preview');
|
||||
const supportedOutputPartTypes: ComposerOutputPartType[] = supportsImages ? ['text-block', 'image-part'] : ['text-block'];
|
||||
|
||||
const llmAttachments = attachments.map(attachment => toLLMAttachment(attachment, supportedOutputPartTypes, chatLLMId));
|
||||
|
||||
const getAttachmentOutputs = (initialTextBlockText: string | null, attachmentId: AttachmentId): ComposerOutputMultiPart => {
|
||||
// get outputs of a specific attachment
|
||||
const outputs = attachments.find(a => a.id === attachmentId)?.outputs || [];
|
||||
return attachmentCollapseOutputs(initialTextBlockText, outputs);
|
||||
};
|
||||
|
||||
const getAttachmentsOutputs = (initialTextBlockText: string | null): ComposerOutputMultiPart => {
|
||||
// accumulate all outputs of all attachments
|
||||
const allOutputs = llmAttachments.reduce((acc, a) => acc.concat(a.attachment.outputs), [] as ComposerOutputMultiPart);
|
||||
return attachmentCollapseOutputs(initialTextBlockText, allOutputs);
|
||||
};
|
||||
|
||||
return {
|
||||
attachments: llmAttachments,
|
||||
getAttachmentOutputs,
|
||||
getAttachmentsOutputs,
|
||||
isOutputAttacheable: llmAttachments.every(a => a.isOutputAttachable),
|
||||
isOutputTextInlineable: llmAttachments.every(a => a.isOutputTextInlineable),
|
||||
tokenCountApprox: llmAttachments.reduce((acc, a) => acc + (a.tokenCountApprox || 0), 0),
|
||||
};
|
||||
}, [attachments, chatLLMId]);
|
||||
}
|
||||
|
||||
export function getTextBlockText(outputs: ComposerOutputMultiPart): string | null {
|
||||
const textOutputs = outputs.filter(part => part.type === 'text-block');
|
||||
return (textOutputs.length === 1 && textOutputs[0].type === 'text-block') ? textOutputs[0].text : null;
|
||||
}
|
||||
|
||||
|
||||
function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: ComposerOutputPartType[], llmForTokenCount: DLLMId | null): LLMAttachment {
|
||||
const { converters, outputs } = attachment;
|
||||
|
||||
const isUnconvertible = converters.length === 0;
|
||||
const isOutputMissing = outputs.length === 0;
|
||||
const isOutputAttachable = areAllOutputsSupported(outputs, supportedOutputPartTypes);
|
||||
const isOutputTextInlineable = areAllOutputsSupported(outputs, supportedOutputPartTypes.filter(pt => pt === 'text-block'));
|
||||
|
||||
const attachmentOutputs = attachmentCollapseOutputs(null, outputs);
|
||||
const tokenCountApprox = llmForTokenCount
|
||||
? attachmentOutputs.reduce((acc, output) => {
|
||||
if (output.type === 'text-block')
|
||||
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 0);
|
||||
console.warn('Unhandled token preview for output type:', output.type);
|
||||
return acc;
|
||||
}, 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
attachment,
|
||||
attachmentOutputs,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputAttachable,
|
||||
isOutputTextInlineable,
|
||||
tokenCountApprox,
|
||||
};
|
||||
}
|
||||
|
||||
function areAllOutputsSupported(outputs: ComposerOutputMultiPart, supportedOutputPartTypes: ComposerOutputPartType[]) {
|
||||
return outputs.length
|
||||
? outputs.every(output => supportedOutputPartTypes.includes(output.type))
|
||||
: false;
|
||||
}
|
||||
|
||||
function attachmentCollapseOutputs(initialTextBlockText: string | null, outputs: ComposerOutputMultiPart): ComposerOutputMultiPart {
|
||||
const accumulatedOutputs: ComposerOutputMultiPart = [];
|
||||
|
||||
// if there's initial text, make it a collapsible default (unquited) text block
|
||||
if (initialTextBlockText !== null) {
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: initialTextBlockText,
|
||||
title: null,
|
||||
collapsible: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate attachment outputs of the same type and 'collapsible' into a single object of that type.
|
||||
for (const output of outputs) {
|
||||
const last = accumulatedOutputs[accumulatedOutputs.length - 1];
|
||||
|
||||
// accumulationg over an existing part of the same type
|
||||
if (last && last.type === output.type && output.collapsible) {
|
||||
switch (last.type) {
|
||||
case 'text-block':
|
||||
last.text += `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``;
|
||||
break;
|
||||
default:
|
||||
console.warn('Unhandled collapsing for output type:', output.type);
|
||||
}
|
||||
}
|
||||
// start a new part
|
||||
else {
|
||||
if (output.type === 'text-block') {
|
||||
// THIS IS NOT CORRECT - we seem to be doing it just for downstream token counting - FIX IT
|
||||
// Do not serialize here
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
|
||||
title: null,
|
||||
collapsible: false, // Wrong
|
||||
});
|
||||
} else {
|
||||
accumulatedOutputs.push(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedOutputs;
|
||||
}
|
||||
@@ -11,10 +11,14 @@ import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
const desktopLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
Combine the answers from multiple models<br />
|
||||
{/*{platformAwareKeystrokes('Ctrl + Enter')}*/}
|
||||
<KeyStroke combo='Ctrl + Enter' sx={{ mt: 0.5, mb: 0.25 }} />
|
||||
</Box>;
|
||||
|
||||
const desktopLegendNoContent =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
Enter the text to Beam, then press this
|
||||
</Box>;
|
||||
|
||||
const mobileSx: SxProps = {
|
||||
mr: { xs: 1, md: 2 },
|
||||
};
|
||||
@@ -31,13 +35,13 @@ const desktopSx: SxProps = {
|
||||
|
||||
export const ButtonBeamMemo = React.memo(ButtonBeam);
|
||||
|
||||
function ButtonBeam(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
|
||||
function ButtonBeam(props: { isMobile?: boolean, disabled?: boolean, hasContent?: boolean, onClick: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
|
||||
<ChatBeamIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={desktopLegend}>
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={props.hasContent ? desktopLegend : desktopLegendNoContent}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<ChatBeamIcon />} sx={desktopSx}>
|
||||
Beam
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type ComposerOutputPartType = 'text-block' | 'image-part';
|
||||
|
||||
export type ComposerOutputPart = {
|
||||
type: 'text-block',
|
||||
text: string,
|
||||
title: string | null,
|
||||
collapsible: boolean,
|
||||
} | {
|
||||
// TODO: not implemented yet
|
||||
type: 'image-part',
|
||||
base64Url: string,
|
||||
collapsible: false,
|
||||
};
|
||||
|
||||
export type ComposerOutputMultiPart = ComposerOutputPart[];
|
||||
+44
-43
@@ -4,6 +4,9 @@ import { Box, Button, CircularProgress, ColorPaletteProp, Sheet, Typography } fr
|
||||
import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PermMediaOutlinedIcon from '@mui/icons-material/PermMediaOutlined';
|
||||
import PhotoSizeSelectLargeOutlinedIcon from '@mui/icons-material/PhotoSizeSelectLargeOutlined';
|
||||
import PhotoSizeSelectSmallOutlinedIcon from '@mui/icons-material/PhotoSizeSelectSmallOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import PivotTableChartIcon from '@mui/icons-material/PivotTableChart';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
@@ -14,8 +17,8 @@ import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { ellipsizeFront, ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
|
||||
import type { Attachment, AttachmentConverterType, AttachmentId } from './store-attachments';
|
||||
import type { LLMAttachment } from './useLLMAttachments';
|
||||
import type { AttachmentDraft, AttachmentDraftConverterType, AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
|
||||
|
||||
// default attachment width
|
||||
@@ -66,20 +69,23 @@ const InputErrorIndicator = () =>
|
||||
<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />;
|
||||
|
||||
|
||||
const converterTypeToIconMap: { [key in AttachmentConverterType]: React.ComponentType<any> } = {
|
||||
const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.ComponentType<any> } = {
|
||||
'text': TextFieldsIcon,
|
||||
'rich-text': CodeIcon,
|
||||
'rich-text-table': PivotTableChartIcon,
|
||||
'pdf-text': PictureAsPdfIcon,
|
||||
'pdf-images': PictureAsPdfIcon,
|
||||
'image': ImageOutlinedIcon,
|
||||
'pdf-images': PermMediaOutlinedIcon,
|
||||
'image-original': ImageOutlinedIcon,
|
||||
'image-resized-high': PhotoSizeSelectLargeOutlinedIcon,
|
||||
'image-resized-low': PhotoSizeSelectSmallOutlinedIcon,
|
||||
'image-to-default': ImageOutlinedIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'ego-message-md': TelegramIcon,
|
||||
'ego-fragments-inlined': TelegramIcon,
|
||||
'unhandled': TextureIcon,
|
||||
};
|
||||
|
||||
function attachmentConverterIcon(attachment: Attachment) {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
function attachmentConverterIcon(attachmentDraft: AttachmentDraft) {
|
||||
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
|
||||
if (converter && converter.id) {
|
||||
const Icon = converterTypeToIconMap[converter.id] ?? null;
|
||||
if (Icon)
|
||||
@@ -88,56 +94,51 @@ function attachmentConverterIcon(attachment: Attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachmentLabelText(attachment: Attachment): string {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
if (converter && attachment.label === 'Rich Text') {
|
||||
function attachmentLabelText(attachmentDraft: AttachmentDraft): string {
|
||||
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
|
||||
if (converter && attachmentDraft.label === 'Rich Text') {
|
||||
if (converter.id === 'rich-text-table')
|
||||
return 'Rich Table';
|
||||
if (converter.id === 'rich-text')
|
||||
return 'Rich HTML';
|
||||
}
|
||||
return ellipsizeFront(attachment.label, 24);
|
||||
return ellipsizeFront(attachmentDraft.label, 24);
|
||||
}
|
||||
|
||||
|
||||
export function AttachmentItem(props: {
|
||||
llmAttachment: LLMAttachment,
|
||||
export function LLMAttachmentItem(props: {
|
||||
llmAttachment: LLMAttachmentDraft,
|
||||
menuShown: boolean,
|
||||
onItemMenuToggle: (attachmentId: AttachmentId, anchor: HTMLAnchorElement) => void,
|
||||
onToggleMenu: (attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { attachmentDraft: draft, llmSupportsAllFragments } = props.llmAttachment;
|
||||
|
||||
const { onItemMenuToggle } = props;
|
||||
const isInputLoading = draft.inputLoading;
|
||||
const isInputError = !!draft.inputError;
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputLoading = draft.outputsConverting;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
|
||||
const {
|
||||
attachment,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputAttachable,
|
||||
} = props.llmAttachment;
|
||||
const showWarning = isUnconvertible || (isOutputMissing || !llmSupportsAllFragments);
|
||||
|
||||
const {
|
||||
inputError,
|
||||
inputLoading: isInputLoading,
|
||||
outputsConverting: isOutputLoading,
|
||||
} = attachment;
|
||||
|
||||
const isInputError = !!inputError;
|
||||
const showWarning = isUnconvertible || isOutputMissing || !isOutputAttachable;
|
||||
// handlers
|
||||
|
||||
const { onToggleMenu } = props;
|
||||
|
||||
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
onItemMenuToggle(attachment.id, event.currentTarget);
|
||||
}, [attachment, onItemMenuToggle]);
|
||||
onToggleMenu(draft.id, event.currentTarget);
|
||||
}, [draft.id, onToggleMenu]);
|
||||
|
||||
|
||||
// compose tooltip
|
||||
let tooltip: string | null = '';
|
||||
if (attachment.source.media !== 'text')
|
||||
tooltip += attachment.source.media + ': ';
|
||||
tooltip += attachment.label;
|
||||
if (draft.source.media !== 'text')
|
||||
tooltip += draft.source.media + ': ';
|
||||
tooltip += draft.label;
|
||||
// if (hasInput)
|
||||
// tooltip += `\n(${aInput.mimeType}: ${aInput.dataSize.toLocaleString()} bytes)`;
|
||||
// if (aOutputs && aOutputs.length >= 1)
|
||||
@@ -149,15 +150,15 @@ export function AttachmentItem(props: {
|
||||
if (isInputLoading || isOutputLoading) {
|
||||
color = 'success';
|
||||
} else if (isInputError) {
|
||||
tooltip = `Issue loading the attachment: ${attachment.inputError}\n\n${tooltip}`;
|
||||
color = 'danger';
|
||||
tooltip = props.menuShown ? null
|
||||
: `Issue loading the attachment: ${draft.inputError}\n\n${tooltip}`;
|
||||
} else if (showWarning) {
|
||||
tooltip = props.menuShown
|
||||
? null
|
||||
: isUnconvertible
|
||||
? `Attachments of type '${attachment.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
|
||||
: `Not compatible with the selected LLM or not supported. Please select another format.\n\n${tooltip}`;
|
||||
color = 'warning';
|
||||
tooltip = props.menuShown ? null
|
||||
: isUnconvertible
|
||||
? `Attachments of type '${draft.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
|
||||
: `Not compatible with the selected LLM or file not supported. Please try another format.\n\n${tooltip}`;
|
||||
} else {
|
||||
// all good
|
||||
tooltip = null;
|
||||
@@ -175,7 +176,7 @@ export function AttachmentItem(props: {
|
||||
sx={{ p: 1, whiteSpace: 'break-spaces' }}
|
||||
>
|
||||
{isInputLoading
|
||||
? <LoadingIndicator label={attachment.label} />
|
||||
? <LoadingIndicator label={draft.label} />
|
||||
: (
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -195,11 +196,11 @@ export function AttachmentItem(props: {
|
||||
{isInputError
|
||||
? <InputErrorIndicator />
|
||||
: <>
|
||||
{attachmentConverterIcon(attachment)}
|
||||
{attachmentConverterIcon(draft)}
|
||||
{isOutputLoading
|
||||
? <>Converting <CircularProgress color='success' size='sm' /></>
|
||||
: <Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{attachmentLabelText(attachment)}
|
||||
{attachmentLabelText(draft)}
|
||||
</Typography>}
|
||||
</>}
|
||||
</Button>
|
||||
@@ -0,0 +1,216 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, CircularProgress, Link, ListDivider, ListItem, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
|
||||
import { DMessageAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
import type { LLMAttachmentDraftsAction } from './LLMAttachmentsList';
|
||||
|
||||
|
||||
// enable for debugging
|
||||
export const DEBUG_LLMATTACHMENTS = true;
|
||||
|
||||
|
||||
export function LLMAttachmentMenu(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
llmAttachmentDraft: LLMAttachmentDraft,
|
||||
menuAnchor: HTMLAnchorElement,
|
||||
isPositionFirst: boolean,
|
||||
isPositionLast: boolean,
|
||||
onDraftAction: (attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => void,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
|
||||
const {
|
||||
attachmentDraft: draft,
|
||||
llmSupportsTextFragments,
|
||||
llmTokenCountApprox,
|
||||
} = props.llmAttachmentDraft;
|
||||
|
||||
const draftId = draft.id;
|
||||
const draftInput = draft.input;
|
||||
const isConverting = draft.outputsConverting;
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
|
||||
const isUnmoveable = props.isPositionFirst && props.isPositionLast;
|
||||
|
||||
|
||||
// operations
|
||||
|
||||
const { attachmentDraftsStoreApi, onDraftAction, onClose } = props;
|
||||
|
||||
const handleMoveUp = React.useCallback(() => {
|
||||
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, -1);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
const handleMoveDown = React.useCallback(() => {
|
||||
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, 1);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
onClose();
|
||||
attachmentDraftsStoreApi.getState().removeAttachmentDraft(draftId);
|
||||
}, [draftId, attachmentDraftsStoreApi, onClose]);
|
||||
|
||||
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
|
||||
return attachmentDraftsStoreApi.getState().setAttachmentDraftConverterIdxAndConvert(draftId, converterIdx);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
// const handleSummarizeText = React.useCallback(() => {
|
||||
// onAttachmentDraftSummarizeText(draftId);
|
||||
// }, [draftId, onAttachmentDraftSummarizeText]);
|
||||
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
dense placement='top'
|
||||
open anchorEl={props.menuAnchor} onClose={props.onClose}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
|
||||
{/* Move Arrows */}
|
||||
{!isUnmoveable && <Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MenuItem
|
||||
disabled={props.isPositionFirst}
|
||||
onClick={handleMoveUp}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={props.isPositionLast}
|
||||
onClick={handleMoveDown}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</MenuItem>
|
||||
</Box>}
|
||||
{!isUnmoveable && <ListDivider sx={{ mt: 0 }} />}
|
||||
|
||||
{/* Render Converters as menu items */}
|
||||
{!isUnconvertible && (
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>
|
||||
Attach as:
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
{!isUnconvertible && draft.converters.map((c, idx) =>
|
||||
<MenuItem
|
||||
disabled={c.disabled || isConverting}
|
||||
key={'c-' + c.id}
|
||||
onClick={async () => idx !== draft.converterIdx && await handleSetConverterIdx(idx)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{(isConverting && idx === draft.converterIdx)
|
||||
? <CircularProgress size='sm' sx={{ '--CircularProgress-size': '1.25rem' }} />
|
||||
: <Radio checked={idx === draft.converterIdx} disabled={isConverting} />}
|
||||
</ListItemDecorator>
|
||||
{c.unsupported
|
||||
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
|
||||
: c.name}
|
||||
</MenuItem>,
|
||||
)}
|
||||
{!isUnconvertible && <ListDivider />}
|
||||
|
||||
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && (
|
||||
<ListItem>
|
||||
<ListItemDecorator />
|
||||
<Box>
|
||||
{!!draftInput && (
|
||||
<Typography level='body-sm'>
|
||||
🡐 {draftInput.mimeType} · {draftInput.dataSize.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
{!!draftInput?.altMimeType && (
|
||||
<Typography level='body-sm'>
|
||||
<span style={{ color: 'transparent' }}>🡐</span> {draftInput.altMimeType} · {draftInput.altData?.length.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
{/*<Typography level='body-sm'>*/}
|
||||
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === draft.converterIdx) ? '*' : ''}`)).join(', ')}*/}
|
||||
{/*</Typography>*/}
|
||||
<Box>
|
||||
{isOutputMissing ? (
|
||||
<Typography level='body-sm'>🡒 ...</Typography>
|
||||
) : (
|
||||
draft.outputFragments.map(({ part }, index) => {
|
||||
if (isImageRefPart(part)) {
|
||||
const resolution = part.width && part.height ? `${part.width} x ${part.height}` : 'unknown resolution';
|
||||
const mime = part.dataRef.reftype === 'dblob' ? part.dataRef.mimeType : 'unknown image';
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 {mime/*unic.replace('image/', 'img: ')*/} · {resolution} · {part.dataRef.reftype === 'dblob' ? part.dataRef.bytesSize?.toLocaleString() : '(remote)'}
|
||||
{' · '}
|
||||
<Link onClick={() => showImageDataRefInNewTab(part.dataRef)}>
|
||||
open <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
|
||||
</Link>
|
||||
</Typography>
|
||||
);
|
||||
} else if (part.pt === 'doc') {
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 text: {part.data.text.length.toLocaleString()} bytes
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 {(part as DMessageAttachmentFragment['part']).pt}: (other)
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
})
|
||||
)}
|
||||
{!!llmTokenCountApprox && (
|
||||
<Typography level='body-sm' sx={{ ml: 1.75 }}>
|
||||
~ {llmTokenCountApprox.toLocaleString()} tokens
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)}
|
||||
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && <ListDivider />}
|
||||
|
||||
{/* Destructive Operations */}
|
||||
{/*<MenuItem onClick={handleCopyToClipboard} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
|
||||
{/* Copy*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
|
||||
{/* Shrink*/}
|
||||
{/*</MenuItem>*/}
|
||||
<MenuItem onClick={() => onDraftAction(draftId, 'inline-text')} disabled={!llmSupportsTextFragments || isConverting}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onDraftAction(draftId, 'copy-text')} disabled={!llmSupportsTextFragments || isConverting}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy text
|
||||
</MenuItem>
|
||||
<ListDivider />
|
||||
<MenuItem onClick={handleRemove}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove
|
||||
</MenuItem>
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
|
||||
|
||||
import type { LLMAttachmentDrafts } from './useLLMAttachmentDrafts';
|
||||
import { LLMAttachmentItem } from './LLMAttachmentItem';
|
||||
import { LLMAttachmentMenu } from './LLMAttachmentMenu';
|
||||
|
||||
|
||||
export type LLMAttachmentDraftsAction = 'inline-text' | 'copy-text';
|
||||
|
||||
|
||||
/**
|
||||
* Renderer of attachment drafts, with menus, etc.
|
||||
*/
|
||||
export function LLMAttachmentsList(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
llmAttachmentDrafts: LLMAttachmentDrafts,
|
||||
onAttachmentDraftsAction: (attachmentDraftId: AttachmentDraftId | null, actionId: LLMAttachmentDraftsAction) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [confirmClearAttachmentDrafts, setConfirmClearAttachmentDrafts] = React.useState<boolean>(false);
|
||||
const [draftMenu, setDraftMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentDraftId: AttachmentDraftId } | null>(null);
|
||||
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
// derived state
|
||||
|
||||
const { llmAttachmentDrafts, canInlineSomeFragments } = props.llmAttachmentDrafts;
|
||||
|
||||
const hasAttachments = llmAttachmentDrafts.length >= 1;
|
||||
|
||||
// derived item menu state
|
||||
|
||||
const itemMenuAnchor = draftMenu?.anchor;
|
||||
const itemMenuAttachmentDraftId = draftMenu?.attachmentDraftId;
|
||||
const itemMenuAttachmentDraft = itemMenuAttachmentDraftId ? llmAttachmentDrafts.find(la => la.attachmentDraft.id === draftMenu.attachmentDraftId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachmentDraft ? llmAttachmentDrafts.indexOf(itemMenuAttachmentDraft) : -1;
|
||||
|
||||
|
||||
// overall menu
|
||||
|
||||
const { onAttachmentDraftsAction } = props;
|
||||
|
||||
const handleOverallMenuHide = React.useCallback(() => setOverallMenuAnchor(null), []);
|
||||
|
||||
const handleOverallMenuToggle = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.shiftKey && console.log(llmAttachmentDrafts);
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, [llmAttachmentDrafts]);
|
||||
|
||||
const handleOverallCopyText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentDraftsAction(null, 'copy-text');
|
||||
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
const handleOverallInlineText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentDraftsAction(null, 'inline-text');
|
||||
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
const handleOverallClear = React.useCallback(() => setConfirmClearAttachmentDrafts(true), []);
|
||||
|
||||
const handleOverallClearConfirmed = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
setConfirmClearAttachmentDrafts(false);
|
||||
props.attachmentDraftsStoreApi.getState().removeAllAttachmentDrafts();
|
||||
}, [handleOverallMenuHide, props.attachmentDraftsStoreApi]);
|
||||
|
||||
|
||||
// item menu
|
||||
|
||||
const handleDraftMenuHide = React.useCallback(() => setDraftMenu(null), []);
|
||||
|
||||
const handleDraftMenuToggle = React.useCallback((attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => {
|
||||
handleOverallMenuHide();
|
||||
setDraftMenu(prev => prev?.attachmentDraftId === attachmentDraftId ? null : { anchor, attachmentDraftId });
|
||||
}, [handleOverallMenuHide]);
|
||||
|
||||
const handleDraftAction = React.useCallback((attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => {
|
||||
// pass-through, but close the menu as well, as the action is destructive for the caller
|
||||
handleDraftMenuHide();
|
||||
onAttachmentDraftsAction(attachmentDraftId, actionId);
|
||||
}, [handleDraftMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
|
||||
// no components without attachments
|
||||
if (!hasAttachments)
|
||||
return null;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Attachment Drafts bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
{/* Horizontally scrollable Attachments */}
|
||||
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
|
||||
{llmAttachmentDrafts.map((llmAttachment) =>
|
||||
<LLMAttachmentItem
|
||||
key={llmAttachment.attachmentDraft.id}
|
||||
llmAttachment={llmAttachment}
|
||||
menuShown={llmAttachment.attachmentDraft.id === itemMenuAttachmentDraftId}
|
||||
onToggleMenu={handleDraftMenuToggle}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
onClick={handleOverallMenuToggle}
|
||||
onContextMenu={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
position: 'absolute', right: 0, top: 0,
|
||||
backgroundColor: 'neutral.softDisabledBg',
|
||||
}}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* LLM Draft Menu */}
|
||||
{!!itemMenuAnchor && !!itemMenuAttachmentDraft && !!props.attachmentDraftsStoreApi && (
|
||||
<LLMAttachmentMenu
|
||||
attachmentDraftsStoreApi={props.attachmentDraftsStoreApi}
|
||||
llmAttachmentDraft={itemMenuAttachmentDraft}
|
||||
menuAnchor={itemMenuAnchor}
|
||||
isPositionFirst={itemMenuIndex === 0}
|
||||
isPositionLast={itemMenuIndex === llmAttachmentDrafts.length - 1}
|
||||
onDraftAction={handleDraftAction}
|
||||
onClose={handleDraftMenuHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* All Drafts Menu */}
|
||||
{!!overallMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='top-start'
|
||||
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<MenuItem onClick={handleOverallInlineText} disabled={!canInlineSomeFragments}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline all text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleOverallCopyText} disabled={!canInlineSomeFragments}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy all text
|
||||
</MenuItem>
|
||||
<ListDivider />
|
||||
<MenuItem onClick={handleOverallClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove All{llmAttachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {llmAttachmentDrafts.length} attachments</span> : null}
|
||||
</MenuItem>
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
{/* 'Clear' Confirmation */}
|
||||
{confirmClearAttachmentDrafts && (
|
||||
<ConfirmationModal
|
||||
open onClose={() => setConfirmClearAttachmentDrafts(false)} onPositive={handleOverallClearConfirmed}
|
||||
title='Confirm Removal'
|
||||
positiveActionText='Remove All'
|
||||
confirmationText={`This action will remove all (${llmAttachmentDrafts.length}) attachments. Do you want to proceed?`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DLLM, LLM_IF_OAI_Vision } from '~/modules/llms/store-llms';
|
||||
|
||||
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { estimateTokensForFragments } from '~/common/stores/chat/chat.tokens';
|
||||
|
||||
|
||||
export interface LLMAttachmentDrafts {
|
||||
llmAttachmentDrafts: LLMAttachmentDraft[];
|
||||
canAttachAllFragments: boolean;
|
||||
canInlineSomeFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export interface LLMAttachmentDraft {
|
||||
attachmentDraft: AttachmentDraft;
|
||||
llmSupportsAllFragments: boolean;
|
||||
llmSupportsTextFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function useLLMAttachmentDrafts(attachmentDrafts: AttachmentDraft[], chatLLM: DLLM | null): LLMAttachmentDrafts {
|
||||
return React.useMemo(() => {
|
||||
|
||||
// LLM-dependent multi-modal enablement
|
||||
const supportsImages = !!chatLLM?.interfaces?.includes(LLM_IF_OAI_Vision);
|
||||
const supportedTypes: DMessageAttachmentFragment['part']['pt'][] = supportsImages ? ['image_ref', 'doc'] : ['doc'];
|
||||
const supportedTextTypes: DMessageAttachmentFragment['part']['pt'][] = supportedTypes.filter(pt => pt === 'doc');
|
||||
|
||||
// Add LLM-specific properties to each attachment draft
|
||||
const llmAttachmentDrafts = attachmentDrafts.map((a): LLMAttachmentDraft => ({
|
||||
attachmentDraft: a,
|
||||
llmSupportsAllFragments: !a.outputFragments ? false : a.outputFragments.every(op => supportedTypes.includes(op.part.pt)),
|
||||
llmSupportsTextFragments: !a.outputFragments ? false : a.outputFragments.some(op => supportedTextTypes.includes(op.part.pt)),
|
||||
llmTokenCountApprox: chatLLM
|
||||
? estimateTokensForFragments(a.outputFragments, chatLLM, true, 'useLLMAttachmentDrafts')
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Calculate the overall properties
|
||||
const canAttachAllFragments = llmAttachmentDrafts.every(a => a.llmSupportsAllFragments);
|
||||
const canInlineSomeFragments = llmAttachmentDrafts.some(a => a.llmSupportsTextFragments);
|
||||
const llmTokenCountApprox = chatLLM
|
||||
? llmAttachmentDrafts.reduce((acc, a) => acc + (a.llmTokenCountApprox || 0), 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
llmAttachmentDrafts,
|
||||
canAttachAllFragments,
|
||||
canInlineSomeFragments,
|
||||
llmTokenCountApprox,
|
||||
};
|
||||
}, [attachmentDrafts, chatLLM]);
|
||||
}
|
||||
+22
-18
@@ -10,7 +10,7 @@ import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { animationBackgroundBeamGather, animationColorBeamScatterINV, animationEnterBelow } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ChatBarAltBeam(props: {
|
||||
requiresConfirmation: store.isScattering || store.isGatheringAny || store.raysReady > 0,
|
||||
// actions
|
||||
setIsMaximized: store.setIsMaximized,
|
||||
terminateBeam: store.terminate,
|
||||
terminateBeam: store.terminateKeepingSettings,
|
||||
})));
|
||||
|
||||
|
||||
@@ -59,20 +59,11 @@ export function ChatBarAltBeam(props: {
|
||||
|
||||
|
||||
// intercept esc this beam is focused
|
||||
useGlobalShortcut(ShortcutKeyName.Esc, false, false, false, handleCloseBeam);
|
||||
useGlobalShortcuts([[ShortcutKeyName.Esc, false, false, false, handleCloseBeam]]);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 1, md: 3 }, alignItems: 'center' }}>
|
||||
|
||||
{/* [desktop] maximize button, or a disabled spacer */}
|
||||
{props.isMobile ? null : (
|
||||
<GoodTooltip title='Maximize'>
|
||||
<IconButton size='sm' onClick={handleMaximizeBeam}>
|
||||
<FullscreenRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'center' }}>
|
||||
|
||||
{/* Title & Status */}
|
||||
<Typography level='title-md'>
|
||||
@@ -89,11 +80,24 @@ export function ChatBarAltBeam(props: {
|
||||
</Typography>
|
||||
|
||||
{/* Right Close Icon */}
|
||||
<GoodTooltip usePlain title={<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>Close Beam Mode <KeyStroke combo='Esc' /></Box>}>
|
||||
<IconButton aria-label='Close' size='sm' onClick={handleCloseBeam}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
|
||||
{/* [desktop] maximize button, or a disabled spacer */}
|
||||
{!props.isMobile && (
|
||||
<GoodTooltip usePlain title={<Box sx={{ p: 1 }}>Maximize</Box>}>
|
||||
<IconButton size='sm' onClick={handleMaximizeBeam}>
|
||||
<FullscreenRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
|
||||
<GoodTooltip usePlain title={<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>Back to Chat <KeyStroke combo='Esc' /></Box>}>
|
||||
<IconButton aria-label='Close' size='sm' onClick={handleCloseBeam}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
+5
-5
@@ -3,14 +3,14 @@ import * as React from 'react';
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
import { CHAT_NOVEL_TITLE } from '../../AppChat';
|
||||
|
||||
import { FadeInButton } from './ChatDrawerItem';
|
||||
import { FadeInButton } from '../layout-drawer/ChatDrawerItem';
|
||||
|
||||
|
||||
export function ChatBarAltTitle(props: {
|
||||
@@ -29,7 +29,7 @@ export function ChatBarAltTitle(props: {
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
setIsEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
await autoConversationTitle(conversationId, true);
|
||||
setIsEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
|
||||
import { useChatLLMDropdown } from './useLLMDropdown';
|
||||
import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
import { useFolderDropdown } from './folders/useFolderDropdown';
|
||||
import { useFolderDropdown } from './useFolderDropdown';
|
||||
|
||||
|
||||
export function ChatBarDropdowns(props: {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
+8
-9
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { usePurposeStore } from './persona-selector/store-purposes';
|
||||
import { usePurposeStore } from '../persona-selector/store-purposes';
|
||||
|
||||
|
||||
function PersonaDropdown(props: {
|
||||
@@ -17,9 +18,7 @@ function PersonaDropdown(props: {
|
||||
|
||||
// external state
|
||||
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
|
||||
const { zenMode } = useUIPreferencesStore(state => ({
|
||||
zenMode: state.zenMode,
|
||||
}), shallow);
|
||||
const zenMode = useUIPreferencesStore(state => state.zenMode);
|
||||
|
||||
|
||||
// filter by key in the object - must be missing the system purpose ids hidden by the user, or be the currently active one
|
||||
@@ -54,12 +53,12 @@ function PersonaDropdown(props: {
|
||||
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const { systemPurposeId } = useChatStore(state => {
|
||||
const { systemPurposeId } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
|
||||
return {
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
|
||||
|
||||
const handleSetSystemPurposeId = React.useCallback((systemPurposeId: SystemPurposeId | null) => {
|
||||
+19
-15
@@ -9,10 +9,11 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
import { DebounceInputMemo } from '~/common/components/DebounceInput';
|
||||
@@ -28,8 +29,8 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folders/ChatFolderList';
|
||||
import { ChatNavGrouping, ChatSearchSorting, isDrawerSearching, useChatDrawerRenderItems } from './useChatDrawerRenderItems';
|
||||
import { ClearFolderText } from './folders/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../store-app-chat';
|
||||
import { ClearFolderText } from '../layout-bar/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../../store-app-chat';
|
||||
|
||||
|
||||
// this is here to make shallow comparisons work on the next hook
|
||||
@@ -75,7 +76,7 @@ function ChatDrawer(props: {
|
||||
|
||||
// local state
|
||||
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('frequency');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('date');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
|
||||
@@ -83,12 +84,13 @@ function ChatDrawer(props: {
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const {
|
||||
filterHasStars, toggleFilterHasStars,
|
||||
filterHasImageAssets, toggleFilterHasImageAssets,
|
||||
showPersonaIcons, toggleShowPersonaIcons,
|
||||
showRelativeSize, toggleShowRelativeSize,
|
||||
} = useChatDrawerFilters();
|
||||
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
||||
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatDrawerRenderItems(
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, navGrouping, searchSorting, showRelativeSize,
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, filterHasImageAssets, navGrouping, searchSorting, showRelativeSize,
|
||||
);
|
||||
const { contentScaling, showSymbols } = useUIPreferencesStore(useShallow(state => ({
|
||||
contentScaling: state.contentScaling,
|
||||
@@ -159,11 +161,11 @@ function ChatDrawer(props: {
|
||||
|
||||
{!isSearching ? (
|
||||
// Search/Filter default menu: Grouping, Filtering, ...
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 200, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Group By</Typography>
|
||||
</ListItem>
|
||||
{(['date', 'persona'] as const).map(_gName => (
|
||||
{(['date', 'persona', 'dimension'] as Exclude<ChatNavGrouping, false>[]).map(_gName => (
|
||||
<MenuItem
|
||||
key={'group-' + _gName}
|
||||
aria-label={`Group by ${_gName}`}
|
||||
@@ -183,6 +185,10 @@ function ChatDrawer(props: {
|
||||
<ListItemDecorator>{filterHasStars && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Starred <StarOutlineRoundedIcon />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={toggleFilterHasImageAssets}>
|
||||
<ListItemDecorator>{filterHasImageAssets && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Has Images <FormatPaintOutlinedIcon />
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
<ListItem>
|
||||
@@ -214,7 +220,10 @@ function ChatDrawer(props: {
|
||||
</Menu>
|
||||
)}
|
||||
</Dropdown>
|
||||
), [filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize]);
|
||||
), [
|
||||
filterHasImageAssets, filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize,
|
||||
toggleFilterHasImageAssets, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize,
|
||||
]);
|
||||
|
||||
|
||||
return <>
|
||||
@@ -277,7 +286,6 @@ function ChatDrawer(props: {
|
||||
<Button
|
||||
// variant='outlined'
|
||||
variant={disableNewButton ? undefined : 'soft'}
|
||||
color='primary'
|
||||
disabled={disableNewButton}
|
||||
onClick={handleButtonNew}
|
||||
sx={{
|
||||
@@ -285,16 +293,12 @@ function ChatDrawer(props: {
|
||||
justifyContent: 'flex-start',
|
||||
padding: '0px 0.75rem',
|
||||
|
||||
// text size
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'background.popup',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
'--ListItemDecorator-size': 'calc(2.5rem - 1px)', // compensate for the border
|
||||
// backgroundColor: 'background.popup',
|
||||
// boxShadow: (disableNewButton || props.isMobile) ? 'none' : 'xs',
|
||||
// transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
@@ -315,7 +319,7 @@ function ChatDrawer(props: {
|
||||
bottomBarBasis={filteredChatsBarBasis}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationBranch={onConversationBranch}
|
||||
onConversationDelete={handleConversationDeleteNoConfirmation}
|
||||
onConversationDeleteNoConfirmation={handleConversationDeleteNoConfirmation}
|
||||
onConversationExport={onConversationsExportDialog}
|
||||
onConversationFolderChange={handleConversationFolderChange}
|
||||
/>
|
||||
+58
-24
@@ -11,17 +11,18 @@ import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { isDeepEqual } from '~/common/util/jsUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
import { STREAM_TEXT_INDICATOR } from '../editors/chat-stream';
|
||||
import { ANIM_BUSY_TYPING } from '../message/messageUtils';
|
||||
import { CHAT_NOVEL_TITLE } from '../../AppChat';
|
||||
|
||||
|
||||
// set to true to display the conversation IDs
|
||||
@@ -42,7 +43,7 @@ export const ChatDrawerItemMemo = React.memo(ChatDrawerItem, (prev, next) =>
|
||||
prev.bottomBarBasis === next.bottomBarBasis &&
|
||||
prev.onConversationActivate === next.onConversationActivate &&
|
||||
prev.onConversationBranch === next.onConversationBranch &&
|
||||
prev.onConversationDelete === next.onConversationDelete &&
|
||||
prev.onConversationDeleteNoConfirmation === next.onConversationDeleteNoConfirmation &&
|
||||
prev.onConversationExport === next.onConversationExport &&
|
||||
prev.onConversationFolderChange === next.onConversationFolderChange,
|
||||
);
|
||||
@@ -54,11 +55,13 @@ export interface ChatNavigationItemData {
|
||||
isAlsoOpen: string | false;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
userSymbol: string | undefined;
|
||||
userFlagsSummary: string | undefined;
|
||||
containsImageAssets: boolean;
|
||||
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
beingGenerated: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
searchFrequency: number;
|
||||
}
|
||||
@@ -76,7 +79,7 @@ function ChatDrawerItem(props: {
|
||||
bottomBarBasis: number,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
onConversationDeleteNoConfirmation: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId, exportAll: boolean) => void,
|
||||
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
|
||||
}) {
|
||||
@@ -88,7 +91,20 @@ function ChatDrawerItem(props: {
|
||||
|
||||
// derived state
|
||||
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, isAlsoOpen, title, userFlagsSummary, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const {
|
||||
conversationId,
|
||||
isActive,
|
||||
isAlsoOpen,
|
||||
title,
|
||||
userSymbol,
|
||||
userFlagsSummary,
|
||||
containsImageAssets,
|
||||
folder,
|
||||
messageCount,
|
||||
beingGenerated,
|
||||
systemPurposeId,
|
||||
searchFrequency,
|
||||
} = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
|
||||
@@ -148,14 +164,23 @@ function ChatDrawerItem(props: {
|
||||
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
setIsAutoEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
await autoConversationTitle(conversationId, true);
|
||||
setIsAutoEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
|
||||
// Delete
|
||||
|
||||
const handleDeleteButtonShow = React.useCallback(() => setDeleteArmed(true), []);
|
||||
const { onConversationDeleteNoConfirmation } = props;
|
||||
const handleDeleteButtonShow = React.useCallback((event: React.MouseEvent) => {
|
||||
// special case: if 'Shift' is pressed, delete immediately
|
||||
if (event.shiftKey) { // immediately delete:conversation
|
||||
event.stopPropagation();
|
||||
onConversationDeleteNoConfirmation(conversationId);
|
||||
return;
|
||||
}
|
||||
setDeleteArmed(true);
|
||||
}, [conversationId, onConversationDeleteNoConfirmation]);
|
||||
|
||||
const handleDeleteButtonHide = React.useCallback(() => setDeleteArmed(false), []);
|
||||
|
||||
@@ -163,12 +188,12 @@ function ChatDrawerItem(props: {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
event.stopPropagation();
|
||||
props.onConversationDelete(conversationId);
|
||||
onConversationDeleteNoConfirmation(conversationId);
|
||||
}
|
||||
}, [conversationId, deleteArmed, props]);
|
||||
}, [conversationId, deleteArmed, onConversationDeleteNoConfirmation]);
|
||||
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
const textSymbol = userSymbol || SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency || messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
@@ -176,11 +201,11 @@ function ChatDrawerItem(props: {
|
||||
|
||||
{/* Symbol, if globally enabled */}
|
||||
{props.showSymbols && <ListItemDecorator>
|
||||
{assistantTyping
|
||||
{beingGenerated
|
||||
? (
|
||||
<Avatar
|
||||
alt='typing' variant='plain'
|
||||
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
|
||||
alt='activity' variant='plain'
|
||||
src={ANIM_BUSY_TYPING}
|
||||
sx={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
@@ -202,11 +227,12 @@ function ChatDrawerItem(props: {
|
||||
onDoubleClick={handleTitleEditBegin}
|
||||
sx={{
|
||||
color: isActive ? 'text.primary' : 'text.secondary',
|
||||
overflowWrap: 'anywhere',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{assistantTyping && STREAM_TEXT_INDICATOR}
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{beingGenerated && ' ...'}
|
||||
</Box>
|
||||
) : (
|
||||
<InlineTextarea
|
||||
@@ -227,13 +253,20 @@ function ChatDrawerItem(props: {
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
) : (userFlagsSummary && props.showSymbols) ? (
|
||||
<Typography sx={{ mr: '5px' }}>
|
||||
{userFlagsSummary}
|
||||
</Typography>
|
||||
) : (props.showSymbols && (userFlagsSummary || containsImageAssets)) ? (
|
||||
<Box sx={{
|
||||
fontSize: 'xs',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{userFlagsSummary}{containsImageAssets && '🖍️'}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary]);
|
||||
</>, [
|
||||
beingGenerated, containsImageAssets, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive,
|
||||
isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary,
|
||||
]);
|
||||
|
||||
const progressBarFixedComponent = React.useMemo(() =>
|
||||
progress > 0 && (
|
||||
@@ -264,6 +297,7 @@ function ChatDrawerItem(props: {
|
||||
}),
|
||||
|
||||
// style
|
||||
fontSize: 'inherit',
|
||||
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
|
||||
borderRadius: 'md',
|
||||
mx: '0.25rem',
|
||||
@@ -316,7 +350,7 @@ function ChatDrawerItem(props: {
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disableInteractive title='Branch'>
|
||||
<Tooltip disableInteractive title='Duplicate (Branch)'>
|
||||
<FadeInButton size='sm' onClick={handleConversationBranch}>
|
||||
<ForkRightIcon />
|
||||
</FadeInButton>
|
||||
+6
-3
@@ -36,8 +36,9 @@ export function FolderListItem(props: {
|
||||
|
||||
|
||||
// Menu
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
const handleMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setMenuAnchorEl(anchor => anchor ? null : event.currentTarget);
|
||||
setDeleteArmed(false); // Reset delete armed state
|
||||
};
|
||||
|
||||
@@ -188,9 +189,11 @@ export function FolderListItem(props: {
|
||||
|
||||
{/* Icon to show the Popup menu */}
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
className='menu-icon'
|
||||
onClick={handleMenuOpen}
|
||||
onClick={handleMenuToggle}
|
||||
onContextMenu={handleMenuToggle}
|
||||
sx={{
|
||||
visibility: 'hidden',
|
||||
my: '-0.25rem', /* absorb the button padding */
|
||||
+65
-20
@@ -1,7 +1,11 @@
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { conversationTitle, DConversationId, DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, useChatStore } from '~/common/state/store-chats';
|
||||
import { DMessage, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag, messageUserFlagToEmoji } from '~/common/stores/chat/chat.message';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { isContentOrAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import type { ChatNavigationItemData } from './ChatDrawerItem';
|
||||
|
||||
@@ -10,7 +14,7 @@ import type { ChatNavigationItemData } from './ChatDrawerItem';
|
||||
const SEARCH_MIN_CHARS = 3;
|
||||
|
||||
|
||||
export type ChatNavGrouping = false | 'date' | 'persona';
|
||||
export type ChatNavGrouping = false | 'date' | 'persona' | 'dimension';
|
||||
|
||||
export type ChatSearchSorting = 'frequency' | 'date';
|
||||
|
||||
@@ -88,6 +92,7 @@ export function useChatDrawerRenderItems(
|
||||
activeFolder: DFolder | null,
|
||||
allFolders: DFolder[],
|
||||
filterHasStars: boolean,
|
||||
filterHasImageAssets: boolean,
|
||||
grouping: ChatNavGrouping,
|
||||
searchSorting: ChatSearchSorting,
|
||||
showRelativeSize: boolean,
|
||||
@@ -99,7 +104,7 @@ export function useChatDrawerRenderItems(
|
||||
filteredChatsBarBasis: number,
|
||||
filteredChatsIncludeActive: boolean,
|
||||
} {
|
||||
return useChatStore(({ conversations }) => {
|
||||
return useStoreWithEqualityFn(useChatStore, ({ conversations }) => {
|
||||
|
||||
// filter 1: select all conversations or just the ones in the active folder
|
||||
const selectedConversations = !activeFolder ? conversations : conversations.filter(_c => activeFolder.conversationIds.includes(_c.id));
|
||||
@@ -107,9 +112,14 @@ export function useChatDrawerRenderItems(
|
||||
// filter 2: preparation: lowercase the query
|
||||
const { isSearching, lcTextQuery } = isDrawerSearching(filterByQuery);
|
||||
|
||||
function messageHasImageFragments(message: DMessage): boolean {
|
||||
return message.fragments.some(fragment => isContentOrAttachmentFragment(fragment) && isImageRefPart(fragment.part) /*&& fragment.part.dataRef.reftype === 'dblob'*/);
|
||||
}
|
||||
|
||||
// transform (the conversations into ChatNavigationItemData) + filter2 (if searching)
|
||||
const chatNavItems = selectedConversations
|
||||
.filter(_c => !filterHasStars || _c.messages.some(m => messageHasUserFlag(m, 'starred')))
|
||||
.filter(_c => !filterHasImageAssets || _c.messages.some(messageHasImageFragments))
|
||||
.map((_c): ChatNavigationItemData => {
|
||||
// rich properties
|
||||
const title = conversationTitle(_c);
|
||||
@@ -119,7 +129,9 @@ export function useChatDrawerRenderItems(
|
||||
let searchFrequency: number = 0;
|
||||
if (isSearching) {
|
||||
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
|
||||
const messageFrequency = _c.messages.reduce((count, message) => count + (message.text.toLowerCase().split(lcTextQuery).length - 1), 0);
|
||||
const messageFrequency = _c.messages.reduce((count, message) => {
|
||||
return count + messageFragmentsReduceText(message.fragments).toLowerCase().split(lcTextQuery).length - 1;
|
||||
}, 0);
|
||||
searchFrequency = titleFrequency + messageFrequency;
|
||||
}
|
||||
|
||||
@@ -127,6 +139,7 @@ export function useChatDrawerRenderItems(
|
||||
const allFlags = new Set<DMessageUserFlag>();
|
||||
_c.messages.forEach(_m => _m.userFlags?.forEach(flag => allFlags.add(flag)));
|
||||
const userFlagsSummary = !allFlags.size ? undefined : Array.from(allFlags).map(messageUserFlagToEmoji).join('');
|
||||
const containsImageAssets = filterHasImageAssets || _c.messages.some(messageHasImageFragments);
|
||||
|
||||
// create the ChatNavigationData
|
||||
return {
|
||||
@@ -136,7 +149,9 @@ export function useChatDrawerRenderItems(
|
||||
isAlsoOpen,
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title,
|
||||
userSymbol: _c.userSymbol || undefined,
|
||||
userFlagsSummary,
|
||||
containsImageAssets,
|
||||
folder: !allFolders.length
|
||||
? undefined // don't show folder select if folders are disabled
|
||||
: _c.id === activeConversationId // only show the folder for active conversation(s)
|
||||
@@ -144,7 +159,7 @@ export function useChatDrawerRenderItems(
|
||||
: null,
|
||||
updatedAt: _c.updated || _c.created || 0,
|
||||
messageCount: _c.messages.length,
|
||||
assistantTyping: !!_c.abortController,
|
||||
beingGenerated: !!_c.abortController, // FIXME: when the AbortController is moved at the message level, derive the state in the conv
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
searchFrequency,
|
||||
};
|
||||
@@ -173,25 +188,53 @@ export function useChatDrawerRenderItems(
|
||||
// [grouping] group by date or persona
|
||||
else if (grouping) {
|
||||
|
||||
// [grouping/date]: sort by update time
|
||||
const midnightTime = getNextMidnightTime();
|
||||
if (grouping === 'date')
|
||||
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
switch (grouping) {
|
||||
// [grouping/date or persona]: sort by last updated
|
||||
case 'date':
|
||||
case 'persona':
|
||||
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
break;
|
||||
// [grouping/dimension]: sort by message count
|
||||
case 'dimension':
|
||||
chatNavItems.sort((a, b) => b.messageCount - a.messageCount);
|
||||
break;
|
||||
}
|
||||
|
||||
// Array.groupBy(...)
|
||||
const midnightTime = getNextMidnightTime();
|
||||
const grouped = chatNavItems.reduce((acc, item) => {
|
||||
|
||||
const groupName = grouping === 'date'
|
||||
? getTimeBucketEn(item.updatedAt || midnightTime, midnightTime)
|
||||
: item.systemPurposeId;
|
||||
// derive the bucket name
|
||||
let bucket: string;
|
||||
switch (grouping) {
|
||||
case 'date':
|
||||
bucket = getTimeBucketEn(item.updatedAt || midnightTime, midnightTime);
|
||||
break;
|
||||
case 'persona':
|
||||
bucket = item.systemPurposeId;
|
||||
break;
|
||||
case 'dimension':
|
||||
if (item.messageCount > 20)
|
||||
bucket = 'Large chats';
|
||||
else if (item.messageCount > 10)
|
||||
bucket = 'Medium chats';
|
||||
else if (item.messageCount > 5)
|
||||
bucket = 'Small chats';
|
||||
else if (item.messageCount > 1)
|
||||
bucket = 'Tiny chats';
|
||||
else if (item.messageCount === 1)
|
||||
bucket = 'Single message';
|
||||
else
|
||||
bucket = 'Empty chats';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!acc[groupName])
|
||||
acc[groupName] = [];
|
||||
acc[groupName].push(item);
|
||||
if (!acc[bucket])
|
||||
acc[bucket] = [];
|
||||
acc[bucket].push(item);
|
||||
return acc;
|
||||
}, {} as { [groupName: string]: ChatNavigationItemData[] });
|
||||
|
||||
// prepend groups
|
||||
// prepend group names as special items
|
||||
renderNavItems = Object.entries(grouped).flatMap(([groupName, items]) => [
|
||||
{ type: 'nav-item-group', title: groupName },
|
||||
...items,
|
||||
@@ -202,9 +245,11 @@ export function useChatDrawerRenderItems(
|
||||
if (!renderNavItems.length)
|
||||
renderNavItems.push({
|
||||
type: 'nav-item-info-message',
|
||||
message: filterHasStars ? 'No starred results'
|
||||
: isSearching ? 'No results found'
|
||||
: 'No conversations in folder',
|
||||
message: (filterHasStars && filterHasImageAssets) ? 'No starred results with images'
|
||||
: filterHasImageAssets ? 'No image results'
|
||||
: filterHasStars ? 'No starred results'
|
||||
: isSearching ? 'No results found'
|
||||
: 'No conversations in folder',
|
||||
});
|
||||
|
||||
// other derived state
|
||||
+3
-3
@@ -13,12 +13,12 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
import { usePaneDuplicateOrClose } from './panes/usePanesManager';
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
import { usePaneDuplicateOrClose } from '../panes/usePanesManager';
|
||||
|
||||
|
||||
export function ChatPageMenuItems(props: {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@ import { Box, Button, Checkbox, IconButton, ListItem, Sheet, Typography } from '
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
import { DMessage, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import { TokenBadgeMemo } from '../composer/TokenBadge';
|
||||
import { makeAvatar, messageBackground } from './ChatMessage';
|
||||
import { isErrorChatMessage } from './explainServiceErrors';
|
||||
import { makeMessageAvatar, messageBackground } from './messageUtils';
|
||||
|
||||
|
||||
/**
|
||||
@@ -44,10 +45,8 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
|
||||
// derived state
|
||||
const {
|
||||
id: messageId,
|
||||
text: messageText,
|
||||
sender: messageSender,
|
||||
avatar: messageAvatar,
|
||||
typing: messageTyping,
|
||||
pendingIncomplete: messagePendingIncomplete,
|
||||
role: messageRole,
|
||||
purposeId: messagePurposeId,
|
||||
originLLM: messageOriginLLM,
|
||||
@@ -55,15 +54,17 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
|
||||
updated: messageUpdated,
|
||||
} = props.message;
|
||||
|
||||
const messageText = messageFragmentsReduceText(props.message.fragments);
|
||||
|
||||
const fromAssistant = messageRole === 'assistant';
|
||||
|
||||
const isAssistantError = fromAssistant && (messageText.startsWith('[Issue] ') || messageText.startsWith('[OpenAI Issue]'));
|
||||
const isAssistantError = fromAssistant && isErrorChatMessage(messageText);
|
||||
|
||||
const backgroundColor = messageBackground(messageRole, !!messageUpdated, isAssistantError);
|
||||
|
||||
const avatarEl: React.JSX.Element | null = React.useMemo(() =>
|
||||
makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping, 'sm'),
|
||||
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping],
|
||||
makeMessageAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete),
|
||||
[messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole],
|
||||
);
|
||||
|
||||
const handleCheckedChange = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, IconButton, Tooltip, Typography } from '@mui/joy';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded';
|
||||
|
||||
|
||||
// configuration
|
||||
const INLINE_COLOR = 'primary';
|
||||
|
||||
|
||||
const bubbleComposerSx: SxProps = {
|
||||
// contained
|
||||
width: '100%',
|
||||
zIndex: 2, // stays on top of the 'tokens' bubble in the composer
|
||||
|
||||
// style
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'xs',
|
||||
padding: '0.5rem 0.25rem 0.5rem 0.5rem',
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
alignItems: 'start',
|
||||
};
|
||||
|
||||
export const inlineMessageBubbleSx: SxProps = {
|
||||
...bubbleComposerSx,
|
||||
|
||||
// redefine
|
||||
// border: 'none',
|
||||
// mt: 1,
|
||||
borderColor: `${INLINE_COLOR}.outlinedColor`,
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'xs',
|
||||
width: undefined,
|
||||
padding: '0.375rem 0.25rem 0.375rem 0.5rem',
|
||||
|
||||
// FORMERLY: self-layout (parent: 'block', as 'grid' was not working and the user would scroll the app on the x-axis on mobile)
|
||||
// float: 'inline-end',
|
||||
// mr: { xs: 7.75, md: 10.5 }, // personaSx.minWidth + gap (md: 1) + 1.5 (text margin)
|
||||
|
||||
// now: the parent is a 'grid' to v-layout fragment types
|
||||
mx: '0.75rem', // 1.5, like margin of text blocks
|
||||
|
||||
};
|
||||
|
||||
|
||||
export function ReplyToBubble(props: {
|
||||
replyToText: string | null,
|
||||
inlineUserMessage?: boolean
|
||||
onClear?: () => void,
|
||||
className?: string,
|
||||
}) {
|
||||
return (
|
||||
<Box className={props.className} sx={!props.inlineUserMessage ? bubbleComposerSx : inlineMessageBubbleSx}>
|
||||
<Tooltip disableInteractive arrow title='Referring to this assistant text' placement='top'>
|
||||
<ReplyRoundedIcon sx={{
|
||||
color: props.inlineUserMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
|
||||
fontSize: 'xl',
|
||||
mt: 0.125,
|
||||
}} />
|
||||
</Tooltip>
|
||||
<Typography level='body-sm' sx={{
|
||||
flex: 1,
|
||||
ml: 1,
|
||||
mr: 0.5,
|
||||
overflow: 'auto',
|
||||
maxHeight: '5.75rem',
|
||||
lineHeight: 'xl',
|
||||
color: /*props.inlineMessage ? 'text.tertiary' :*/ 'text.secondary',
|
||||
whiteSpace: 'break-spaces', // 'balance'
|
||||
}}>
|
||||
{props.replyToText}
|
||||
</Typography>
|
||||
{!!props.onClear && (
|
||||
<IconButton size='sm' onClick={props.onClear} sx={{ my: -0.5, background: 'none' }}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
|
||||
export function isErrorChatMessage(text: string) {
|
||||
return ['**[Service Issue] ', '[Issue] ', '[OpenAI Issue] '].some(prefix => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function explainServiceErrors(text: string, isAssistant: boolean, modelId?: string) {
|
||||
const isAssistantError = isAssistant && isErrorChatMessage(text);
|
||||
if (!isAssistantError)
|
||||
return null;
|
||||
|
||||
switch (true) {
|
||||
case text.includes('"insufficient_quota"'):
|
||||
return <>
|
||||
{/*The model appears to be occupied at the moment. Kindly try another model, try again after some time,*/}
|
||||
{/*or give it another go by selecting <b>Run again</b> from the message menu.*/}
|
||||
The OpenAI API key appears to have <b>insufficient quota</b>. Please
|
||||
check <Link noLinkStyle href='https://platform.openai.com/usage' target='_blank'>your usage</Link> and
|
||||
make sure the usage is under <Link noLinkStyle href='https://platform.openai.com/account/billing/limits' target='_blank'>the limits</Link>.
|
||||
</>;
|
||||
|
||||
case text.includes('"invalid_api_key"'):
|
||||
return <>
|
||||
The OpenAI API key appears to be incorrect or to have expired.
|
||||
Please <Link noLinkStyle href='https://platform.openai.com/api-keys' target='_blank'>check your
|
||||
API key</Link> and update it in <b>Models</b>.
|
||||
</>;
|
||||
|
||||
// [OpenAI] "Service Temporarily Unavailable (503)", {"code":503,"message":"Service Unavailable.","param":null,"type":"cf_service_unavailable"}
|
||||
case text.includes('"cf_service_unavailable"'):
|
||||
return <>
|
||||
The OpenAI servers appear to be having trouble at the moment. Kindly follow
|
||||
the <Link noLinkStyle href='https://status.openai.com/' target='_blank'>OpenAI Status</Link> page
|
||||
for up to date information, and at your option try again.
|
||||
</>;
|
||||
|
||||
case text.includes('"model_not_found"'):
|
||||
return <>
|
||||
The API key appears to be unauthorized for {modelId || 'this model'}. You can change to <b>GPT-3.5
|
||||
Turbo</b> and simultaneously <Link noLinkStyle href='https://openai.com/waitlist/gpt-4-api' target='_blank'>request
|
||||
access</Link> to the desired model.
|
||||
</>;
|
||||
|
||||
case text.includes('"context_length_exceeded"'):
|
||||
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
|
||||
const match = pattern.exec(text);
|
||||
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens > {parseInt(match[1] || '0').toLocaleString()}</b> : '';
|
||||
return <>
|
||||
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
|
||||
Please consider removing some earlier messages from the conversation, start a new conversation,
|
||||
choose a model with larger context, or submit a shorter new message.
|
||||
{!usedText && ` -- ${text}`}
|
||||
</>;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button } from '@mui/joy';
|
||||
import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
|
||||
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
|
||||
|
||||
function iconForFragment({ part }: DMessageAttachmentFragment): React.ComponentType<any> {
|
||||
switch (part.pt) {
|
||||
case 'doc':
|
||||
switch (part.type) {
|
||||
case 'text/plain':
|
||||
return TextFieldsIcon;
|
||||
case 'text/html':
|
||||
return CodeIcon;
|
||||
case 'text/markdown':
|
||||
return CodeIcon;
|
||||
case 'application/vnd.agi.ocr':
|
||||
return part.meta?.srcOcrFrom === 'image' ? AbcIcon : PictureAsPdfIcon;
|
||||
case 'application/vnd.agi.ego':
|
||||
return TelegramIcon;
|
||||
default:
|
||||
return TextureIcon;
|
||||
}
|
||||
case 'image_ref':
|
||||
return ImageOutlinedIcon;
|
||||
case '_pt_sentinel':
|
||||
return TextureIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function DocumentFragmentButton(props: {
|
||||
fragment: DMessageAttachmentFragment,
|
||||
contentScaling: ContentScaling,
|
||||
isSelected: boolean,
|
||||
toggleSelected: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { fragment, isSelected, toggleSelected } = props;
|
||||
|
||||
// only operate on doc fragments
|
||||
if (fragment.part.pt !== 'doc')
|
||||
throw new Error('Unexpected part type: ' + fragment.part.pt);
|
||||
|
||||
// handlers
|
||||
const handleSelectFragment = React.useCallback(() => {
|
||||
toggleSelected(fragment.fId);
|
||||
}, [fragment.fId, toggleSelected]);
|
||||
|
||||
// memos
|
||||
const buttonSx = React.useMemo((): SxProps => ({
|
||||
// from ATTACHMENT_MIN_STYLE
|
||||
// height: '100%',
|
||||
minHeight: props.contentScaling === 'md' ? 40 : props.contentScaling === 'sm' ? 38 : 36,
|
||||
minWidth: '64px',
|
||||
maxWidth: '280px',
|
||||
padding: 0,
|
||||
|
||||
// style
|
||||
fontSize: themeScalingMap[props.contentScaling]?.fragmentButtonFontSize ?? undefined,
|
||||
border: '1px solid',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'xs',
|
||||
...isSelected ? {
|
||||
borderColor: 'neutral.solidBg',
|
||||
} : {
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
backgroundColor: 'background.surface',
|
||||
},
|
||||
|
||||
// from LLMAttachmentItem
|
||||
display: 'flex', flexDirection: 'row',
|
||||
}), [isSelected, props.contentScaling]);
|
||||
|
||||
const buttonText = ellipsizeMiddle(fragment.title || 'Text', 28 /* totally arbitrary length */);
|
||||
|
||||
const Icon = iconForFragment(fragment);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={props.contentScaling === 'md' ? 'md' : 'sm'}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'neutral' : 'neutral'}
|
||||
onClick={handleSelectFragment}
|
||||
sx={buttonSx}
|
||||
>
|
||||
{!!Icon && (
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
paddingX: '0.5rem',
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: isSelected ? 'neutral.solidBg' : 'primary.outlinedBorder',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<Icon />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', paddingX: '0.5rem' }}>
|
||||
<Box sx={{ whiteSpace: 'nowrap', fontWeight: 'md' }}>
|
||||
{buttonText}
|
||||
</Box>
|
||||
{/*<Box sx={{ fontSize: 'xs', fontWeight: 'sm' }}>*/}
|
||||
{/* {fragment.caption}*/}
|
||||
{/*</Box>*/}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Button } from '@mui/joy';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { marshallWrapText } from '~/common/stores/chat/chat.tokens';
|
||||
|
||||
import { ContentPartTextEditor } from '../fragments-content/ContentPartTextEditor';
|
||||
|
||||
|
||||
export function DocumentFragmentEditor(props: {
|
||||
fragment: DMessageAttachmentFragment,
|
||||
editedText?: string,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
isMobile?: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newContent: DMessageAttachmentFragment) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { editedText, fragment, onFragmentDelete, onFragmentReplace } = props;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isDeleteArmed, setIsDeleteArmed] = React.useState(false);
|
||||
|
||||
const fragmentId = fragment.fId;
|
||||
const fragmentTitle = fragment.title;
|
||||
const fragmentCaption = fragment.caption;
|
||||
const part = fragment.part;
|
||||
|
||||
if (part.pt !== 'doc')
|
||||
throw new Error('Unexpected part type: ' + part.pt);
|
||||
|
||||
// delete
|
||||
|
||||
const handleToggleDeleteArmed = React.useCallback(() => {
|
||||
// setIsEditing(false);
|
||||
setIsDeleteArmed(on => !on);
|
||||
}, []);
|
||||
|
||||
const handleFragmentDelete = React.useCallback(() => {
|
||||
onFragmentDelete(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
|
||||
// edit
|
||||
|
||||
const handleToggleEdit = React.useCallback(() => {
|
||||
setIsDeleteArmed(false);
|
||||
setIsEditing(on => !on);
|
||||
}, []);
|
||||
|
||||
const handleEditApply = React.useCallback(() => {
|
||||
setIsDeleteArmed(false);
|
||||
if (editedText === undefined)
|
||||
return;
|
||||
|
||||
// only edit DOCs
|
||||
if (fragment.part.pt !== 'doc') {
|
||||
console.warn('handleEditApply: unexpected part type:', fragment.part.pt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editedText.length > 0) {
|
||||
const newData = createDMessageDataInlineText(editedText, fragment.part.data.mimeType);
|
||||
const newAttachment = createDocAttachmentFragment(fragment.title, fragment.caption, fragment.part.type, newData, fragment.part.ref, fragment.part.meta);
|
||||
// reuse the same fragment ID, which makes the screen not flash (otherwise the whole editor would disappear as the ID does not exist anymore)
|
||||
newAttachment.fId = fragmentId;
|
||||
onFragmentReplace(fragmentId, newAttachment);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
// if the user deleted all text, let's remove the part
|
||||
handleFragmentDelete();
|
||||
}
|
||||
}, [editedText, fragment, fragmentId, handleFragmentDelete, onFragmentReplace]);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level2',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'inset 2px 0px 5px -4px var(--joy-palette-background-backdrop)',
|
||||
p: 1,
|
||||
mt: 0.5,
|
||||
}}>
|
||||
|
||||
{isEditing ? (
|
||||
// Document Editor
|
||||
<ContentPartTextEditor
|
||||
textPartText={part.data.text}
|
||||
fragmentId={fragmentId}
|
||||
contentScaling={props.contentScaling}
|
||||
editedText={props.editedText}
|
||||
setEditedText={props.setEditedText}
|
||||
onEnterPressed={handleEditApply}
|
||||
onEscapePressed={handleToggleEdit}
|
||||
/>
|
||||
) : (
|
||||
// Document viewer, including collapse/expand
|
||||
<AutoBlocksRenderer
|
||||
text={marshallWrapText(part.data.text, /*fragment.title ||*/ part.meta?.srcFileName || part.ref, 'markdown-code')}
|
||||
// text={selectedFragment.part.text}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.isMobile}
|
||||
specialCodePlain
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit / Delete commands */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{isDeleteArmed ? (
|
||||
<Button variant='solid' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<CloseRoundedIcon />}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<DeleteOutlineIcon />}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{isDeleteArmed && (
|
||||
<Button variant='plain' color='danger' size='sm' onClick={handleFragmentDelete} startDecorator={<DeleteForeverIcon />}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
{isEditing ? (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<CloseRoundedIcon />}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<EditRoundedIcon />}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button variant='plain' color='success' onClick={handleEditApply} size='sm' startDecorator={<CheckRoundedIcon />}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import type { ChatMessageTextPartEditState } from '../ChatMessage';
|
||||
import { DocumentFragmentButton } from './DocumentFragmentButton';
|
||||
import { DocumentFragmentEditor } from './DocumentFragmentEditor';
|
||||
|
||||
|
||||
/**
|
||||
* Displays a list of 'cards' which are buttons with a mutually exclusive active state.
|
||||
* When one is active, there is a content part just right under (with the collapse mechanism in case it's a user role).
|
||||
* If one is clicked the content part (use ContentPartText) is displayed.
|
||||
*/
|
||||
export function DocumentFragments(props: {
|
||||
attachmentFragments: DMessageAttachmentFragment[],
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
isMobile?: boolean,
|
||||
renderTextAsMarkdown: boolean;
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageAttachmentFragment) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [activeFragmentId, setActiveFragmentId] = React.useState<DMessageFragmentId | null>(null);
|
||||
const [editState, setEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
|
||||
|
||||
|
||||
// selection
|
||||
|
||||
const handleToggleSelectedId = React.useCallback((fragmentId: DMessageFragmentId) => setActiveFragmentId(prevId => prevId === fragmentId ? null : fragmentId), []);
|
||||
|
||||
const selectedFragment = props.attachmentFragments.find(fragment => fragment.fId === activeFragmentId);
|
||||
|
||||
|
||||
// editing
|
||||
|
||||
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, value: string) => setEditState(prevState => ({ ...prevState, [fragmentId]: value })), []);
|
||||
|
||||
// [effect] clear edits on onmount
|
||||
React.useEffect(() => {
|
||||
return () => setEditState(null);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Box aria-label={`${props.attachmentFragments.length} attachments`} sx={{
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
|
||||
{/* Horizontally scrollable Document buttons */}
|
||||
<Box sx={{
|
||||
pb: 0.5, // 4px: to show the button shadow
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
|
||||
}}>
|
||||
{props.attachmentFragments.map((attachmentFragment) =>
|
||||
<DocumentFragmentButton
|
||||
key={attachmentFragment.fId}
|
||||
fragment={attachmentFragment}
|
||||
contentScaling={props.contentScaling}
|
||||
isSelected={activeFragmentId === attachmentFragment.fId}
|
||||
toggleSelected={handleToggleSelectedId}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Document Viewer & Editor */}
|
||||
{!!selectedFragment && (
|
||||
<DocumentFragmentEditor
|
||||
fragment={selectedFragment}
|
||||
messageRole={props.messageRole}
|
||||
editedText={editState?.[selectedFragment.fId]}
|
||||
setEditedText={handleEditSetText}
|
||||
contentScaling={props.contentScaling}
|
||||
isMobile={props.isMobile}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
onFragmentDelete={props.onFragmentDelete}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { DMessageAttachmentFragment, DMessageFragmentId, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
// configuration
|
||||
const CARD_MIN_SQR = 84;
|
||||
const CARD_MAX_WIDTH = CARD_MIN_SQR * 3; // 3:1 max wide ratio (252px)
|
||||
const CARD_MAX_HEIGHT = CARD_MIN_SQR * 2.25; // 1:2.25 max tall ratio (189px)
|
||||
|
||||
|
||||
const layoutSx: SxProps = {
|
||||
// style
|
||||
my: 'auto',
|
||||
flex: 0,
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
// alignItems: 'center', // commented to keep them to the top
|
||||
// justifyContent: 'flex-end', // commented as we do it dynamically
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
};
|
||||
|
||||
const imageSheetPatchSx: SxProps = {
|
||||
// undo the RenderImageURL default style
|
||||
m: 0,
|
||||
minWidth: CARD_MIN_SQR,
|
||||
minHeight: CARD_MIN_SQR,
|
||||
boxShadow: 'xs',
|
||||
// border: 'none',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'background.popup',
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
|
||||
// style the <img> tag
|
||||
'& picture > img': {
|
||||
// override the style in RenderImageURL
|
||||
maxWidth: CARD_MAX_WIDTH, // very important to keep the aspect ratio
|
||||
maxHeight: CARD_MAX_HEIGHT, // very important to keep the aspect ratio
|
||||
// width: '100%',
|
||||
// height: '100%',
|
||||
// objectFit: 'cover',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Shows image attachments in a flexbox that wraps the images (overflowing by rows)
|
||||
* Also see `TextAttachmentFragments` for the text version, and 'ContentFragments'.
|
||||
*/
|
||||
export function ImageAttachmentFragments(props: {
|
||||
imageAttachments: DMessageAttachmentFragment[],
|
||||
contentScaling: ContentScaling,
|
||||
messageRole: DMessageRole,
|
||||
isMobile?: boolean,
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
const layoutSxMemo = React.useMemo((): SxProps => ({
|
||||
...layoutSx,
|
||||
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
|
||||
}), [props.messageRole]);
|
||||
|
||||
const cardStyleSxMemo = React.useMemo((): SxProps => ({
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
|
||||
...imageSheetPatchSx,
|
||||
}), [props.contentScaling]);
|
||||
|
||||
|
||||
return (
|
||||
<Box aria-label={`${props.imageAttachments.length} images`} sx={layoutSxMemo}>
|
||||
|
||||
{/* render each image attachment */}
|
||||
{props.imageAttachments.map(attachmentFragment => {
|
||||
// only operate on image_ref
|
||||
if (!isImageRefPart(attachmentFragment.part))
|
||||
throw new Error('Unexpected part type: ' + attachmentFragment.part.pt);
|
||||
|
||||
const { title, part: imageRefPart } = attachmentFragment;
|
||||
const { dataRef, altText } = imageRefPart;
|
||||
|
||||
// only support rendering DBLob images as cards for now
|
||||
if (dataRef.reftype === 'dblob') {
|
||||
return (
|
||||
<RenderImageRefDBlob
|
||||
key={'att-img-' + attachmentFragment.fId}
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
imageAltText={imageRefPart.altText || title}
|
||||
imageWidth={imageRefPart.width}
|
||||
imageHeight={imageRefPart.height}
|
||||
onOpenInNewTab={() => showImageDataRefInNewTab(dataRef)}
|
||||
onDeleteFragment={() => props.onFragmentDelete(attachmentFragment.fId)}
|
||||
scaledImageSx={cardStyleSxMemo}
|
||||
variant='attachment-card'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected dataRef type: ' + dataRef.reftype);
|
||||
})}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { DMessageContentFragment, DMessageFragment, DMessageFragmentId, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import type { ChatMessageTextPartEditState } from '../ChatMessage';
|
||||
import { ContentPartImageRef } from './ContentPartImageRef';
|
||||
import { ContentPartPlaceholder } from './ContentPartPlaceholder';
|
||||
import { ContentPartTextAutoBlocks } from './ContentPartTextAutoBlocks';
|
||||
import { ContentPartTextEditor } from './ContentPartTextEditor';
|
||||
|
||||
|
||||
const editLayoutSx: SxProps = {
|
||||
display: 'grid',
|
||||
gap: 1.5, // see why we give more space on ChatMessage
|
||||
|
||||
// horizontal separator between messages (second part+ and before)
|
||||
// '& > *:not(:first-child)': {
|
||||
// borderTop: '1px solid',
|
||||
// borderTopColor: 'background.level3',
|
||||
// },
|
||||
};
|
||||
|
||||
const startLayoutSx: SxProps = {
|
||||
...editLayoutSx,
|
||||
justifyContent: 'flex-start',
|
||||
};
|
||||
|
||||
const endLayoutSx: SxProps = {
|
||||
...editLayoutSx,
|
||||
justifyContent: 'flex-end',
|
||||
};
|
||||
|
||||
|
||||
export function ContentFragments(props: {
|
||||
|
||||
fragments: DMessageFragment[]
|
||||
|
||||
contentScaling: ContentScaling,
|
||||
fitScreen: boolean,
|
||||
messageOriginLLM?: string,
|
||||
messageRole: DMessageRole,
|
||||
optiAllowSubBlocksMemo?: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
showTopWarning?: string,
|
||||
showUnsafeHtml?: boolean,
|
||||
|
||||
textEditsState: ChatMessageTextPartEditState | null,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
onEditsApply: () => void,
|
||||
onEditsCancel: () => void,
|
||||
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
}) {
|
||||
|
||||
const fromAssistant = props.messageRole === 'assistant';
|
||||
const isEditingText = !!props.textEditsState;
|
||||
const isMonoFragment = props.fragments.length < 2;
|
||||
|
||||
// if no fragments, don't box them
|
||||
if (!props.fragments.length)
|
||||
return null;
|
||||
|
||||
return <Box aria-label='message body' sx={isEditingText ? editLayoutSx : fromAssistant ? startLayoutSx : endLayoutSx}>
|
||||
{props.fragments.map((fragment) => {
|
||||
|
||||
// only proceed with DMessageContentFragment
|
||||
if (!isContentFragment(fragment))
|
||||
return null;
|
||||
|
||||
// editing for text parts
|
||||
if (props.textEditsState && (isTextPart(fragment.part) || fragment.part.pt === 'error')) {
|
||||
return (
|
||||
<ContentPartTextEditor
|
||||
key={'edit-' + fragment.fId}
|
||||
textPartText={isTextPart(fragment.part) ? fragment.part.text : fragment.part.error}
|
||||
fragmentId={fragment.fId}
|
||||
contentScaling={props.contentScaling}
|
||||
editedText={props.textEditsState[fragment.fId]}
|
||||
setEditedText={props.setEditedText}
|
||||
onEnterPressed={props.onEditsApply}
|
||||
onEscapePressed={props.onEditsCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fragment.part.pt) {
|
||||
case 'error':
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={fragment.part.error}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsDanger
|
||||
showAsItalic
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
case 'image_ref':
|
||||
return (
|
||||
<ContentPartImageRef
|
||||
key={fragment.fId}
|
||||
imageRefPart={fragment.part}
|
||||
fragmentId={fragment.fId}
|
||||
contentScaling={props.contentScaling}
|
||||
onFragmentDelete={!isMonoFragment ? props.onFragmentDelete : undefined}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'ph':
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={fragment.part.pText}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsItalic
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<ContentPartTextAutoBlocks
|
||||
key={fragment.fId}
|
||||
// ref={blocksRendererRef}
|
||||
textPartText={fragment.part.text}
|
||||
messageRole={props.messageRole}
|
||||
messageOriginLLM={props.messageOriginLLM}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.fitScreen}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
// renderTextDiff={textDiffs || undefined}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
showTopWarning={props.showTopWarning}
|
||||
optiAllowSubBlocksMemo={!!props.optiAllowSubBlocksMemo}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tool_call':
|
||||
case 'tool_response':
|
||||
case '_pt_sentinel':
|
||||
default:
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={`Unknown Content fragment: ${fragment.part.pt}`}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsDanger
|
||||
/>
|
||||
);
|
||||
}
|
||||
}).filter(Boolean)}
|
||||
</Box>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { BlocksContainer } from '~/modules/blocks/BlocksContainer';
|
||||
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
|
||||
|
||||
import type { DMessageContentFragment, DMessageFragmentId, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
|
||||
|
||||
export function ContentPartImageRef(props: {
|
||||
imageRefPart: DMessageImageRefPart,
|
||||
fragmentId: DMessageFragmentId,
|
||||
contentScaling: ContentScaling,
|
||||
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { fragmentId, imageRefPart, onFragmentDelete, onFragmentReplace } = props;
|
||||
const { dataRef } = imageRefPart;
|
||||
|
||||
// event handlers
|
||||
const handleDeleteFragment = React.useCallback(() => {
|
||||
onFragmentDelete?.(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
const handleReplaceFragment = React.useCallback((newImageFragment: DMessageContentFragment) => {
|
||||
onFragmentReplace?.(fragmentId, newImageFragment);
|
||||
}, [fragmentId, onFragmentReplace]);
|
||||
|
||||
const handleOpenInNewTab = React.useCallback(() => {
|
||||
void showImageDataRefInNewTab(dataRef); // fire/forget
|
||||
}, [dataRef]);
|
||||
|
||||
|
||||
// memo the scaled image style
|
||||
const scaledImageSx = React.useMemo((): SxProps => ({
|
||||
// overflowX: 'auto', // <- this would make the right side margin scrollable
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
|
||||
marginBottom: themeScalingMap[props.contentScaling]?.blockImageGap ?? 1.5,
|
||||
}), [props.contentScaling]);
|
||||
|
||||
return (
|
||||
<BlocksContainer>
|
||||
{dataRef.reftype === 'dblob' ? (
|
||||
<RenderImageRefDBlob
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
imageAltText={imageRefPart.altText}
|
||||
imageWidth={imageRefPart.width}
|
||||
imageHeight={imageRefPart.height}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onDeleteFragment={onFragmentDelete ? handleDeleteFragment : undefined}
|
||||
onReplaceFragment={onFragmentReplace ? handleReplaceFragment : undefined}
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : dataRef.reftype === 'url' ? (
|
||||
<RenderImageURL
|
||||
imageURL={dataRef.url}
|
||||
expandableText={imageRefPart.altText}
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
ContentPartImageRef: unknown reftype
|
||||
</Box>
|
||||
)}
|
||||
</BlocksContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
|
||||
|
||||
export function ContentPartPlaceholder(props: {
|
||||
placeholderText: string,
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
showAsDanger?: boolean,
|
||||
showAsItalic?: boolean,
|
||||
// showAsProgress?: boolean,
|
||||
}) {
|
||||
// const placeholder = (
|
||||
return (
|
||||
<AutoBlocksRenderer
|
||||
text={props.placeholderText}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={false}
|
||||
showAsDanger={props.showAsDanger}
|
||||
showAsItalic={props.showAsItalic}
|
||||
renderTextAsMarkdown={false}
|
||||
/>
|
||||
);
|
||||
//
|
||||
// return props.showAsProgress ? (
|
||||
// <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
// <CircularProgress color='neutral' size='sm' sx={{ ml: 1.5, '--CircularProgress-size': '16px', '--CircularProgress-trackThickness': '2px' }} /> {placeholder}
|
||||
// </Box>
|
||||
// ) : (
|
||||
// placeholder
|
||||
// );
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import type { Diff as TextDiff } from '@sanity/diff-match-patch';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
|
||||
import { explainServiceErrors } from '../explainServiceErrors';
|
||||
|
||||
/**
|
||||
* The OG part, comprised of text, which can be markdown, have code blocks, etc.
|
||||
* Uses BlocksRenderer to render the markdown/code/html/text, etc.
|
||||
*/
|
||||
export function ContentPartTextAutoBlocks(props: {
|
||||
textPartText: string,
|
||||
|
||||
messageRole: DMessageRole,
|
||||
messageOriginLLM?: string,
|
||||
|
||||
contentScaling: ContentScaling,
|
||||
fitScreen: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
renderTextDiff?: TextDiff[];
|
||||
|
||||
showUnsafeHtml?: boolean,
|
||||
showTopWarning: string | undefined,
|
||||
optiAllowSubBlocksMemo: boolean,
|
||||
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const messageText = props.textPartText;
|
||||
const fromAssistant = props.messageRole === 'assistant';
|
||||
|
||||
const errorMessage = React.useMemo(
|
||||
() => explainServiceErrors(messageText, fromAssistant, props.messageOriginLLM),
|
||||
[fromAssistant, messageText, props.messageOriginLLM],
|
||||
);
|
||||
|
||||
// if errored, render an Auto-Error message
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<GoodTooltip placement='top' arrow title={messageText}>
|
||||
<div><InlineError error={`${errorMessage}. Hover this message for more details.`} /></div>
|
||||
</GoodTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoBlocksRenderer
|
||||
text={messageText || ''}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.fitScreen}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
showTopWarning={props.showTopWarning}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
renderTextDiff={props.renderTextDiff}
|
||||
optiAllowSubBlocksMemo={props.optiAllowSubBlocksMemo}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { BlocksTextarea } from '~/modules/blocks/BlocksContainer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
/**
|
||||
* Very similar to <InlineTextArea /> but with externally controlled state rather than internal.
|
||||
* Made it for as the editing alternative for <ContentPartText />.
|
||||
*/
|
||||
export function ContentPartTextEditor(props: {
|
||||
// current value
|
||||
textPartText: string,
|
||||
fragmentId: DMessageFragmentId,
|
||||
|
||||
// visual
|
||||
contentScaling: ContentScaling,
|
||||
|
||||
// edited value
|
||||
editedText?: string,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
|
||||
// events
|
||||
onEnterPressed: () => void,
|
||||
onEscapePressed: () => void,
|
||||
}) {
|
||||
|
||||
// external
|
||||
// NOTE: we disabled `useUIPreferencesStore(state => state.enterIsNewline)` on 2024-06-19, as it's
|
||||
// not a good pattern for this kind of editing and we have buttons to take care of Save/Cancel
|
||||
const enterIsNewline = true;
|
||||
|
||||
// derived state
|
||||
const { fragmentId, setEditedText, onEnterPressed, onEscapePressed } = props;
|
||||
|
||||
// handlers
|
||||
const handleEditTextChanged = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
(e.target.value !== undefined) && setEditedText(fragmentId, e.target.value);
|
||||
}, [fragmentId, setEditedText]);
|
||||
|
||||
const handleEditKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const shiftOrAlt = e.shiftKey || e.altKey;
|
||||
if (enterIsNewline ? shiftOrAlt : !shiftOrAlt) {
|
||||
e.preventDefault();
|
||||
onEnterPressed();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onEscapePressed();
|
||||
}
|
||||
}, [enterIsNewline, onEnterPressed, onEscapePressed]);
|
||||
|
||||
return (
|
||||
<BlocksTextarea
|
||||
variant={/*props.invertedColors ? 'plain' :*/ 'soft'}
|
||||
color={/*props.decolor ? undefined : props.invertedColors ? 'primary' :*/ 'warning'}
|
||||
autoFocus
|
||||
size={props.contentScaling !== 'md' ? 'sm' : undefined}
|
||||
value={(props.editedText !== undefined)
|
||||
? props.editedText /* self-text */
|
||||
: props.textPartText /* DMessageTextPart text */
|
||||
}
|
||||
onChange={handleEditTextChanged}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
// onBlur={props.disableAutoSaveOnBlur ? undefined : handleEditBlur}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'done',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box } from '@mui/joy';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
// Animations
|
||||
const ANIM_BUSY_DOWNLOADING = 'https://i.giphy.com/26u6dIwIphLj8h10A.webp'; // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
|
||||
const ANIM_BUSY_PAINTING = 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp';
|
||||
const ANIM_BUSY_THINKING = 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp';
|
||||
export const ANIM_BUSY_TYPING = 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp';
|
||||
|
||||
|
||||
export const messageAsideColumnSx: SxProps = {
|
||||
// make this stick to the top of the screen
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
minWidth: { xs: 50, md: 64 },
|
||||
maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
export const avatarIconSx = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
} as const;
|
||||
|
||||
|
||||
export function makeMessageAvatar(
|
||||
messageAvatarUrl: string | null,
|
||||
messageRole: DMessageRole | string,
|
||||
messageOriginLLM: string | undefined,
|
||||
messagePurposeId: SystemPurposeId | string | undefined,
|
||||
messageIncomplete: boolean,
|
||||
larger?: boolean,
|
||||
): React.JSX.Element {
|
||||
const nameByRole = messageRole === 'user' ? 'You' : messageRole === 'assistant' ? 'Assistant' : 'System';
|
||||
if (typeof messageAvatarUrl === 'string' && messageAvatarUrl)
|
||||
return <Avatar alt={nameByRole} src={messageAvatarUrl} />;
|
||||
|
||||
const mascotSx = larger ? { width: 48, height: 48 } : avatarIconSx;
|
||||
switch (messageRole) {
|
||||
case 'system':
|
||||
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
|
||||
|
||||
case 'user':
|
||||
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
|
||||
|
||||
case 'assistant':
|
||||
const isDownload = messageOriginLLM === 'web';
|
||||
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
|
||||
const isReact = messageOriginLLM?.startsWith('react-');
|
||||
|
||||
// animation on incomplete messages
|
||||
if (messageIncomplete)
|
||||
return <Avatar
|
||||
alt={nameByRole} variant='plain'
|
||||
src={isDownload ? ANIM_BUSY_DOWNLOADING
|
||||
: isTextToImage ? ANIM_BUSY_PAINTING
|
||||
: isReact ? ANIM_BUSY_THINKING
|
||||
: ANIM_BUSY_TYPING}
|
||||
sx={{ ...mascotSx, borderRadius: 'sm' }}
|
||||
/>;
|
||||
|
||||
// icon: text-to-image
|
||||
if (isTextToImage)
|
||||
return <FormatPaintOutlinedIcon sx={{
|
||||
...avatarIconSx,
|
||||
animation: `${animationColorRainbow} 1s linear 2.66`,
|
||||
}} />;
|
||||
|
||||
// purpose symbol (if present)
|
||||
const symbol = SystemPurposes[messagePurposeId as SystemPurposeId]?.symbol;
|
||||
if (symbol)
|
||||
return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
|
||||
// default assistant avatar
|
||||
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
}
|
||||
return <Avatar alt={nameByRole} />;
|
||||
}
|
||||
|
||||
|
||||
export function messageBackground(messageRole: DMessageRole | string, wasEdited: boolean, isAssistantIssue: boolean): string {
|
||||
switch (messageRole) {
|
||||
case 'user':
|
||||
return 'primary.plainHoverBg'; // was .background.level1
|
||||
case 'assistant':
|
||||
return isAssistantIssue ? 'danger.softBg' : 'background.surface';
|
||||
case 'system':
|
||||
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
|
||||
default:
|
||||
return '#ff0000';
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { agiUuid } from '~/common/util/idUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
|
||||
// change this to increase/decrease the number history steps per pane
|
||||
@@ -54,7 +55,7 @@ interface AppChatPanesStore extends AppChatPanesState {
|
||||
|
||||
function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
paneId: agiUuid('chat-pane'),
|
||||
conversationId,
|
||||
history: conversationId ? [conversationId] : [],
|
||||
historyIndex: conversationId ? 0 : -1,
|
||||
@@ -63,7 +64,7 @@ function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
|
||||
function duplicatePane(pane: ChatPane): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
paneId: agiUuid('chat-pane'),
|
||||
conversationId: pane.conversationId,
|
||||
history: [...pane.history],
|
||||
historyIndex: pane.historyIndex,
|
||||
|
||||
@@ -10,17 +10,21 @@ import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
|
||||
import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
|
||||
import { createDMessageTextContent } from '~/common/stores/chat/chat.message';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { navigateToPersonas } from '~/common/app.routes';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useChipBoolean } from '~/common/components/useChipBoolean';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { YouTubeURLInput } from './YouTubeURLInput';
|
||||
import { usePurposeStore } from './store-purposes';
|
||||
|
||||
|
||||
@@ -116,6 +120,8 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [isYouTubeTranscriberActive, setIsYouTubeTranscriberActive] = React.useState(false);
|
||||
|
||||
|
||||
// external state
|
||||
const showFinder = useUIPreferencesStore(state => state.showPersonaFinder);
|
||||
@@ -154,10 +160,39 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
// Handlers
|
||||
|
||||
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
|
||||
if (purposeId && setSystemPurposeId)
|
||||
setSystemPurposeId(props.conversationId, purposeId);
|
||||
if (purposeId) {
|
||||
if (purposeId === 'YouTubeTranscriber') {
|
||||
// If the YouTube Transcriber tile is clicked, set the state accordingly
|
||||
setIsYouTubeTranscriberActive(true);
|
||||
} else {
|
||||
setIsYouTubeTranscriberActive(false);
|
||||
}
|
||||
if (setSystemPurposeId) {
|
||||
setSystemPurposeId(props.conversationId, purposeId);
|
||||
}
|
||||
}
|
||||
}, [props.conversationId, setSystemPurposeId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isTranscriberActive = systemPurposeId === 'YouTubeTranscriber';
|
||||
setIsYouTubeTranscriberActive(isTranscriberActive);
|
||||
}, [systemPurposeId]);
|
||||
|
||||
|
||||
const handleAppendTranscriptAsMessage = (messageText: string) => {
|
||||
// Retrieve the appendMessage action from the useChatStore
|
||||
const { appendMessage } = useChatStore.getState();
|
||||
|
||||
const conversationId = props.conversationId;
|
||||
|
||||
// Create a new message object
|
||||
const newMessage = createDMessageTextContent('assistant', messageText); // [chat] append assistant:YouTube transcript
|
||||
|
||||
// Append the new message to the conversation
|
||||
appendMessage(conversationId, newMessage);
|
||||
};
|
||||
|
||||
|
||||
const handleCustomSystemMessageChange = React.useCallback((v: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
// TODO: persist this change? Right now it's reset every time.
|
||||
// maybe we shall have a "save" button just save on a state to persist between sessions
|
||||
@@ -418,6 +453,17 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [row -1] YouTube URL */}
|
||||
{isYouTubeTranscriberActive && (
|
||||
<YouTubeURLInput
|
||||
onSubmit={(transcript) => handleAppendTranscriptAsMessage(transcript)}
|
||||
isFetching={false}
|
||||
sx={{
|
||||
gridColumn: '1 / -1',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Input } from '@mui/joy';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { useYouTubeTranscript, YTVideoTranscript } from '~/modules/youtube/useYouTubeTranscript';
|
||||
|
||||
|
||||
interface YouTubeURLInputProps {
|
||||
onSubmit: (transcript: string) => void;
|
||||
isFetching: boolean;
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
export const YouTubeURLInput: React.FC<YouTubeURLInputProps> = ({ onSubmit, isFetching, sx }) => {
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [submitFlag, setSubmitFlag] = React.useState(false);
|
||||
|
||||
// Function to extract video ID from URL
|
||||
function extractVideoID(videoURL: string): string | null {
|
||||
const regExp = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^#&?]*).*/;
|
||||
const match = videoURL.match(regExp);
|
||||
return (match && match[1]?.length == 11) ? match[1] : null;
|
||||
}
|
||||
|
||||
const videoID = extractVideoID(url);
|
||||
|
||||
// Callback function to handle new transcript
|
||||
const handleNewTranscript = (newTranscript: YTVideoTranscript) => {
|
||||
onSubmit(newTranscript.transcript); // Pass the transcript text to the onSubmit handler
|
||||
setSubmitFlag(false); // Reset submit flag after handling
|
||||
};
|
||||
|
||||
const { transcript, isFetching: isTranscriptFetching, isError, error } = useYouTubeTranscript(videoID && submitFlag ? videoID : null, handleNewTranscript);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); // Prevent form from causing a page reload
|
||||
setSubmitFlag(true); // Set flag to indicate a submit action
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1, ...sx }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
required
|
||||
type='url'
|
||||
fullWidth
|
||||
disabled={isFetching || isTranscriptFetching}
|
||||
variant='outlined'
|
||||
placeholder='Enter YouTube Video URL'
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
startDecorator={<YouTubeIcon sx={{ color: '#f00' }} />}
|
||||
sx={{ mb: 1.5, backgroundColor: 'background.popup' }}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
variant='solid'
|
||||
disabled={isFetching || isTranscriptFetching || !url}
|
||||
loading={isFetching || isTranscriptFetching}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Get Transcript
|
||||
</Button>
|
||||
{isError && <div>Error fetching transcript. Please try again.</div>}
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const usePurposeStore = create<PurposeStore>()(
|
||||
(set) => ({
|
||||
|
||||
// default state
|
||||
hiddenPurposeIDs: ['Developer', 'Designer'],
|
||||
hiddenPurposeIDs: ['Developer', 'Designer', 'YouTubeTranscriber'],
|
||||
|
||||
toggleHiddenPurposeId: (purposeId: string) => {
|
||||
set(state => {
|
||||
@@ -37,14 +37,19 @@ export const usePurposeStore = create<PurposeStore>()(
|
||||
|
||||
/* versioning:
|
||||
* 1: hide 'Developer' as 'DeveloperPreview' is best
|
||||
* 2: add a hidden 'YouTubeTranscriber' purpose
|
||||
*/
|
||||
version: 1,
|
||||
version: 2,
|
||||
|
||||
migrate: (state: any, fromVersion: number): PurposeStore => {
|
||||
// 0 -> 1: rename 'enterToSend' to 'enterIsNewline' (flip the meaning)
|
||||
if (state && fromVersion === 0)
|
||||
if (!state.hiddenPurposeIDs.includes('Developer'))
|
||||
state.hiddenPurposeIDs.push('Developer');
|
||||
// 1 -> 2: add a hidden 'YouTubeTranscriber' purpose
|
||||
if (state && fromVersion === 1)
|
||||
if (!state.hiddenPurposeIDs.includes('YouTubeTranscriber'))
|
||||
state.hiddenPurposeIDs.push('YouTubeTranscriber');
|
||||
return state;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { getChatLLMId } from '~/modules/llms/store-llms';
|
||||
import { inlineUpdateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DMessage } from '~/common/stores/chat/chat.message';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { createTextContentFragment, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats';
|
||||
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ChatExecuteMode } from '../execute-mode/execute-mode.types';
|
||||
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
|
||||
import { textToDrawCommand } from '../commands/CommandsDraw';
|
||||
|
||||
import { _handleExecuteCommand, RET_NO_CMD } from './_handleExecuteCommand';
|
||||
import { runAssistantUpdatingStateV1 } from './chat-stream-v1';
|
||||
import { runImageGenerationUpdatingState } from './image-generate';
|
||||
import { runPersonaOnConversationHead } from './chat-persona';
|
||||
import { runReActUpdatingState } from './react-tangent';
|
||||
|
||||
|
||||
export async function _handleExecute(chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, executeCallerNameDebug: string) {
|
||||
|
||||
// Handle missing conversation
|
||||
if (!conversationId)
|
||||
return 'err-no-conversation';
|
||||
|
||||
const chatLLMId = getChatLLMId();
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
const initialHistory = cHandler.historyViewHead(executeCallerNameDebug) as Readonly<DMessage[]>;
|
||||
|
||||
// Update the system message from the active persona to the history
|
||||
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
|
||||
// 1. all the callers need to pass a new array
|
||||
// 2. all the exit points need to call setMessages
|
||||
const _inplaceEditableHistory = [...initialHistory];
|
||||
cHandler.inlineUpdatePurposeInHistory(_inplaceEditableHistory, chatLLMId || undefined);
|
||||
|
||||
// FIXME: shouldn't do this for all the code paths. The advantage for having it here (vs Composer output only) is re-executing history
|
||||
// TODO: move this to the server side after transferring metadata?
|
||||
inlineUpdateHistoryForReplyTo(_inplaceEditableHistory);
|
||||
|
||||
// Set the history - note that 'history' objects become invalid after this, and you'd have to
|
||||
// re-read it from the store, such as with `cHandler.historyView()`
|
||||
cHandler.historyReplace(_inplaceEditableHistory);
|
||||
|
||||
|
||||
// Handle unconfigured
|
||||
if (!chatLLMId || !chatExecuteMode)
|
||||
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
|
||||
|
||||
// handle missing last user message (or fragment)
|
||||
// note that we use the initial history, as the user message could have been displaced on the edited versions
|
||||
const lastMessage = initialHistory.length >= 1 ? initialHistory.slice(-1)[0] : null;
|
||||
const firstFragment = lastMessage?.fragments[0];
|
||||
if (!lastMessage || !firstFragment)
|
||||
return 'err-no-last-message';
|
||||
|
||||
|
||||
// execute a command, if the last message has one
|
||||
if (lastMessage.role === 'user') {
|
||||
const cmdRC = await _handleExecuteCommand(lastMessage.id, firstFragment, cHandler, chatLLMId);
|
||||
if (cmdRC !== RET_NO_CMD) return cmdRC;
|
||||
}
|
||||
|
||||
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
// TODO: change this massively
|
||||
if (!getConversationSystemPurposeId(conversationId)) {
|
||||
cHandler.messageAppendAssistantText('Issue: no Persona selected.', 'issue');
|
||||
return 'err-no-persona';
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
switch (chatExecuteMode) {
|
||||
case 'generate-content':
|
||||
return await runPersonaOnConversationHead(chatLLMId, conversationId);
|
||||
|
||||
case 'generate-text-v1':
|
||||
return await runAssistantUpdatingStateV1(conversationId, cHandler.historyViewHead('generate-text-v1'), chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'beam-content':
|
||||
cHandler.beamInvoke(cHandler.historyViewHead('beam-content'), [], null);
|
||||
return true;
|
||||
|
||||
case 'append-user':
|
||||
return true;
|
||||
|
||||
case 'generate-image':
|
||||
// verify we were called with a single DMessageTextContent
|
||||
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
|
||||
return false;
|
||||
const imagePrompt = firstFragment.part.text;
|
||||
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(imagePrompt)), true);
|
||||
return await runImageGenerationUpdatingState(cHandler, imagePrompt);
|
||||
|
||||
case 'react-content':
|
||||
// verify we were called with a single DMessageTextContent
|
||||
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
|
||||
return false;
|
||||
const reactPrompt = firstFragment.part.text;
|
||||
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(reactPrompt)), true);
|
||||
return await runReActUpdatingState(cHandler, reactPrompt, chatLLMId);
|
||||
|
||||
default:
|
||||
console.log('Chat execute: issue running', chatExecuteMode, conversationId, lastMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user