mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
1087 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 | |||
| 78e3a57857 | |||
| 79d0c96b20 | |||
| 21ed38a20e | |||
| d8b1f99114 | |||
| b0fb1b9890 | |||
| a63932cff2 | |||
| 0b22165d2a | |||
| 41b1951abe | |||
| 353431e54c | |||
| 7b232dd7d8 | |||
| d32adf9dbf | |||
| 940d490217 | |||
| 46e41e38cf | |||
| 276ff8f995 | |||
| 030837fccf | |||
| a7d38aefb1 | |||
| 230a0d7caf | |||
| 6e14e43c78 | |||
| e6389f08be | |||
| a4edeb098e | |||
| 093c536415 | |||
| 7479b50fea | |||
| ebce36d043 | |||
| 77bab1aa74 | |||
| ebcac3405c | |||
| d2781a6f87 | |||
| f5954f5bb3 | |||
| 6baf694d6f | |||
| cb3b586d4d | |||
| f68789ab20 | |||
| 0c6a3f1917 | |||
| 05fccaf982 | |||
| 7340b9ecc2 | |||
| 78eb4ebe0b | |||
| b1453a34ec | |||
| c357e9e2f5 | |||
| 98717bf8a9 | |||
| d7077ada0e | |||
| 64f63ed1d3 | |||
| 2a27f6c30d | |||
| 9fdddeaba8 | |||
| 2cfa5e93e4 | |||
| 778ac14344 | |||
| 85fcf8be61 | |||
| b31eb09015 | |||
| 5154dd1740 | |||
| 274f11ef1d | |||
| aeb1acf458 | |||
| a204f4a58e | |||
| 8e4a57aa01 | |||
| 797ed0a553 | |||
| 663bc0d471 | |||
| 8d7e2d2c46 | |||
| 19d96bb30b | |||
| 47f2f20d9c | |||
| 12c7c634c0 | |||
| 9a322c150a | |||
| 1a3bc4f666 | |||
| d4881b1ce5 | |||
| a2ad2df473 | |||
| 541c5bd1c3 | |||
| b744e9673b | |||
| bb94b7c5c6 | |||
| e9ff57d5e1 | |||
| 179245457c | |||
| 1493f74691 | |||
| 4857503ed3 | |||
| a0e38b4f0c | |||
| 1d62cad9e9 | |||
| 855761020c | |||
| 0950d06dfb | |||
| 1496402325 | |||
| 77e2c4babb | |||
| a465082984 | |||
| 025fdac686 | |||
| 6bde5ec64c | |||
| f099a9ec39 | |||
| 5bfcef92ee | |||
| 79a8fbd881 | |||
| 7f96a14cf6 | |||
| 5fe6d70713 | |||
| dcba4dd4bc | |||
| ccbe77913b | |||
| 2844cb81c2 | |||
| d86e8e5920 | |||
| 9665fa1eb4 | |||
| 2788ef679b | |||
| e1a88e1fd8 | |||
| 32163c5302 | |||
| 2d3d5efe87 | |||
| e1bbba392c | |||
| ed642c856b | |||
| 927e462f7a | |||
| e250499a3b | |||
| 91d96a6639 | |||
| 104ec4c87c | |||
| 0a7e8436c3 | |||
| 9e597e0a28 | |||
| 01fbb5d47c | |||
| 6517d16337 | |||
| 0e636adf28 | |||
| 0bb281237b | |||
| 2b224376c2 | |||
| e510b369d7 | |||
| a0de1f7230 | |||
| 4591132269 | |||
| a03de8d490 | |||
| 27bcfec17e | |||
| f6dbec3e1d | |||
| aebc45f705 | |||
| 310c60b9d9 | |||
| bcba67c209 | |||
| fc013aed52 | |||
| 8ad41c059b | |||
| 8eaf8db850 | |||
| 896883766c | |||
| 258dacf3ed | |||
| 242243f485 | |||
| a18436dce1 | |||
| 5323cbc00e | |||
| ddd3b137ac | |||
| 94550088e5 | |||
| 1375ca6f5c | |||
| e4c4fe0495 | |||
| 2fa5277e56 | |||
| b73ad8fdc1 | |||
| 9cc281e65e | |||
| d62107d39b | |||
| 4a8d20ad72 | |||
| 5acb72c39b | |||
| 67e8236a60 | |||
| 18b8853f82 | |||
| 65c7df7938 | |||
| 15678cdfa2 | |||
| 6cd6c62046 | |||
| dbf92805a2 | |||
| 11fc9a7b85 | |||
| 8bc970ff57 | |||
| a16eefd97b | |||
| ca5e5b820c | |||
| f73ad52441 | |||
| 729ec1d1bf | |||
| 4adb30b861 | |||
| 999f6de45f | |||
| 70686502b4 | |||
| d17a980151 | |||
| 7fa5947030 | |||
| de8f120fd4 | |||
| 9b54603264 | |||
| 698c77d7ba | |||
| 18d83a4d18 | |||
| 8e849d93b2 | |||
| 4ca42f028b | |||
| 3118337879 | |||
| db4490affb | |||
| 51ab79384e | |||
| 3ee30a252d | |||
| b883566ebb | |||
| ac78fb85b8 | |||
| 0d2b11d0c4 | |||
| 5b610c88c1 | |||
| bf444ce043 | |||
| c91c027dab | |||
| 81fd87c510 | |||
| 9da174a962 | |||
| 84f54a7e65 | |||
| baeecf1464 | |||
| f2fdd39c96 | |||
| 53b074d78e | |||
| f4fc1e6775 | |||
| dba791b8db | |||
| 750fa02621 | |||
| 7a67816111 | |||
| 613625644e | |||
| 0e25071ef0 | |||
| ed1932cd26 | |||
| 67b89213d0 | |||
| 814f142c5f | |||
| 16cd3e7d5a | |||
| c5dcb8faef | |||
| 6b46c022f9 | |||
| 88ef05fc72 | |||
| 445ea367fc | |||
| c819554f43 | |||
| bbc8a79ded | |||
| 3d181bc10d | |||
| ba5478f382 | |||
| 136c993c8d | |||
| 6cf18ea4e8 | |||
| fe7f56c82e | |||
| 6c580f1e43 | |||
| f171cd4f03 | |||
| ea109e6c30 | |||
| f514eed226 | |||
| 274ba80149 | |||
| 46b4dfc458 | |||
| 4af8f4ff6a | |||
| df5810d695 | |||
| d9ad96c374 | |||
| 06cc93fd82 | |||
| 41da63765f | |||
| 3975411c78 | |||
| fc2e75ef61 | |||
| ef0f2dd3d0 | |||
| 548c3c5d72 | |||
| d2e3a0cb8e | |||
| 9cdace6f81 | |||
| 12f020570e | |||
| bef2551eec | |||
| 7e20f8c189 | |||
| 56e8390e55 | |||
| 89fff16385 | |||
| 2cf15a24eb | |||
| 512e867034 | |||
| ce8c55c3c7 | |||
| 8e0d904d9a | |||
| 6c846a8ae7 | |||
| 5004469fe9 | |||
| 14d0af74ed | |||
| 5a76cf9486 | |||
| 82901ccd02 | |||
| 1dc9d66673 | |||
| a0cbfaf390 | |||
| 9a01ae61ef | |||
| 91837d5acd | |||
| 1b9ebdda22 | |||
| b6f6177af3 | |||
| d35486196b | |||
| 1603637e3b | |||
| 8f20840169 | |||
| 4fff2394de | |||
| afb74e68ee | |||
| d5fa7844c5 | |||
| b8470cd640 | |||
| 9a23f573a6 | |||
| efe8fa0fda | |||
| 2d16e8bb4f | |||
| bbd95eebff | |||
| ceb00b4e93 | |||
| cc60d26d1c | |||
| ba3ff739f6 | |||
| 6062647705 | |||
| 070c1c2de9 | |||
| d3aaa69409 | |||
| 0ac7753e35 | |||
| eba9d53d2e | |||
| d04d4ec8e7 | |||
| c7c3efcbe7 | |||
| 2b8d53a44c | |||
| ef6b573e08 | |||
| 61eedd41df | |||
| b265bcda20 | |||
| d703d32a1f | |||
| aab9334404 | |||
| c2570f6955 | |||
| 8e936a6334 | |||
| 46bfc22869 | |||
| db1620dd56 | |||
| e59f8a42a3 | |||
| 17d18bd85d | |||
| fb256cf578 | |||
| 1b6b5db76d | |||
| 41647ca83a | |||
| 07d2a17a87 | |||
| 6d744dfb7e | |||
| b9b946c35f | |||
| 17adfe2117 | |||
| 1e5e21102d | |||
| 4af992222f | |||
| a9447c6a11 | |||
| db71323313 | |||
| b9b2748e05 | |||
| 387231f743 | |||
| 2216a89aa3 | |||
| 4faa6326fa | |||
| cb22b3d9a1 | |||
| 152a3873bd | |||
| adc2760a89 | |||
| dde64acb06 | |||
| 008adbd8bc | |||
| 0e4866a5a2 | |||
| 5cb96cae3a | |||
| 8cbb82a67f | |||
| 848ddbe477 | |||
| 083c1cde8b | |||
| b792971062 | |||
| 07dde8f4b1 | |||
| 01f94127dd | |||
| 4d457b4e9e | |||
| 8ac93ff2da | |||
| ef33a4b08e | |||
| fdd3b25a27 | |||
| 4dc979da08 | |||
| 8f426e03c4 | |||
| 40cd085bf8 | |||
| 6aa75fc5d1 | |||
| eae5920f9d | |||
| 2f6bfa37cc | |||
| 9d6fd9b9b8 | |||
| 260cd67c96 | |||
| aff76e2d18 | |||
| 52e4343045 | |||
| 1ffbb135c6 | |||
| c3ec522261 | |||
| 4538839376 | |||
| 834edd3a71 | |||
| 581c3d9593 | |||
| 0c672fbaa5 | |||
| 6d96b9a312 | |||
| 691791ccd0 | |||
| f4299121d5 | |||
| 1adfb7eedd | |||
| 33ad583d15 | |||
| a7e2fe2277 | |||
| 5a479d5863 | |||
| 873ff034d2 | |||
| 61d3537617 | |||
| ae068a3f64 | |||
| f7402cd6f5 | |||
| c53f9c8020 | |||
| 798b4d57f4 | |||
| 98d428fb34 | |||
| 3ac5ace216 | |||
| 444a1a7ab9 | |||
| 43ea4bd4b5 | |||
| 6a9272e40a | |||
| 10589a11aa | |||
| a88f898bc0 | |||
| 7a84038b04 | |||
| 111c40732d | |||
| 69bb78c8be | |||
| ad3b327d69 | |||
| dc27f38534 | |||
| 5b0816cb92 | |||
| 57f6955303 | |||
| 78915f878d | |||
| 6ced6d626b | |||
| ee3cb819b4 | |||
| cc17b1d19d | |||
| 2c83240d47 | |||
| 54f18ff120 | |||
| 5e1fe363c3 | |||
| 3d2ec507e1 | |||
| 1dd7af3c8b | |||
| 06ec1fcebf | |||
| 86cb863fd4 | |||
| d5ef1288d8 | |||
| f3354c498d | |||
| 9557141b38 | |||
| 3144b66e73 | |||
| 6dbefa3d2f | |||
| c8f3b139e8 | |||
| 288663325d | |||
| 49947ee01d | |||
| fa7a45ebc7 | |||
| 9a074c222f | |||
| 4e0d7b6ed9 | |||
| 1f3defb04c | |||
| 6c52c43460 | |||
| deae2879f1 | |||
| 5b255a7d8b | |||
| 6e06c24b7a | |||
| 2fde1efdd3 | |||
| aeb29d983a | |||
| c8a7123da9 | |||
| 5c22061415 | |||
| 9a0fda8c02 | |||
| 2f9a17c44a | |||
| 50559015d8 | |||
| a8d4e143c2 | |||
| 2a6c69538d | |||
| 0ba5d61353 | |||
| d436ec5790 | |||
| 759b822b92 | |||
| 9df45af698 | |||
| 3474e81446 | |||
| e1f07eb957 | |||
| 71ff1b98be | |||
| 9b370dfa88 | |||
| 0be0661750 | |||
| eaa7230af7 | |||
| 11cb000481 | |||
| 8ae3554a58 | |||
| dfd4736386 | |||
| feb793c9fa | |||
| ee962fde08 | |||
| c08dd96de3 | |||
| b52f771133 | |||
| 4631232551 | |||
| df7f5047aa | |||
| 467d14324d | |||
| cbdce08e96 | |||
| d6bf8f8854 | |||
| 4599da3ded | |||
| 6d50952b2e | |||
| 7066947809 | |||
| e2924aacab | |||
| 1e86d2503f | |||
| eb67eee53a | |||
| dfdad45963 | |||
| 4735508d87 | |||
| c43c47eab8 | |||
| fafb2dc6b9 | |||
| 140e99c465 | |||
| 7ba1974390 | |||
| 51b8510f17 | |||
| 5d6949d471 | |||
| 8e9d0c1fd1 | |||
| 3852a3b779 | |||
| 8b4ba96936 | |||
| 0c17e18491 | |||
| 2bdbab3afc | |||
| b97499a95e | |||
| a70ac57872 | |||
| a9cf457024 | |||
| e5c938ac37 | |||
| edad54efa2 | |||
| f88426758f | |||
| 77a28eb810 | |||
| f834b27562 | |||
| 984e257cc5 | |||
| 729e7612bc | |||
| 59fadeae57 | |||
| bfbf7a298a | |||
| aad5d3bd65 | |||
| 504f19c445 | |||
| 19c47eb442 | |||
| ab6043df60 | |||
| 3305549a0f | |||
| c24c3cb571 | |||
| 952999258b | |||
| 0713eaa52c | |||
| 8fee689f60 | |||
| 75ddb17fed | |||
| 0c6a74626c | |||
| 41e3d0eaf9 | |||
| 8b9cfebd42 | |||
| 16badee259 | |||
| 9d5171dd36 | |||
| e0c0e81b7d | |||
| fd4e8985fc | |||
| 1d9b8503c0 | |||
| b3ef7b914d | |||
| 2f59e12e20 | |||
| 30e8652c2a | |||
| 5ee6aceb60 | |||
| 6940b6a6d1 | |||
| 4e33ce9415 | |||
| 944e22bde6 | |||
| 6054fa0a26 | |||
| 4db13cfed4 | |||
| 6a6adda2e0 | |||
| 4afa55c0db |
@@ -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 }}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# BIG-AGI 🧠✨
|
||||
|
||||
Welcome to big-AGI 👋, the GPT application for professionals that need function, form,
|
||||
Welcome to big-AGI, the AI suite for professionals that need function, form,
|
||||
simplicity, and speed. Powered by the latest models from 12 vendors and
|
||||
open-source model servers, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
|
||||
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
|
||||
open-source servers, `big-AGI` offers best-in-class Chats,
|
||||
[Beams](https://github.com/enricoros/big-AGI/issues/470),
|
||||
and [Calls](https://github.com/enricoros/big-AGI/issues/354) with AI personas,
|
||||
visualizations, coding, drawing, side-by-side chatting, and more -- all wrapped in a polished UX.
|
||||
|
||||
Pros use big-AGI. 🚀 Developers love big-AGI. 🤖
|
||||
Stay ahead of the curve with big-AGI. 🚀 Pros & Devs love big-AGI. 🤖
|
||||
|
||||
[](https://big-agi.com)
|
||||
|
||||
@@ -13,21 +15,65 @@ 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`.
|
||||
|
||||
### What's New in 1.14.0 · March 6, 2024 · Modelmorphic
|
||||
[//]: # (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
|
||||
|
||||
- ⚠️ [**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)
|
||||
- Managed Deployments **Auto-Configuration**: simplify the UI models setup with backend-set models. [#436](https://github.com/enricoros/big-AGI/issues/436)
|
||||
- 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)
|
||||
|
||||
<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)
|
||||
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
|
||||
- **Mistral** Large and Google **Gemini 1.5** support
|
||||
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- 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
|
||||
- [Release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.14.0), and changes [v1.13.1...v1.14.0](https://github.com/enricoros/big-AGI/compare/v1.13.1...v1.14.0) (233 commits, 8,000+ lines changed)
|
||||
|
||||
### 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
|
||||
|
||||
@@ -39,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>
|
||||
|
||||
@@ -83,11 +131,11 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
|
||||
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## ✨ Key Features 👊
|
||||
## 👉 Key Features ✨
|
||||
|
||||
|  |  |  |  |  |
|
||||
|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| **Chat**<br/>**Call** AGI<br/>**Draw** images<br/>**Agents**, ... | Local & Cloud<br/>Open & Closed<br/>Cheap & Heavy<br/>Google, Mistral, ... | Attachments<br/>Diagrams<br/>Multi-Chat<br/>Mobile-first UI | Stored Locally<br/>Easy self-Host<br/>Local actions<br/>Data = Gold | AI Personas<br/>Voice Modes<br/>Screen Capture<br/>Camera + OCR |
|
||||
| **Chat**<br/>**Call**<br/>**Beam**<br/>**Draw**, ... | Local & Cloud<br/>Open & Closed<br/>Cheap & Heavy<br/>Google, Mistral, ... | Attachments<br/>Diagrams<br/>Multi-Chat<br/>Mobile-first UI | Stored Locally<br/>Easy self-Host<br/>Local actions<br/>Data = Gold | AI Personas<br/>Voice Modes<br/>Screen Capture<br/>Camera + OCR |
|
||||
|
||||

|
||||
|
||||
@@ -95,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/) |
|
||||
@@ -130,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))
|
||||
@@ -139,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,15 +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,
|
||||
});
|
||||
|
||||
|
||||
// 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 };
|
||||
+9
-15
@@ -28,7 +28,7 @@ Detailed guides to configure your big-AGI interface and models.
|
||||
- **Advanced Feature Configuration**:
|
||||
- **[Browse](config-feature-browse.md)**: Enable web page download through third-party services or your own cloud (advanced)
|
||||
- **ElevenLabs API**: Voice and cutom voice generation, only requires their API key
|
||||
- **Google Search API**: guide not yet available, see the Google options in 'Environment Variables'
|
||||
- **Google Search API**: guide not yet available, see the Google options in '[Environment Variables](environment-variables.md)'
|
||||
- **Prodia API**: Stable Diffusion XL image generation, only requires their API key, alternative to DALL·E
|
||||
|
||||
## Deployment
|
||||
@@ -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
|
||||
|
||||
|
||||
+41
-5
@@ -5,15 +5,51 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.15.0 - Mar 2024
|
||||
### 1.17.0 - Jun 2024
|
||||
|
||||
Prediction: OpenAI will release GPT-5 on March 14, 2024. We will support it on day 1.
|
||||
|
||||
- milestone: [1.15.0](https://github.com/enricoros/big-agi/milestone/15)
|
||||
- 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.14.0 · March 6, 2024 · Modelmorphic
|
||||
### 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)
|
||||
- Managed Deployments **Auto-Configuration**: simplify the UI mdoels setup with backend-set models. [#436](https://github.com/enricoros/big-AGI/issues/436)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- **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)
|
||||
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
|
||||
- **Mistral** Large and Google **Gemini 1.5** support
|
||||
|
||||
@@ -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.
|
||||
|
||||
+25
-6
@@ -22,6 +22,25 @@ Understand the Architecture: big-AGI uses Next.js, React for the front end, and
|
||||
|
||||
This necessitates a code change (file renaming) before build initiation, detailed in [deploy-authentication.md](deploy-authentication.md).
|
||||
|
||||
### Increase Vercel Functions Timeout
|
||||
|
||||
For long-running operations, Vercel allows paid deployments to increase the timeout on Functions.
|
||||
Note that this applies to old-style Vercel Functions (based on Node.js) and not the new Edge Functions.
|
||||
|
||||
At time of writing, big-AGI has only 2 operations that run on Node.js Functions:
|
||||
browsing (fetching web pages) and sharing. They both can exceed 10 seconds, especially
|
||||
when fetching large pages or waiting for websites to be completed.
|
||||
|
||||
We provide `vercel_PRODUCTION.json` to raise the duration to 25 seconds (from a default of 10), to use it,
|
||||
make sure to rename it to `vercel.json` before build.
|
||||
|
||||
From the Vercel Project > Settings > General > Build & Development Settings,
|
||||
you can for instance set the build command to:
|
||||
|
||||
```bash
|
||||
mv vercel_PRODUCTION.json vercel.json; next build
|
||||
```
|
||||
|
||||
### Change the Personas
|
||||
|
||||
Edit the `src/data.ts` file to customize personas. This file houses the default personas. You can add, remove, or modify these to meet your project's needs.
|
||||
@@ -47,20 +66,20 @@ 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
|
||||
|
||||
After deployment, share your project with the community. We will link to your project to help others discover and learn from your work.
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
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. |
|
||||
@@ -147,5 +147,5 @@ The value of these variables are passed to the frontend (Web UI) - make sure the
|
||||
|
||||
---
|
||||
|
||||
For a higher level overview of backend code and environemnt customization,
|
||||
For a higher level overview of backend code and environment customization,
|
||||
see the [big-AGI Customization](customizations.md) guide.
|
||||
|
||||
@@ -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
+5259
-1116
File diff suppressed because it is too large
Load Diff
+73
-41
@@ -1,18 +1,23 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.14.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.11",
|
||||
"@mui/joy": "^5.0.0-beta.29",
|
||||
"@next/bundle-analyzer": "^14.1.2",
|
||||
"@next/third-parties": "^14.1.2",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@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.2",
|
||||
"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.12",
|
||||
"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.24",
|
||||
"@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.62",
|
||||
"@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.19",
|
||||
"@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.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.10.2",
|
||||
"typescript": "^5.3.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -11,13 +11,14 @@ 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 { ProviderBackendAndNoSSR } from '~/common/providers/ProviderBackendAndNoSSR';
|
||||
import { ProviderBackendCapabilities } from '~/common/providers/ProviderBackendCapabilities';
|
||||
import { ProviderBootstrapLogic } from '~/common/providers/ProviderBootstrapLogic';
|
||||
import { ProviderSingleTab } from '~/common/providers/ProviderSingleTab';
|
||||
import { ProviderSnacks } from '~/common/providers/ProviderSnacks';
|
||||
import { ProviderTRPCQueryClient } from '~/common/providers/ProviderTRPCQueryClient';
|
||||
import { ProviderTRPCQuerySettings } from '~/common/providers/ProviderTRPCQuerySettings';
|
||||
import { ProviderTheming } from '~/common/providers/ProviderTheming';
|
||||
import { hasGoogleAnalytics, OptionalGoogleAnalytics } from '~/common/components/GoogleAnalytics';
|
||||
import { isVercelFromFrontend } from '~/common/util/pwaUtils';
|
||||
@@ -33,15 +34,16 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
|
||||
<ProviderTheming emotionCache={emotionCache}>
|
||||
<ProviderSingleTab>
|
||||
<ProviderBootstrapLogic>
|
||||
<ProviderTRPCQueryClient>
|
||||
<ProviderSnacks>
|
||||
<ProviderBackendAndNoSSR>
|
||||
<ProviderTRPCQuerySettings>
|
||||
<ProviderBackendCapabilities>
|
||||
{/* ^ SSR boundary */}
|
||||
<ProviderBootstrapLogic>
|
||||
<ProviderSnacks>
|
||||
<Component {...pageProps} />
|
||||
</ProviderBackendAndNoSSR>
|
||||
</ProviderSnacks>
|
||||
</ProviderTRPCQueryClient>
|
||||
</ProviderBootstrapLogic>
|
||||
</ProviderSnacks>
|
||||
</ProviderBootstrapLogic>
|
||||
</ProviderBackendCapabilities>
|
||||
</ProviderTRPCQuerySettings>
|
||||
</ProviderSingleTab>
|
||||
</ProviderTheming>
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/icons/favicon-16x16.png' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='black' />
|
||||
|
||||
{/* Opengraph */}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppBeam } from '../../src/apps/beam/AppBeam';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function BeamPage() {
|
||||
return withLayout({ type: 'optima' }, <AppBeam />);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { AppPlaceholder } from '../../src/apps/AppPlaceholder';
|
||||
|
||||
import { backendCaps } from '~/modules/backend/state-backend';
|
||||
import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities';
|
||||
import { getPlantUmlServerUrl } from '~/modules/blocks/code/RenderCode';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
@@ -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';
|
||||
|
||||
@@ -76,11 +77,12 @@ function AppDebug() {
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const backendCapabilities = backendCaps();
|
||||
const backendCaps = getBackendCapabilities();
|
||||
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
|
||||
@@ -112,7 +114,7 @@ function AppDebug() {
|
||||
},
|
||||
};
|
||||
const cBackend = {
|
||||
configuration: backendCapabilities,
|
||||
configuration: backendCaps,
|
||||
deployment: {
|
||||
home: Brand.URIs.Home,
|
||||
hostName: clientHostName(),
|
||||
@@ -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: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 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
+17
-15
@@ -10,7 +10,7 @@ import { useRouterRoute } from '~/common/app.routes';
|
||||
* https://github.com/enricoros/big-AGI/issues/299
|
||||
*/
|
||||
export function AppPlaceholder(props: {
|
||||
title?: string,
|
||||
title?: string | null,
|
||||
text?: React.ReactNode,
|
||||
children?: React.ReactNode,
|
||||
}) {
|
||||
@@ -29,23 +29,25 @@ export function AppPlaceholder(props: {
|
||||
border: '1px solid blue',
|
||||
}}>
|
||||
|
||||
<Box sx={{
|
||||
my: 'auto',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 4,
|
||||
border: '1px solid red',
|
||||
}}>
|
||||
{(props.title !== null || !!props.text) && (
|
||||
<Box sx={{
|
||||
my: 'auto',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 4,
|
||||
border: '1px solid red',
|
||||
}}>
|
||||
|
||||
<Typography level='h1'>
|
||||
{placeholderAppName}
|
||||
</Typography>
|
||||
{!!props.text && (
|
||||
<Typography>
|
||||
{props.text}
|
||||
<Typography level='h1'>
|
||||
{placeholderAppName}
|
||||
</Typography>
|
||||
)}
|
||||
{!!props.text && (
|
||||
<Typography>
|
||||
{props.text}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, Button, Typography } from '@mui/joy';
|
||||
|
||||
import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
|
||||
import { BeamView } from '~/modules/beam/BeamView';
|
||||
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
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(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, (content) => alert(content));
|
||||
return beamStore;
|
||||
}
|
||||
|
||||
|
||||
export function AppBeam() {
|
||||
|
||||
// state
|
||||
const [showDebug, setShowDebug] = React.useState(false);
|
||||
|
||||
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();
|
||||
const { isOpen, beamState } = useBeamStore(beamStoreApi, useShallow(state => {
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
beamState: showDebug ? state : null,
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
beamStoreApi.getState().terminateKeepingSettings();
|
||||
}, [beamStoreApi]);
|
||||
|
||||
|
||||
// layout
|
||||
usePluggableOptimaLayout(null, React.useMemo(() => <>
|
||||
{/* button to toggle debug info */}
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={() => setShowDebug(on => !on)}>
|
||||
{showDebug ? 'Hide' : 'Show'} debug
|
||||
</Button>
|
||||
|
||||
{/* 'open' */}
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={() => setConversation(initTestConversation())}>
|
||||
.open
|
||||
</Button>
|
||||
|
||||
{/* 'close' */}
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={handleClose}>
|
||||
.close
|
||||
</Button>
|
||||
</>, [handleClose, showDebug]), null, 'AppBeam');
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', position: 'relative' }}>
|
||||
|
||||
{isOpen && (
|
||||
<BeamView
|
||||
beamStore={beamStoreApi}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDebug && (
|
||||
<Typography level='body-xs' sx={{
|
||||
whiteSpace: 'pre',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1 /* debug on top of BeamView */,
|
||||
backdropFilter: 'blur(4px)',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{JSON.stringify(beamState, null, 2)
|
||||
// add an extra newline between first level properties (space, space, double quote) to make it more readable
|
||||
.split('\n').map(line => line.replace(/^\s\s"/g, '\n ')).join('\n')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,60 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
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';
|
||||
|
||||
|
||||
/*export const cssRainbowBackgroundKeyframes = keyframes`
|
||||
100%, 0% {
|
||||
background-color: rgb(128, 0, 0);
|
||||
}
|
||||
8% {
|
||||
background-color: rgb(102, 51, 0);
|
||||
}
|
||||
16% {
|
||||
background-color: rgb(64, 64, 0);
|
||||
}
|
||||
25% {
|
||||
background-color: rgb(38, 76, 0);
|
||||
}
|
||||
33% {
|
||||
background-color: rgb(0, 89, 0);
|
||||
}
|
||||
41% {
|
||||
background-color: rgb(0, 76, 41);
|
||||
}
|
||||
50% {
|
||||
background-color: rgb(0, 64, 64);
|
||||
}
|
||||
58% {
|
||||
background-color: rgb(0, 51, 102);
|
||||
}
|
||||
66% {
|
||||
background-color: rgb(0, 0, 128);
|
||||
}
|
||||
75% {
|
||||
background-color: rgb(63, 0, 128);
|
||||
}
|
||||
83% {
|
||||
background-color: rgb(76, 0, 76);
|
||||
}
|
||||
91% {
|
||||
background-color: rgb(102, 0, 51);
|
||||
}`;*/
|
||||
|
||||
function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) {
|
||||
return (
|
||||
<Card sx={{ width: '100%' }}>
|
||||
@@ -67,7 +29,7 @@ function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: s
|
||||
{props.button}
|
||||
</Typography>
|
||||
<ListItemDecorator>
|
||||
{props.hasIssue ? <WarningRoundedIcon color='warning' /> : <CheckIcon color='success' />}
|
||||
{props.hasIssue ? <WarningRoundedIcon color='warning' /> : <CheckRoundedIcon color='success' />}
|
||||
</ListItemDecorator>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -124,7 +86,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 'sm', textAlign: 'center' }}>
|
||||
Welcome to<br />
|
||||
<Box component='span' sx={{ animation: `${cssRainbowColorKeyframes} 15s linear infinite` }}>
|
||||
<Box component='span' sx={{ animation: `${animationColorRainbow} 15s linear infinite` }}>
|
||||
your first call
|
||||
</Box>
|
||||
</Typography>
|
||||
@@ -167,7 +129,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
{/* Text to Speech status */}
|
||||
<StatusCard
|
||||
icon={<RecordVoiceOverIcon />}
|
||||
icon={<RecordVoiceOverTwoToneIcon />}
|
||||
text={
|
||||
(synthesis.mayWork ? 'Voice synthesis should be ready.' : 'There might be an issue with ElevenLabs voice synthesis.')
|
||||
+ (synthesis.isConfiguredServerSide ? '' : (synthesis.isConfiguredClientSide ? '' : ' Please add your API key in the settings.'))
|
||||
@@ -208,7 +170,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
// boxShadow: allGood ? 'md' : 'none',
|
||||
}}
|
||||
>
|
||||
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseRoundedIcon sx={{ fontSize: '1.5em' }} />}
|
||||
{allGood ? <ArrowForwardRoundedIcon sx={{ fontSize: '1.5em' }} /> : <CloseRoundedIcon sx={{ fontSize: '1.5em' }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
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 { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { animationShadowRingLimey } from '~/common/util/animUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
@@ -19,27 +19,6 @@ import { useAppCallStore } from './state/store-app-call';
|
||||
const COLLAPSED_COUNT = 2;
|
||||
|
||||
|
||||
export const niceShadowKeyframes = keyframes`
|
||||
100%, 0% {
|
||||
//background-color: rgb(102, 0, 51);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(183, 255, 0);
|
||||
}
|
||||
25% {
|
||||
//background-color: rgb(76, 0, 76);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 251, 0);
|
||||
//scale: 1.2;
|
||||
}
|
||||
50% {
|
||||
//background-color: rgb(63, 0, 128);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgba(0, 255, 81);
|
||||
//scale: 0.8;
|
||||
}
|
||||
75% {
|
||||
//background-color: rgb(0, 0, 128);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 153, 0);
|
||||
}`;
|
||||
|
||||
|
||||
const ContactCardAvatar = (props: { size: string, symbol?: string, imageUrl?: string, onClick?: () => void, sx?: SxProps }) =>
|
||||
<Avatar
|
||||
// variant='outlined'
|
||||
@@ -81,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,
|
||||
}) {
|
||||
|
||||
@@ -125,7 +104,6 @@ function CallContactCard(props: {
|
||||
sx={{
|
||||
mx: 'auto',
|
||||
mt: '-2.5rem',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -211,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
|
||||
@@ -282,7 +260,7 @@ export function Contacts(props: { setCallIntent: (intent: AppCallIntent) => void
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'background.popup',
|
||||
animation: `${niceShadowKeyframes} 5s infinite`,
|
||||
animation: `${animationShadowRingLimey} 5s infinite`,
|
||||
}}>
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
|
||||
+36
-38
@@ -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';
|
||||
@@ -7,22 +7,24 @@ import CallEndIcon from '@mui/icons-material/CallEnd';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import MicNoneIcon from '@mui/icons-material/MicNone';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
|
||||
import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '../chat/components/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
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';
|
||||
@@ -57,7 +59,7 @@ function CallMenuItems(props: {
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleChangeVoiceToggle}>
|
||||
<ListItemDecorator><RecordVoiceOverIcon /></ListItemDecorator>
|
||||
<ListItemDecorator><RecordVoiceOverTwoToneIcon /></ListItemDecorator>
|
||||
Change Voice
|
||||
<Switch checked={props.override} onChange={handleChangeVoiceToggle} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
@@ -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);
|
||||
@@ -331,28 +342,15 @@ export function Telephone(props: {
|
||||
padding: 0, // move this to the ScrollToBottom component
|
||||
}}>
|
||||
|
||||
<ScrollToBottom
|
||||
// bootToBottom
|
||||
stickToBottom
|
||||
sx={{
|
||||
// allows the content to be scrolled (all browsers)
|
||||
overflowY: 'auto',
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
<ScrollToBottom stickToBottomInitial>
|
||||
|
||||
// content
|
||||
display: 'grid',
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ minHeight: '100%', p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
{/* Call Messages [] */}
|
||||
{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}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import { Avatar, Box } from '@mui/joy';
|
||||
|
||||
|
||||
const cssScaleKeyframes = keyframes`
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}`;
|
||||
import { animationScalePulse } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging?: boolean, onClick: () => void }) {
|
||||
@@ -34,7 +23,7 @@ export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging
|
||||
<Box
|
||||
sx={{
|
||||
...(props.isRinging
|
||||
? { animation: `${cssScaleKeyframes} 1.4s ease-in-out infinite` }
|
||||
? { animation: `${animationScalePulse} 1.4s ease-in-out infinite` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
|
||||
+280
-291
@@ -1,62 +1,75 @@
|
||||
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';
|
||||
import { useAreBeamsOpen } from '~/modules/beam/store-beam.hooks';
|
||||
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { ConversationManager } from '~/common/chats/ConversationHandler';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
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 { Beam } from './components/beam/Beam';
|
||||
import { ChatDrawerMemo } from './components/ChatDrawer';
|
||||
import { ChatDropdowns } from './components/ChatDropdowns';
|
||||
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/layout-drawer/ChatDrawer';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
|
||||
import { ChatTitle } from './components/ChatTitle';
|
||||
import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { ScrollToBottom } from './components/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from './components/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { getInstantAppChatPanesCount, usePanesManager } from './components/panes/usePanesManager';
|
||||
import { usePanesManager } from './components/panes/usePanesManager';
|
||||
|
||||
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() {
|
||||
@@ -78,35 +91,59 @@ export function AppChat() {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const showAltTitleBar = useUXLabsStore(state => state.labsChatBarAlt === 'title');
|
||||
const intent = useRouterQuery<Partial<AppChatIntent>>();
|
||||
|
||||
const { openLlmOptions } = useOptimaLayout();
|
||||
const showAltTitleBar = useUXLabsStore(state => DEV_MODE_SETTINGS && state.labsChatBarAlt === 'title');
|
||||
|
||||
const { openLlmOptions, openModelsSetup, openPreferencesTab } = useOptimaLayout();
|
||||
|
||||
const { chatLLM } = useChatLLM();
|
||||
|
||||
const {
|
||||
// state
|
||||
chatPanes,
|
||||
focusedConversationId,
|
||||
focusedPaneIndex,
|
||||
focusedPaneConversationId,
|
||||
// actions
|
||||
navigateHistoryInFocusedPane,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
focusedPaneIndex,
|
||||
removePane,
|
||||
setFocusedPane,
|
||||
setFocusedPaneIndex,
|
||||
} = usePanesManager();
|
||||
|
||||
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 beamsOpens = useAreBeamsOpen(paneBeamStores);
|
||||
const beamOpenStoreInFocusedPane = React.useMemo(() => {
|
||||
const open = focusedPaneIndex !== null ? (beamsOpens?.[focusedPaneIndex] ?? false) : false;
|
||||
return open ? paneBeamStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, focusedPaneIndex, paneBeamStores]);
|
||||
|
||||
const {
|
||||
// focused
|
||||
title: focusedChatTitle,
|
||||
isChatEmpty: isFocusedChatEmpty,
|
||||
isEmpty: isFocusedChatEmpty,
|
||||
isDeveloper: isFocusedChatDeveloper,
|
||||
areChatsEmpty,
|
||||
conversationIdx: focusedChatNumber,
|
||||
newConversationId,
|
||||
// all
|
||||
hasConversations,
|
||||
recycleNewConversationId,
|
||||
// actions
|
||||
prependNewConversation,
|
||||
branchConversation,
|
||||
deleteConversations,
|
||||
setMessages,
|
||||
} = useConversation(focusedConversationId);
|
||||
} = useConversation(focusedPaneConversationId);
|
||||
|
||||
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
|
||||
|
||||
@@ -123,27 +160,29 @@ 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;
|
||||
|
||||
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
|
||||
return pane.conversationId ? ConversationManager.getHandler(pane.conversationId) : null;
|
||||
}), [chatPanes]);
|
||||
|
||||
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
|
||||
const handleOpenConversationInFocusedPane = React.useCallback((conversationId: DConversationId | null) => {
|
||||
conversationId && openConversationInFocusedPane(conversationId);
|
||||
}, [openConversationInFocusedPane]);
|
||||
|
||||
const openSplitConversationId = React.useCallback((conversationId: DConversationId | null) => {
|
||||
const handleOpenConversationInSplitPane = React.useCallback((conversationId: DConversationId | null) => {
|
||||
conversationId && openConversationInSplitPane(conversationId);
|
||||
}, [openConversationInSplitPane]);
|
||||
|
||||
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
|
||||
const handleNavigateHistoryInFocusedPane = React.useCallback((direction: 'back' | 'forward') => {
|
||||
if (navigateHistoryInFocusedPane(direction))
|
||||
showNextTitleChange.current = true;
|
||||
}, [navigateHistoryInFocusedPane]);
|
||||
|
||||
// [effect] Handle the initial conversation intent
|
||||
React.useEffect(() => {
|
||||
intent.initialConversationId && handleOpenConversationInFocusedPane(intent.initialConversationId);
|
||||
}, [handleOpenConversationInFocusedPane, intent.initialConversationId]);
|
||||
|
||||
// [effect] Show snackbar with the focused chat title after a history navigation in focused pane
|
||||
React.useEffect(() => {
|
||||
if (showNextTitleChange.current) {
|
||||
showNextTitleChange.current = false;
|
||||
@@ -156,160 +195,88 @@ 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]);
|
||||
|
||||
// "/command ...": overrides the chat mode
|
||||
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-beam':
|
||||
return ConversationManager.getHandler(conversationId).beamStore.create(history);
|
||||
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
|
||||
|
||||
case 'ass-browse':
|
||||
setMessages(conversationId, history);
|
||||
return await runBrowseGetPageUpdatingState(conversationId, chatCommand.params!);
|
||||
// [multicast] send the message to all the panes
|
||||
const uniqueConversationIds = willMulticast
|
||||
? Array.from(new Set([conversationId, ...paneUniqueConversationIds]))
|
||||
: [conversationId];
|
||||
|
||||
case 'ass-t2i':
|
||||
setMessages(conversationId, history);
|
||||
return await runImageGenerationUpdatingState(conversationId, chatCommand.params!);
|
||||
|
||||
case 'ass-react':
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
|
||||
|
||||
case 'chat-alter':
|
||||
if (chatCommand.command === '/clear') {
|
||||
if (chatCommand.params === 'all')
|
||||
return setMessages(conversationId, []);
|
||||
const helpMessage = createDMessage('assistant', 'This command requires the \'all\' parameter to confirm the operation.');
|
||||
helpMessage.originLLM = Brand.Title.Base;
|
||||
return setMessages(conversationId, [...history, helpMessage]);
|
||||
}
|
||||
Object.assign(lastMessage, {
|
||||
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
|
||||
sender: 'Bot',
|
||||
text: chatCommand.params || '',
|
||||
} satisfies Partial<DMessage>);
|
||||
return setMessages(conversationId, history);
|
||||
|
||||
case 'cmd-help':
|
||||
const chatCommandsText = findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
const helpMessage = createDMessage('assistant', 'Available Chat Commands:\n' + chatCommandsText);
|
||||
helpMessage.originLLM = Brand.Title.Base;
|
||||
return setMessages(conversationId, [...history, helpMessage]);
|
||||
|
||||
default:
|
||||
return setMessages(conversationId, [...history, createDMessage('assistant', 'This command is not supported.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get the focused system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
const conversationSystemPurposeId = getConversationSystemPurposeId(conversationId);
|
||||
if (!conversationSystemPurposeId)
|
||||
return setMessages(conversationId, [...history, createDMessage('assistant', 'No persona selected.')]);
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
if (chatLLMId) {
|
||||
switch (chatModeId) {
|
||||
case 'generate-text':
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, conversationSystemPurposeId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'generate-text-beam':
|
||||
return ConversationManager.getHandler(conversationId).beamStore.create(history);
|
||||
|
||||
case 'append-user':
|
||||
return setMessages(conversationId, history);
|
||||
|
||||
case 'generate-image':
|
||||
if (!lastMessage?.text)
|
||||
break;
|
||||
// also add a 'fake' user message with the '/draw' command
|
||||
setMessages(conversationId, history.map(message => message.id !== lastMessage.id ? message : {
|
||||
...message,
|
||||
text: `/draw ${lastMessage.text}`,
|
||||
}));
|
||||
return await runImageGenerationUpdatingState(conversationId, lastMessage.text);
|
||||
|
||||
case 'generate-react':
|
||||
if (!lastMessage?.text)
|
||||
break;
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, lastMessage.text, chatLLMId);
|
||||
}
|
||||
}
|
||||
|
||||
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
|
||||
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
|
||||
setMessages(conversationId, history);
|
||||
}, [setMessages]);
|
||||
|
||||
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;
|
||||
}
|
||||
for (const conversation of uniqueConverations) {
|
||||
|
||||
// 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 enqueued;
|
||||
}, [chatPanes, willMulticast, _handleExecute]);
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[], chatEffectBeam: boolean): Promise<void> => {
|
||||
await _handleExecute(!chatEffectBeam ? 'generate-text' : 'generate-text-beam', conversationId, history);
|
||||
}, [_handleExecute]);
|
||||
return true;
|
||||
}, [paneUniqueConversationIds, handleExecuteAndOutcome, willMulticast]);
|
||||
|
||||
const handleMessageRegenerateLast = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedConversationId);
|
||||
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 (focusedPaneConversationId && focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
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
|
||||
}
|
||||
}, [focusedPaneConversationId, handleExecuteAndOutcome]);
|
||||
|
||||
const handleMessageBeamLastInFocusedPane = React.useCallback(async () => {
|
||||
// Ctrl + Shift + B
|
||||
const focusedConversation = getConversation(focusedPaneConversationId);
|
||||
if (focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
return await _handleExecute('generate-text', focusedConversation.id, lastMessage.role === 'assistant'
|
||||
? focusedConversation.messages.slice(0, -1)
|
||||
: [...focusedConversation.messages],
|
||||
);
|
||||
if (lastMessage.role === 'assistant')
|
||||
ConversationsManager.getHandler(focusedConversation.id).beamInvoke(focusedConversation.messages.slice(0, -1), [lastMessage], lastMessage.id);
|
||||
else if (lastMessage.role === 'user')
|
||||
ConversationsManager.getHandler(focusedConversation.id).beamInvoke(focusedConversation.messages, [], null);
|
||||
}
|
||||
}, [focusedConversationId, _handleExecute]);
|
||||
}, [focusedPaneConversationId]);
|
||||
|
||||
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);
|
||||
@@ -318,13 +285,15 @@ export function AppChat() {
|
||||
|
||||
// Chat actions
|
||||
|
||||
const handleConversationNew = React.useCallback((forceNoRecycle?: boolean) => {
|
||||
const handleConversationNewInFocusedPane = React.useCallback((forceNoRecycle?: boolean) => {
|
||||
|
||||
// activate an existing new conversation if present, or create another
|
||||
const conversationId = (newConversationId && !forceNoRecycle)
|
||||
? newConversationId
|
||||
: prependNewConversation(getConversationSystemPurposeId(focusedConversationId) ?? undefined);
|
||||
setFocusedConversationId(conversationId);
|
||||
// create conversation (or recycle the existing top-of-stack empty conversation)
|
||||
const conversationId = (recycleNewConversationId && !forceNoRecycle)
|
||||
? recycleNewConversationId
|
||||
: prependNewConversation(getConversationSystemPurposeId(focusedPaneConversationId) ?? undefined);
|
||||
|
||||
// switch the focused pane to the new conversation
|
||||
handleOpenConversationInFocusedPane(conversationId);
|
||||
|
||||
// if a folder is active, add the new conversation to the folder
|
||||
if (activeFolderId && conversationId)
|
||||
@@ -333,7 +302,7 @@ export function AppChat() {
|
||||
// focus the composer
|
||||
composerTextAreaRef.current?.focus();
|
||||
|
||||
}, [activeFolderId, focusedConversationId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
}, [activeFolderId, focusedPaneConversationId, handleOpenConversationInFocusedPane, prependNewConversation, recycleNewConversationId]);
|
||||
|
||||
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
|
||||
|
||||
@@ -341,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);
|
||||
@@ -351,22 +346,22 @@ export function AppChat() {
|
||||
|
||||
// replace/open a new pane with this
|
||||
showNextTitleChange.current = true;
|
||||
if (isMultiAddable)
|
||||
openSplitConversationId(branchedConversationId);
|
||||
if (!isMultiAddable)
|
||||
handleOpenConversationInFocusedPane(branchedConversationId);
|
||||
else
|
||||
setFocusedConversationId(branchedConversationId);
|
||||
handleOpenConversationInSplitPane(branchedConversationId);
|
||||
|
||||
return branchedConversationId;
|
||||
}, [activeFolderId, branchConversation, isMultiAddable, openSplitConversationId, setFocusedConversationId]);
|
||||
}, [activeFolderId, branchConversation, handleOpenConversationInFocusedPane, handleOpenConversationInSplitPane, isMultiAddable]);
|
||||
|
||||
const handleConversationFlatten = React.useCallback((conversationId: DConversationId) => setFlattenConversationId(conversationId), []);
|
||||
|
||||
const handleConfirmedClearConversation = React.useCallback(() => {
|
||||
if (clearConversationId) {
|
||||
setMessages(clearConversationId, []);
|
||||
ConversationsManager.getHandler(clearConversationId).historyClear();
|
||||
setClearConversationId(null);
|
||||
}
|
||||
}, [clearConversationId, setMessages]);
|
||||
}, [clearConversationId]);
|
||||
|
||||
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
|
||||
|
||||
@@ -374,13 +369,17 @@ export function AppChat() {
|
||||
if (!bypassConfirmation)
|
||||
return setDeleteConversationIds(conversationIds);
|
||||
|
||||
// perform deletion
|
||||
// perform deletion, and return the next (or a new) conversation
|
||||
const nextConversationId = deleteConversations(conversationIds, /*focusedSystemPurposeId ??*/ undefined);
|
||||
|
||||
setFocusedConversationId(nextConversationId);
|
||||
// switch the focused pane to the new conversation - NOTE: this makes the assumption that deletion had impact on the focused pane
|
||||
handleOpenConversationInFocusedPane(nextConversationId);
|
||||
|
||||
setDeleteConversationIds(null);
|
||||
}, [deleteConversations, setFocusedConversationId]);
|
||||
|
||||
// run GC for dblobs in this conversation
|
||||
void gcChatImageAssets(); // fire/forget
|
||||
}, [deleteConversations, handleOpenConversationInFocusedPane]);
|
||||
|
||||
const handleConfirmedDeleteConversations = React.useCallback(() => {
|
||||
!!deleteConversationIds?.length && handleDeleteConversations(deleteConversationIds, true);
|
||||
@@ -395,18 +394,23 @@ 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)],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistoryInFocusedPane('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistoryInFocusedPane('forward')],
|
||||
// global
|
||||
['o', true, true, false, handleOpenChatLlmOptions],
|
||||
['r', true, true, false, handleMessageRegenerateLast],
|
||||
['n', true, false, true, handleConversationNew],
|
||||
['b', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationBranch(focusedConversationId, null))],
|
||||
['x', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationClear(focusedConversationId))],
|
||||
['d', true, false, true, () => focusedConversationId && handleDeleteConversations([focusedConversationId], false)],
|
||||
['+', true, true, false, useUIPreferencesStore.getState().increaseContentScaling],
|
||||
['-', true, true, false, useUIPreferencesStore.getState().decreaseContentScaling],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationNew, handleDeleteConversations, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleFileOpenConversation, handleFileSaveConversation, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
|
||||
@@ -414,48 +418,50 @@ export function AppChat() {
|
||||
|
||||
const barAltTitle = showAltTitleBar ? focusedChatTitle ?? 'No Chat' : null;
|
||||
|
||||
const barContent = React.useMemo(() =>
|
||||
(barAltTitle === null)
|
||||
? <ChatDropdowns conversationId={focusedConversationId} />
|
||||
: <ChatTitle conversationId={focusedConversationId} conversationTitle={barAltTitle} />
|
||||
, [focusedConversationId, barAltTitle],
|
||||
const focusedBarContent = React.useMemo(() => beamOpenStoreInFocusedPane
|
||||
? <ChatBarAltBeam beamStore={beamOpenStoreInFocusedPane} isMobile={isMobile} />
|
||||
: (barAltTitle === null)
|
||||
? <ChatBarDropdowns conversationId={focusedPaneConversationId} />
|
||||
: <ChatBarAltTitle conversationId={focusedPaneConversationId} conversationTitle={barAltTitle} />
|
||||
, [barAltTitle, beamOpenStoreInFocusedPane, focusedPaneConversationId, isMobile],
|
||||
);
|
||||
|
||||
const drawerContent = React.useMemo(() =>
|
||||
<ChatDrawerMemo
|
||||
isMobile={isMobile}
|
||||
activeConversationId={focusedConversationId}
|
||||
activeConversationId={focusedPaneConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
|
||||
chatPanesConversationIds={paneUniqueConversationIds}
|
||||
disableNewButton={disableNewButton}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationNew={handleConversationNewInFocusedPane}
|
||||
onConversationsDelete={handleDeleteConversations}
|
||||
onConversationsExportDialog={handleConversationExport}
|
||||
onConversationsImportDialog={handleConversationImportDialog}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[activeFolderId, chatPanes, disableNewButton, focusedConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleDeleteConversations, isMobile, setFocusedConversationId],
|
||||
[activeFolderId, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile, paneUniqueConversationIds],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
const focusedMenuItems = React.useMemo(() =>
|
||||
<ChatPageMenuItems
|
||||
isMobile={isMobile}
|
||||
conversationId={focusedConversationId}
|
||||
disableItems={!focusedConversationId || isFocusedChatEmpty}
|
||||
hasConversations={!areChatsEmpty}
|
||||
conversationId={focusedPaneConversationId}
|
||||
disableItems={!focusedPaneConversationId || isFocusedChatEmpty}
|
||||
hasConversations={hasConversations}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationClear={handleConversationClear}
|
||||
onConversationFlatten={handleConversationFlatten}
|
||||
// onConversationNew={handleConversationNew}
|
||||
// onConversationNew={handleConversationNewInFocusedPane}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
/>,
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationFlatten, /*handleConversationNew,*/ isFocusedChatEmpty, isMessageSelectionMode, isMobile],
|
||||
[focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationFlatten, hasConversations, isFocusedChatEmpty, isMessageSelectionMode, isMobile],
|
||||
);
|
||||
|
||||
usePluggableOptimaLayout(drawerContent, barContent, menuItems, 'AppChat');
|
||||
usePluggableOptimaLayout(drawerContent, focusedBarContent, focusedMenuItems, 'AppChat');
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
@@ -465,11 +471,14 @@ export function AppChat() {
|
||||
>
|
||||
|
||||
{chatPanes.map((pane, idx) => {
|
||||
const _paneIsFocused = idx === focusedPaneIndex;
|
||||
const _paneConversationId = pane.conversationId;
|
||||
const _paneChatHandler = chatHandlers[idx] ?? null;
|
||||
const _paneChatHandler = paneHandlers[idx] ?? null;
|
||||
const _paneBeamStore = paneBeamStores[idx] ?? null;
|
||||
const _paneBeamIsOpen = !!beamsOpens?.[idx] && !!_paneBeamStore;
|
||||
const _panesCount = chatPanes.length;
|
||||
const _keyAndId = `chat-pane-${idx}-${_paneConversationId}`;
|
||||
const _sepId = `sep-pane-${idx}-${_paneConversationId}`;
|
||||
const _keyAndId = `chat-pane-${pane.paneId}`;
|
||||
const _sepId = `sep-pane-${idx}`;
|
||||
return <React.Fragment key={_keyAndId}>
|
||||
|
||||
<Panel
|
||||
@@ -480,7 +489,7 @@ export function AppChat() {
|
||||
minSize={20}
|
||||
onClick={(event) => {
|
||||
const setFocus = chatPanes.length < 2 || !event.altKey;
|
||||
setFocusedPane(setFocus ? idx : -1);
|
||||
setFocusedPaneIndex(setFocus ? idx : -1);
|
||||
}}
|
||||
onCollapse={() => {
|
||||
// NOTE: despite the delay to try to let the draggin settle, there seems to be an issue with the Pane locking the screen
|
||||
@@ -493,12 +502,13 @@ export function AppChat() {
|
||||
position: 'relative',
|
||||
...(isMultiPane ? {
|
||||
borderRadius: '0.375rem',
|
||||
border: `2px solid ${idx === focusedPaneIndex
|
||||
border: `2px solid ${_paneIsFocused
|
||||
? ((willMulticast || !isMultiConversationId) ? theme.palette.primary.solidBg : theme.palette.primary.solidBg)
|
||||
: ((willMulticast || !isMultiConversationId) ? theme.palette.warning.softActiveBg : theme.palette.background.level1)}`,
|
||||
filter: (!willMulticast && idx !== focusedPaneIndex)
|
||||
? (!isMultiConversationId ? 'grayscale(66.67%)' /* clone of the same */ : 'grayscale(66.67%)')
|
||||
: undefined,
|
||||
: ((willMulticast || !isMultiConversationId) ? theme.palette.primary.softActiveBg : theme.palette.background.level1)}`,
|
||||
// DISABLED on 2024-03-13, it gets in the way quite a lot
|
||||
// filter: (!willMulticast && !_paneIsFocused)
|
||||
// ? (!isMultiConversationId ? 'grayscale(66.67%)' /* clone of the same */ : 'grayscale(66.67%)')
|
||||
// : undefined,
|
||||
} : {
|
||||
// NOTE: this is a workaround for the 'stuck-after-collapse-close' issue. We will collapse the 'other' pane, which
|
||||
// will get it removed (onCollapse), and somehow this pane will be stuck with a pointerEvents: 'none' style, which de-facto
|
||||
@@ -512,62 +522,47 @@ export function AppChat() {
|
||||
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottom
|
||||
sx={{
|
||||
// allows the content to be scrolled (all browsers)
|
||||
overflowY: 'auto',
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
}}
|
||||
stickToBottomInitial
|
||||
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>
|
||||
|
||||
{/* Best-Of Mode */}
|
||||
<Beam
|
||||
conversationHandler={_paneChatHandler}
|
||||
isMobile={isMobile}
|
||||
sx={{
|
||||
overflowY: 'auto',
|
||||
backgroundColor: 'background.level2',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1, // stay on top of Chips :shrug:
|
||||
}}
|
||||
/>
|
||||
|
||||
</Panel>
|
||||
|
||||
{/* Panel Separators & Resizers */}
|
||||
@@ -586,20 +581,14 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
chatLLM={chatLLM}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
conversationId={focusedConversationId}
|
||||
targetConversationId={focusedPaneConversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
|
||||
isDeveloperMode={isFocusedChatDeveloper}
|
||||
onAction={handleComposerAction}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextImagine={handleImagineFromText}
|
||||
setIsMulticast={setIsComposerMulticast}
|
||||
sx={{
|
||||
zIndex: 21, // position: 'sticky', bottom: 0,
|
||||
backgroundColor: themeBgAppChatComposer,
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
sx={beamOpenStoreInFocusedPane ? composerClosedSx : composerOpenSx}
|
||||
/>
|
||||
|
||||
{/* Diagrams */}
|
||||
@@ -618,7 +607,7 @@ export function AppChat() {
|
||||
{!!tradeConfig && (
|
||||
<TradeModal
|
||||
config={tradeConfig}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onClose={() => setTradeConfig(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 { getUXLabsChatBeam } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBeam: ICommandsProvider = {
|
||||
id: 'ass-beam',
|
||||
id: 'cmd-mode-beam',
|
||||
rank: 9,
|
||||
|
||||
getCommands: () => getUXLabsChatBeam() ? [{
|
||||
getCommands: () => [{
|
||||
primary: '/beam',
|
||||
arguments: ['prompt'],
|
||||
description: 'Best of multiple replies',
|
||||
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: () => [{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
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: () => [{
|
||||
@@ -11,7 +15,7 @@ export const CommandsDraw: ICommandsProvider = {
|
||||
alternatives: ['/imagine', '/img'],
|
||||
arguments: ['prompt'],
|
||||
description: 'Assistant will draw the text',
|
||||
Icon: FormatPaintIcon,
|
||||
Icon: FormatPaintTwoToneIcon,
|
||||
}],
|
||||
|
||||
};
|
||||
|
||||
@@ -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-beam' | 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help';
|
||||
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-beam': CommandsBeam,
|
||||
'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,
|
||||
'cmd-mode-beam': CommandsBeam,
|
||||
'cmd-mode-react': CommandsReact,
|
||||
};
|
||||
|
||||
export function findAllChatCommands(): ChatCommand[] {
|
||||
@@ -31,16 +31,25 @@ 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(' ');
|
||||
const potentialCommand = inputTrimmed.substring(0, firstSpaceIndex >= 0 ? firstSpaceIndex : inputTrimmed.length);
|
||||
const commandMatch = inputTrimmed.match(/^\/\S+/);
|
||||
const potentialCommand = commandMatch ? commandMatch[0] : inputTrimmed;
|
||||
|
||||
const textAfterCommand = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
|
||||
|
||||
// Check if the potential command is an actual command
|
||||
for (const provider of Object.values(ChatCommandsProviders)) {
|
||||
@@ -48,22 +57,33 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
if (cmd.primary === potentialCommand || cmd.alternatives?.includes(potentialCommand)) {
|
||||
|
||||
// command needs arguments: take the rest of the input as parameters
|
||||
if (cmd.arguments?.length) {
|
||||
const params = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
|
||||
return [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: params || undefined, isError: !params || undefined }];
|
||||
}
|
||||
if (cmd.arguments?.length) return [{
|
||||
type: 'cmd',
|
||||
providerId: provider.id,
|
||||
command: potentialCommand,
|
||||
params: textAfterCommand || undefined,
|
||||
isErrorNoArgs: !textAfterCommand,
|
||||
}];
|
||||
|
||||
// command without arguments, treat any text after as a separate text piece
|
||||
const pieces: TextCommandPiece[] = [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: undefined }];
|
||||
const textAfterCommand = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
|
||||
if (textAfterCommand)
|
||||
pieces.push({ type: 'text', value: textAfterCommand });
|
||||
const pieces: TextCommandPiece[] = [{
|
||||
type: 'cmd',
|
||||
providerId: provider.id,
|
||||
command: potentialCommand,
|
||||
params: undefined,
|
||||
}];
|
||||
textAfterCommand && pieces.push({
|
||||
type: 'nocmd',
|
||||
value: textAfterCommand,
|
||||
});
|
||||
return pieces;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No command found, return the entire input as text
|
||||
return [{ type: 'text', value: input }];
|
||||
return [{
|
||||
type: 'nocmd',
|
||||
value: input,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { 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
|
||||
const isMaximized = useBeamStore(props.beamStore, state => state.isMaximized);
|
||||
|
||||
const handleUnMaximize = React.useCallback(() => {
|
||||
props.beamStore.getState().setIsMaximized(false);
|
||||
}, [props.beamStore]);
|
||||
|
||||
// memo the beamview
|
||||
const beamView = React.useMemo(() => (
|
||||
<BeamView
|
||||
beamStore={props.beamStore}
|
||||
isMobile={props.isMobile}
|
||||
showExplainer
|
||||
/>
|
||||
), [props.beamStore, props.isMobile]);
|
||||
|
||||
return isMaximized ? (
|
||||
<Modal open onClose={handleUnMaximize}>
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
}}>
|
||||
<ScrollToBottom disableAutoStick>
|
||||
{beamView}
|
||||
</ScrollToBottom>
|
||||
<ModalClose sx={{ color: 'white', backgroundColor: 'background.surface', boxShadow: 'xs', mr: 2 }} />
|
||||
</Box>
|
||||
</Modal>
|
||||
) : (
|
||||
<Box sx={props.inlineSx}>
|
||||
{beamView}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, List } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
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, getConversation, 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';
|
||||
import { useScrollToBottom } from '~/common/scroll-to-bottom/useScrollToBottom';
|
||||
|
||||
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 { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
|
||||
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[], chatEffectBeam: boolean) => 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,63 +54,101 @@ 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(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,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
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 (text: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)], false);
|
||||
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
|
||||
const handleRunExample = React.useCallback(async (examplePrompt: string) => {
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.messageAppend(createDMessageTextContent('user', examplePrompt)); // [chat] append user:persona question
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
const handleConversationBranch = React.useCallback((messageId: string) => {
|
||||
const handleMessageAssistantFrom = React.useCallback(async (messageId: DMessageId, offset: number) => {
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.historyTruncateTo(messageId, offset);
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
const handleMessageBeam = React.useCallback(async (messageId: DMessageId) => {
|
||||
// Right-click menu Beam
|
||||
if (!conversationId || !props.conversationHandler) return;
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (messages?.length) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1);
|
||||
const lastMessage = truncatedHistory[truncatedHistory.length - 1];
|
||||
if (lastMessage) {
|
||||
// assistant: do an in-place beam
|
||||
if (lastMessage.role === 'assistant') {
|
||||
if (truncatedHistory.length >= 2)
|
||||
props.conversationHandler.beamInvoke(truncatedHistory.slice(0, -1), [lastMessage], lastMessage.id);
|
||||
} else {
|
||||
// user: truncate and append (but if the next message is an assistant message, import it)
|
||||
const nextMessage = messages[truncatedHistory.length];
|
||||
if (nextMessage?.role === 'assistant')
|
||||
props.conversationHandler.beamInvoke(truncatedHistory, [nextMessage], null);
|
||||
else
|
||||
props.conversationHandler.beamInvoke(truncatedHistory, [], null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [conversationId, props.conversationHandler]);
|
||||
|
||||
const handleMessageBranch = React.useCallback((messageId: DMessageId) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number, chatEffectBeam: boolean) => {
|
||||
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, chatEffectBeam);
|
||||
}
|
||||
}, [conversationId, onConversationExecuteHistory]);
|
||||
const handleMessageTruncate = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.historyTruncateTo(messageId, 0);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleConversationTruncate = 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 handleMessageDelete = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.messagesDelete([messageId]);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageDelete = React.useCallback((messageId: string) => {
|
||||
conversationId && deleteMessage(conversationId, messageId);
|
||||
}, [conversationId, deleteMessage]);
|
||||
const handleMessageAppendFragment = React.useCallback((messageId: DMessageId, fragment: DMessageFragment) => {
|
||||
props.conversationHandler?.messageFragmentAppend(messageId, fragment, false, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
|
||||
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
|
||||
}, [conversationId, editMessage]);
|
||||
const handleMessageDeleteFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId) => {
|
||||
props.conversationHandler?.messageFragmentDelete(messageId, fragmentId, false, true);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
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, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
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]);
|
||||
|
||||
@@ -139,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
|
||||
@@ -194,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}
|
||||
@@ -217,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 ? (
|
||||
|
||||
@@ -234,19 +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}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationRestartFrom={handleConversationRestartFrom}
|
||||
onConversationTruncate={handleConversationTruncate}
|
||||
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 { ConversationManager } from '~/common/chats/ConversationHandler';
|
||||
import { DConversationId } from '~/common/state/store-chats';
|
||||
import { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { lineHeightChatTextMd } from '~/common/app.theme';
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function StateRenderer(props: { state: object }) {
|
||||
function EphemeralItem({ conversationId, ephemeral }: { conversationId: string, ephemeral: DEphemeral }) {
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
ConversationManager.getHandler(conversationId).ephemeralsStore.delete(ephemeral.id);
|
||||
ConversationsManager.getHandler(conversationId).ephemeralsStore.delete(ephemeral.id);
|
||||
}, [conversationId, ephemeral.id]);
|
||||
|
||||
return <Box
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Alert, Box, Sheet, Typography } from '@mui/joy';
|
||||
|
||||
import { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import { useBeam } from '~/common/chats/BeamStore';
|
||||
import { useLLMSelect } from '~/common/components/forms/useLLMSelect';
|
||||
|
||||
|
||||
export function Beam(props: {
|
||||
conversationHandler: ConversationHandler | null,
|
||||
isMobile: boolean,
|
||||
sx?: SxProps
|
||||
}) {
|
||||
|
||||
// state
|
||||
const { config, candidates } = useBeam(props.conversationHandler);
|
||||
|
||||
// external state
|
||||
const [allChatLlm, allChatLlmComponent] = useLLMSelect(true, 'Beam LLM');
|
||||
|
||||
if (!config)
|
||||
return null;
|
||||
|
||||
const lastMessage = config.history.slice(-1)[0] ?? null;
|
||||
|
||||
return (
|
||||
<Box sx={{ ...props.sx, px: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
{/* Issues */}
|
||||
{!!config.configError && (
|
||||
<Alert>
|
||||
{config.configError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Models, [x] all same, */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'start', gap: 2 }}>
|
||||
<Box sx={{ minWidth: 200 }}>
|
||||
{allChatLlmComponent}
|
||||
</Box>
|
||||
|
||||
{!!lastMessage && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.surface',
|
||||
boxShadow: 'xs',
|
||||
borderRadius: 'lg',
|
||||
borderTopRightRadius: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
py: 1,
|
||||
px: 1,
|
||||
mb: 'auto',
|
||||
|
||||
|
||||
flex: 1,
|
||||
}}>
|
||||
{lastMessage.text}
|
||||
</Box>
|
||||
// <ChatMessageMemo
|
||||
// message={lastMessage}
|
||||
// fitScreen={props.isMobile}
|
||||
// sx={{
|
||||
// borderRadius: 'lg',
|
||||
// borderBottomRightRadius: lastMessage.role === 'assistant' ? undefined : 0,
|
||||
// borderBottomLeftRadius: lastMessage.role === 'user' ? undefined : 0,
|
||||
// boxShadow: 'xs',
|
||||
// my: 2,
|
||||
// px: 0,
|
||||
// py: 1,
|
||||
// alignSelf: 'self-end',
|
||||
// flex: 1,
|
||||
// maxHeight: '5rem',
|
||||
// overflow: 'hidden',
|
||||
// }}
|
||||
// />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Grid */}
|
||||
<Box sx={{
|
||||
// my: 'auto',
|
||||
// display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
border: '1px solid purple',
|
||||
minHeight: '300px',
|
||||
|
||||
// layout
|
||||
display: 'grid',
|
||||
gridTemplateColumns: props.isMobile ? 'repeat(auto-fit, minmax(320px, 1fr))' : 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: { xs: 2, md: 2 },
|
||||
}}>
|
||||
<Sheet sx={{ minHeight: '50%' }}>
|
||||
b
|
||||
</Sheet>
|
||||
<Sheet>
|
||||
a
|
||||
</Sheet>
|
||||
<Sheet>
|
||||
a
|
||||
</Sheet>
|
||||
<Sheet>
|
||||
a
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
{/* Auto-Gatherer: All-in-one, Best-Of */}
|
||||
<Box>
|
||||
Gatherer
|
||||
</Box>
|
||||
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces' }}>
|
||||
{/*{JSON.stringify(config, null, 2)}*/}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
borderRadius: 'lg',
|
||||
borderBottomLeftRadius: 0,
|
||||
backgroundColor: 'background.surface',
|
||||
boxShadow: 'lg',
|
||||
m: 2,
|
||||
p: '0.25rem 1rem',
|
||||
}}>
|
||||
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
a
|
||||
</Box>
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function CameraCaptureModal(props: {
|
||||
}}>
|
||||
|
||||
{/* Top bar */}
|
||||
<Sheet variant='solid' invertedColors sx={{ zIndex: 10, display: 'flex', justifyContent: 'space-between', p: 1 }}>
|
||||
<Sheet variant='solid' invertedColors sx={{ display: 'flex', justifyContent: 'space-between', p: 1 }}>
|
||||
<Select
|
||||
variant='solid' color='neutral'
|
||||
value={cameraIdx} onChange={(_event: any, value: number | null) => setCameraIdx(value === null ? -1 : value)}
|
||||
@@ -116,7 +116,7 @@ export function CameraCaptureModal(props: {
|
||||
|
||||
{showInfo && !!info && <Typography
|
||||
sx={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1,
|
||||
position: 'absolute', inset: 0, zIndex: 1, /* camera info on top of video */
|
||||
background: 'rgba(0,0,0,0.5)', color: 'white',
|
||||
whiteSpace: 'pre', overflowY: 'scroll',
|
||||
}}>
|
||||
@@ -127,7 +127,7 @@ export function CameraCaptureModal(props: {
|
||||
</Box>
|
||||
|
||||
{/* Bottom controls (zoom, ocr, download) & progress */}
|
||||
<Sheet variant='soft' sx={{ display: 'flex', flexDirection: 'column', zIndex: 20, gap: 1, p: 1 }}>
|
||||
<Sheet variant='soft' sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
|
||||
|
||||
{!!error && <InlineError error={error} />}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function CameraCaptureModal(props: {
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'space-between' }}>
|
||||
{/* Info */}
|
||||
<IconButton size='lg' disabled={!info} variant='soft' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
|
||||
<IconButton size='lg' disabled={!info} variant='soft' onClick={() => setShowInfo(info => !info)}>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
{/*<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>*/}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke } 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;
|
||||
shortcut?: string;
|
||||
requiresTTI?: boolean;
|
||||
}
|
||||
|
||||
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
'generate-text': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'append-user': {
|
||||
label: 'Write',
|
||||
description: 'Appends a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'generate-image': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
requiresTTI: true,
|
||||
},
|
||||
'generate-text-beam': {
|
||||
label: 'Best-Of', // Best of, Auto-Prime, Top Pick, Select Best
|
||||
description: 'Smarter: best of multiple replies',
|
||||
},
|
||||
'generate-react': {
|
||||
label: 'Reason + Act', // · α
|
||||
description: 'Answers questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
if (shortcut === 'ENTER')
|
||||
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
export function ChatModeMenu(props: {
|
||||
anchorEl: HTMLAnchorElement | null, onClose: () => void,
|
||||
chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void
|
||||
capabilityHasTTI: boolean,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const labsChatBeam = useUXLabsStore(state => state.labsChatBeam);
|
||||
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' || labsChatBeam)
|
||||
.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 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={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { fileOpen, FileWithHandle } from 'browser-fs-access';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
@@ -10,69 +9,73 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
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 { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
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 { countModelTokens } from '~/common/util/token-counter';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
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 { playSoundUrl } from '~/common/util/audioUtils';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
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';
|
||||
|
||||
import type { ActileItem, ActileProvider } from './actile/ActileProvider';
|
||||
import type { ActileItem } from './actile/ActileProvider';
|
||||
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';
|
||||
import { ButtonAttachScreenCaptureMemo } from './buttons/ButtonAttachScreenCapture';
|
||||
import { ButtonBeamMemo } from './buttons/ButtonBeam';
|
||||
import { ButtonCallMemo } from './buttons/ButtonCall';
|
||||
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';
|
||||
|
||||
|
||||
export const animationStopEnter = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px)
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0)
|
||||
}
|
||||
`;
|
||||
const zIndexComposerOverlayDrop = 10;
|
||||
const zIndexComposerOverlayMic = 20;
|
||||
|
||||
const dropperCardSx: SxProps = {
|
||||
display: 'none',
|
||||
@@ -81,7 +84,7 @@ const dropperCardSx: SxProps = {
|
||||
border: '2px dashed',
|
||||
borderRadius: 'xs',
|
||||
boxShadow: 'none',
|
||||
zIndex: 10,
|
||||
zIndex: zIndexComposerOverlayDrop,
|
||||
} as const;
|
||||
|
||||
const dropppedCardDraggingSx: SxProps = {
|
||||
@@ -97,11 +100,11 @@ 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;
|
||||
@@ -112,56 +115,85 @@ export function Composer(props: {
|
||||
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, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
const { labsAttachScreenCapture, labsCameraDesktop, labsShowCost } = useUXLabsStore(useShallow(state => ({
|
||||
labsAttachScreenCapture: state.labsAttachScreenCapture,
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
}), shallow);
|
||||
labsShowCost: state.labsShowCost,
|
||||
})));
|
||||
const timeToShowTips = useAppStateStore(state => state.usageCount > 2);
|
||||
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
const { novel: explainAltEnter, touch: touchAltEnter } = useUICounter('composer-alt-enter');
|
||||
const { novel: explainCtrlEnter, touch: touchCtrlEnter } = useUICounter('composer-ctrl-enter');
|
||||
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, 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)
|
||||
@@ -173,77 +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);
|
||||
}
|
||||
|
||||
return enqueued;
|
||||
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
|
||||
if (!fragments.length) {
|
||||
// addSnackbar({ key: 'chat-composer-empty', message: 'Nothing to send', type: 'info' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleSendClicked = React.useCallback(() => {
|
||||
handleSendAction(chatModeId, composeText);
|
||||
}, [chatModeId, composeText, handleSendAction]);
|
||||
// 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;
|
||||
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
|
||||
|
||||
|
||||
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 onActileCommandSelect = React.useCallback((item: ActileItem) => {
|
||||
if (props.composerTextAreaRef.current) {
|
||||
const textArea = props.composerTextAreaRef.current;
|
||||
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
|
||||
if (composerTextAreaRef.current) {
|
||||
const textArea = composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
const cursorPos = textArea.selectionStart;
|
||||
|
||||
@@ -260,23 +311,39 @@ export function Composer(props: {
|
||||
const newCursorPos = commandStart + item.label.length + 1;
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
}, [props.composerTextAreaRef, setComposeText]);
|
||||
}, [composerTextAreaRef, setComposeText]);
|
||||
|
||||
const actileProviders: ActileProvider[] = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandSelect)];
|
||||
}, [onActileCommandSelect]);
|
||||
const onActileEmbedMessage = React.useCallback(async ({ conversationId, messageId }: StarredMessageItem) => {
|
||||
// get the message
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [attachAppendEgoFragments]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
const actileProviders = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
|
||||
}, [onActileCommandPaste, onActileEmbedMessage]);
|
||||
|
||||
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;
|
||||
@@ -284,9 +351,17 @@ export function Composer(props: {
|
||||
// Enter: primary action
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
// Alt: append the message instead
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey) {
|
||||
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 ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey)) {
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
touchCtrlEnter();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -295,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, touchShiftEnter]);
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
@@ -326,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;
|
||||
@@ -369,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')
|
||||
@@ -380,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 });
|
||||
@@ -397,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
|
||||
@@ -444,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]);
|
||||
|
||||
@@ -463,89 +537,94 @@ 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 showCall = isText || isAppend;
|
||||
const showChatReplyTo = !!replyToGenerateText;
|
||||
const showChatExtras = isText && !showChatReplyTo;
|
||||
|
||||
const buttonColor: ColorPaletteProp =
|
||||
assistantAbortible ? 'warning'
|
||||
: isReAct ? 'success'
|
||||
: isTextBeam ? 'success'
|
||||
: isDraw ? 'warning'
|
||||
: 'primary';
|
||||
const sendButtonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
|
||||
const buttonText =
|
||||
isAppend ? 'Write'
|
||||
: isReAct ? 'ReAct'
|
||||
: isTextBeam ? 'Best-Of'
|
||||
: isDraw ? 'Draw'
|
||||
: 'Chat';
|
||||
const sendButtonColor: ColorPaletteProp = assistantAbortible ? 'warning' : chatExecuteModeSendColor;
|
||||
|
||||
const buttonIcon =
|
||||
const sendButtonLabel = chatExecuteModeSendLabel;
|
||||
|
||||
const sendButtonIcon =
|
||||
micContinuation ? <AutoModeIcon />
|
||||
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
|
||||
: isReAct ? <PsychologyIcon />
|
||||
: isTextBeam ? <ChatBeamIcon /> /* <GavelIcon /> */
|
||||
: isDraw ? <FormatPaintIcon />
|
||||
: isDraw ? <FormatPaintTwoToneIcon />
|
||||
: <TelegramIcon />;
|
||||
|
||||
let textPlaceholder: string =
|
||||
isDraw ? 'Describe an idea or a drawing...'
|
||||
: isReAct ? 'Multi-step reasoning question...'
|
||||
: isTextBeam ? 'Multi-chat with this persona...'
|
||||
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
|
||||
: props.capabilityHasT2I ? 'Chat · /react · /draw · drop files...'
|
||||
: 'Chat · /react · drop files...';
|
||||
if (isDesktop && explainShiftEnter)
|
||||
textPlaceholder += !enterIsNewline ? '\nShift+Enter to add a new line' : '\nShift+Enter to send';
|
||||
: isTextBeam ? 'Beam: combine the smarts of models...'
|
||||
: 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 (explainCtrlEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Ctrl + Enter to beam');
|
||||
}
|
||||
|
||||
return (
|
||||
<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*/}
|
||||
@@ -563,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}
|
||||
@@ -595,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',
|
||||
@@ -602,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>
|
||||
@@ -625,7 +707,7 @@ export function Composer(props: {
|
||||
{isSpeechEnabled && (
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
zIndex: 21,
|
||||
zIndex: zIndexComposerOverlayMic + 1,
|
||||
mt: isDesktop ? 1 : 0.25,
|
||||
mr: isDesktop ? 1 : 0.25,
|
||||
display: 'flex', flexDirection: 'column', gap: isDesktop ? 1 : 0.25,
|
||||
@@ -644,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: 20,
|
||||
px: 1.5, py: 1,
|
||||
zIndex: zIndexComposerOverlayMic,
|
||||
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>
|
||||
)}
|
||||
@@ -681,27 +775,30 @@ 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}>
|
||||
|
||||
{/* This row is here only for the [mobile] bottom-start corner item */}
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
{/* [mobile] This row is here only for the [mobile] bottom-start corner item */}
|
||||
{/* [desktop] This column arrangement will have the [desktop] beam button right under call */}
|
||||
<Box sx={isMobile ? { display: 'flex' } : { display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (showCall
|
||||
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
|
||||
{isMobile && (showChatExtras
|
||||
? <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 } }} />
|
||||
@@ -709,38 +806,46 @@ export function Composer(props: {
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
<ButtonGroup
|
||||
variant={isAppend ? 'outlined' : 'solid'}
|
||||
color={buttonColor}
|
||||
variant={sendButtonVariant}
|
||||
color={sendButtonColor}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
boxShadow: isMobile ? '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: `${animationStopEnter} 0.1s ease-out` }}
|
||||
sx={{ animation: `${animationEnterBelow} 0.1s ease-out` }}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* [Beam] Open Beam */}
|
||||
{/*{isText && <Tooltip title='Open Beam'>*/}
|
||||
{/* <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>}
|
||||
@@ -748,23 +853,32 @@ 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 */}
|
||||
{isDesktop && showChatExtras && !assistantAbortible && (
|
||||
<ButtonBeamMemo
|
||||
disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
|
||||
hasContent={!!composeText}
|
||||
onClick={handleSendTextBeamClicked}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
{/* [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 */}
|
||||
{showCall && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
{showChatExtras && <ButtonCallMemo disabled={noConversation || noLLM} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -776,19 +890,13 @@ export function Composer(props: {
|
||||
|
||||
</Grid>
|
||||
|
||||
{/* Mode selector */}
|
||||
{!!chatModeMenuAnchor && (
|
||||
<ChatModeMenu
|
||||
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;
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ActilePopup(props: {
|
||||
const labelNormal = item.label.slice(props.activePrefixLength);
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
key={item.key}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
color={isActive ? 'primary' : undefined}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
export interface ActileItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
argument?: string;
|
||||
description?: string;
|
||||
Icon?: FunctionComponent;
|
||||
}
|
||||
|
||||
type ActileProviderIds = 'actile-commands' | 'actile-attach-reference';
|
||||
|
||||
export interface ActileProvider {
|
||||
id: ActileProviderIds;
|
||||
title: string;
|
||||
searchPrefix: string;
|
||||
|
||||
checkTriggerText: (trailingText: string) => boolean;
|
||||
|
||||
fetchItems: () => Promise<ActileItem[]>;
|
||||
export interface ActileProvider<TItem extends ActileItem = ActileItem> {
|
||||
fastCheckTriggerText: (trailingText: string) => boolean;
|
||||
fetchItems: () => Promise<{ title: string, searchPrefix: string, items: TItem[] }>;
|
||||
onItemSelect: (item: ActileItem) => void;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
|
||||
/*export const providerAttachReference: ActileProvider = {
|
||||
id: 'actile-attach-reference',
|
||||
title: 'Attach Reference',
|
||||
searchPrefix: '@',
|
||||
|
||||
checkTriggerText: (trailingText: string) =>
|
||||
trailingText.endsWith(' @'),
|
||||
|
||||
fetchItems: async () => {
|
||||
return [{
|
||||
id: 'test-1',
|
||||
label: 'Attach This',
|
||||
description: 'Attach this to the message',
|
||||
Icon: undefined,
|
||||
}];
|
||||
},
|
||||
|
||||
onItemSelect: (item: ActileItem) => {
|
||||
console.log('Selected item:', item);
|
||||
},
|
||||
};*/
|
||||
@@ -2,23 +2,25 @@ import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
import { findAllChatCommands } from '../../../commands/commands.registry';
|
||||
|
||||
|
||||
export const providerCommands = (onItemSelect: (item: ActileItem) => void): ActileProvider => ({
|
||||
id: 'actile-commands',
|
||||
title: 'Chat Commands',
|
||||
searchPrefix: '/',
|
||||
export function providerCommands(onCommandSelect: (item: ActileItem) => void): ActileProvider {
|
||||
return {
|
||||
|
||||
checkTriggerText: (trailingText: string) =>
|
||||
trailingText.trim() === '/',
|
||||
// only the literal '/' is a trigger
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '/',
|
||||
|
||||
fetchItems: async () => {
|
||||
return findAllChatCommands().map((cmd) => ({
|
||||
id: cmd.primary,
|
||||
label: cmd.primary,
|
||||
argument: cmd.arguments?.join(' ') ?? undefined,
|
||||
description: cmd.description,
|
||||
Icon: cmd.Icon,
|
||||
}));
|
||||
},
|
||||
// no real need to be async
|
||||
fetchItems: async () => ({
|
||||
title: 'Chat Commands',
|
||||
searchPrefix: '/',
|
||||
items: findAllChatCommands().map((cmd) => ({
|
||||
key: cmd.primary,
|
||||
label: cmd.primary,
|
||||
argument: cmd.arguments?.join(' ') ?? undefined,
|
||||
description: cmd.description,
|
||||
Icon: cmd.Icon,
|
||||
} satisfies ActileItem)),
|
||||
}),
|
||||
|
||||
onItemSelect,
|
||||
});
|
||||
onItemSelect: onCommandSelect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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';
|
||||
|
||||
|
||||
export interface StarredMessageItem extends ActileItem {
|
||||
conversationId: DConversationId,
|
||||
messageId: string,
|
||||
}
|
||||
|
||||
export function providerStarredMessage(onMessageSeelect: (item: StarredMessageItem) => void): ActileProvider<StarredMessageItem> {
|
||||
return {
|
||||
|
||||
// only the literal '@' at start of chat, or ' @' at end of chat
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
|
||||
|
||||
// finds all the starred messages in all the conversations - this could be heavy
|
||||
fetchItems: async () => {
|
||||
const { conversations } = useChatStore.getState();
|
||||
|
||||
const starredMessages: StarredMessageItem[] = [];
|
||||
conversations.forEach((conversation) => {
|
||||
conversation.messages.forEach((message) => {
|
||||
messageHasUserFlag(message, 'starred') && starredMessages.push({
|
||||
// data
|
||||
conversationId: conversation.id,
|
||||
messageId: message.id,
|
||||
// looks
|
||||
key: message.id,
|
||||
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
|
||||
// description: message.text.slice(32, 100),
|
||||
Icon: undefined,
|
||||
} satisfies StarredMessageItem);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
title: 'Starred Messages',
|
||||
searchPrefix: '',
|
||||
items: starredMessages,
|
||||
};
|
||||
},
|
||||
|
||||
onItemSelect: item => onMessageSeelect(item as StarredMessageItem),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
const [popupOpen, setPopupOpen] = React.useState(false);
|
||||
const [provider, setProvider] = React.useState<ActileProvider | null>(null);
|
||||
|
||||
const [title, setTitle] = React.useState<string>('');
|
||||
const [items, setItems] = React.useState<ActileItem[]>([]);
|
||||
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(0);
|
||||
@@ -17,7 +18,7 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
// derived state
|
||||
const activeItems = React.useMemo(() => {
|
||||
const search = activeSearchString.trim().toLowerCase();
|
||||
return items.filter(item => item.label.toLowerCase().startsWith(search));
|
||||
return items.filter(item => item.label?.toLowerCase().startsWith(search));
|
||||
}, [items, activeSearchString]);
|
||||
const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null;
|
||||
|
||||
@@ -25,6 +26,7 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
const handleClose = React.useCallback(() => {
|
||||
setPopupOpen(false);
|
||||
setProvider(null);
|
||||
setTitle('');
|
||||
setItems([]);
|
||||
setActiveSearchString('');
|
||||
setActiveItemIndex(0);
|
||||
@@ -42,13 +44,19 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
|
||||
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
|
||||
for (const provider of providers) {
|
||||
if (provider.checkTriggerText(trailingText)) {
|
||||
setProvider(provider);
|
||||
setPopupOpen(true);
|
||||
setActiveSearchString(provider.searchPrefix);
|
||||
if (provider.fastCheckTriggerText(trailingText)) {
|
||||
provider
|
||||
.fetchItems()
|
||||
.then(items => setItems(items))
|
||||
.then(({ title, searchPrefix, items }) => {
|
||||
// if there are no items, ignore
|
||||
if (items.length) {
|
||||
setPopupOpen(true);
|
||||
setProvider(provider);
|
||||
setTitle(title);
|
||||
setItems(items);
|
||||
setActiveSearchString(searchPrefix);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
handleClose();
|
||||
console.error('Failed to fetch popup items:', error);
|
||||
@@ -100,14 +108,14 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
<ActilePopup
|
||||
anchorEl={anchorRef.current}
|
||||
onClose={handleClose}
|
||||
title={provider?.title}
|
||||
title={title}
|
||||
items={activeItems}
|
||||
activeItemIndex={activeItemIndex}
|
||||
activePrefixLength={activeSearchString.length}
|
||||
onItemClick={handlePopupItemClicked}
|
||||
/>
|
||||
);
|
||||
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, provider?.title]);
|
||||
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, title]);
|
||||
|
||||
return {
|
||||
actileComponent,
|
||||
|
||||
@@ -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,169 +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>) =>
|
||||
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}
|
||||
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,346 +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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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 '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,201 +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;
|
||||
};
|
||||
|
||||
|
||||
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'
|
||||
| '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;
|
||||
|
||||
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,147 +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') {
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
|
||||
title: null,
|
||||
collapsible: false,
|
||||
});
|
||||
} else {
|
||||
accumulatedOutputs.push(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedOutputs;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
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 />
|
||||
<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 },
|
||||
};
|
||||
|
||||
const desktopSx: SxProps = {
|
||||
'--Button-gap': '1rem',
|
||||
backgroundColor: 'background.popup',
|
||||
// border: '1px solid',
|
||||
// borderColor: 'primary.outlinedBorder',
|
||||
boxShadow: '0 4px 16px -4px rgb(var(--joy-palette-primary-mainChannel) / 10%)',
|
||||
animation: `${animationEnterBelow} 0.1s ease-out`,
|
||||
};
|
||||
|
||||
|
||||
export const ButtonBeamMemo = React.memo(ButtonBeam);
|
||||
|
||||
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={props.hasContent ? desktopLegend : desktopLegendNoContent}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<ChatBeamIcon />} sx={desktopSx}>
|
||||
Beam
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const desktopSx: SxProps = {
|
||||
|
||||
export const ButtonCallMemo = React.memo(ButtonCall);
|
||||
|
||||
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
|
||||
function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
|
||||
<CallIcon />
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ButtonMultiChat(props: { isMobile?: boolean, multiChat: boolean,
|
||||
<FormControl orientation='horizontal' sx={{ minHeight: '2.25rem', justifyContent: 'space-between' }}>
|
||||
<FormLabel sx={{ gap: 1, flexFlow: 'row nowrap' }}>
|
||||
<Box sx={{ display: { xs: 'none', lg: 'inline-block' } }}>
|
||||
{multiChat ? <ChatMulticastOnIcon sx={{ color: 'warning.solidBg' }} /> : <ChatMulticastOffIcon />}
|
||||
{multiChat ? <ChatMulticastOnIcon color='primary' /> : <ChatMulticastOffIcon />}
|
||||
</Box>
|
||||
{multiChat ? 'Multichat · On' : 'Multichat'}
|
||||
</FormLabel>
|
||||
|
||||
@@ -2,13 +2,13 @@ import * as React from 'react';
|
||||
|
||||
import { Button, IconButton } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
|
||||
|
||||
export function ButtonOptionsDraw(props: { isMobile?: boolean, onClick: () => void, sx?: SxProps }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='warning' onClick={props.onClick} sx={props.sx}>
|
||||
<FormatPaintIcon />
|
||||
<FormatPaintTwoToneIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant='soft' color='warning' onClick={props.onClick} sx={props.sx}>
|
||||
|
||||
@@ -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[];
|
||||
+47
-43
@@ -4,8 +4,12 @@ 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';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
@@ -13,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
|
||||
@@ -65,19 +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-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)
|
||||
@@ -86,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.stopPropagation();
|
||||
onItemMenuToggle(attachment.id, event.currentTarget);
|
||||
}, [attachment, onItemMenuToggle]);
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
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)
|
||||
@@ -147,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;
|
||||
@@ -173,12 +176,13 @@ export function AttachmentItem(props: {
|
||||
sx={{ p: 1, whiteSpace: 'break-spaces' }}
|
||||
>
|
||||
{isInputLoading
|
||||
? <LoadingIndicator label={attachment.label} />
|
||||
? <LoadingIndicator label={draft.label} />
|
||||
: (
|
||||
<Button
|
||||
size='sm'
|
||||
variant={variant} color={color}
|
||||
onClick={handleToggleMenu}
|
||||
onContextMenu={handleToggleMenu}
|
||||
sx={{
|
||||
backgroundColor: props.menuShown ? `${color}.softActiveBg` : variant === 'outlined' ? 'background.popup' : undefined,
|
||||
border: variant === 'soft' ? '1px solid' : undefined,
|
||||
@@ -192,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]);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Droppable, DroppableProps } from "react-beautiful-dnd";
|
||||
|
||||
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = requestAnimationFrame(() => setEnabled(true));
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animation);
|
||||
setEnabled(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Droppable {...props}>{children}</Droppable>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, IconButton, Typography } from '@mui/joy';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import FullscreenRoundedIcon from '@mui/icons-material/FullscreenRounded';
|
||||
|
||||
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, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { animationBackgroundBeamGather, animationColorBeamScatterINV, animationEnterBelow } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
export function ChatBarAltBeam(props: {
|
||||
beamStore: BeamStoreApi,
|
||||
isMobile?: boolean
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [showCloseConfirmation, setShowCloseConfirmation] = React.useState(false);
|
||||
|
||||
|
||||
// external beam state
|
||||
const { isScattering, isGatheringAny, requiresConfirmation, setIsMaximized, terminateBeam } = useBeamStore(props.beamStore, useShallow((store) => ({
|
||||
// state
|
||||
isScattering: store.isScattering,
|
||||
isGatheringAny: store.isGatheringAny,
|
||||
requiresConfirmation: store.isScattering || store.isGatheringAny || store.raysReady > 0,
|
||||
// actions
|
||||
setIsMaximized: store.setIsMaximized,
|
||||
terminateBeam: store.terminateKeepingSettings,
|
||||
})));
|
||||
|
||||
|
||||
// closure handlers
|
||||
|
||||
const handleCloseBeam = React.useCallback(() => {
|
||||
if (requiresConfirmation)
|
||||
setShowCloseConfirmation(true);
|
||||
else
|
||||
terminateBeam();
|
||||
}, [requiresConfirmation, terminateBeam]);
|
||||
|
||||
const handleCloseConfirmation = React.useCallback(() => {
|
||||
terminateBeam();
|
||||
setShowCloseConfirmation(false);
|
||||
}, [terminateBeam]);
|
||||
|
||||
const handleCloseDenial = React.useCallback(() => {
|
||||
setShowCloseConfirmation(false);
|
||||
}, []);
|
||||
|
||||
const handleMaximizeBeam = React.useCallback(() => {
|
||||
setIsMaximized(true);
|
||||
}, [setIsMaximized]);
|
||||
|
||||
|
||||
// intercept esc this beam is focused
|
||||
useGlobalShortcuts([[ShortcutKeyName.Esc, false, false, false, handleCloseBeam]]);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'center' }}>
|
||||
|
||||
{/* Title & Status */}
|
||||
<Typography level='title-md'>
|
||||
<Box
|
||||
component='span'
|
||||
sx={
|
||||
isGatheringAny ? { animation: `${animationBackgroundBeamGather} 3s infinite, ${animationEnterBelow} 0.6s`, px: 1.5, py: 0.5 }
|
||||
: isScattering ? { animation: `${animationColorBeamScatterINV} 5s infinite, ${animationEnterBelow} 0.6s` }
|
||||
: { fontWeight: 'lg' }
|
||||
}>
|
||||
{isGatheringAny ? 'Merging...' : isScattering ? 'Beaming...' : 'Beam'}
|
||||
</Box>
|
||||
{(!isGatheringAny && !isScattering) && ' Mode'}
|
||||
</Typography>
|
||||
|
||||
{/* Right Close Icon */}
|
||||
<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 */}
|
||||
{showCloseConfirmation && (
|
||||
<ConfirmationModal
|
||||
open
|
||||
onClose={handleCloseDenial}
|
||||
onPositive={handleCloseConfirmation}
|
||||
lowStakes
|
||||
noTitleBar
|
||||
confirmationText='Are you sure you want to close Beam Mode? Unsaved text will be lost.'
|
||||
positiveActionText='Yes, close'
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -3,17 +3,17 @@ 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 ChatTitle(props: {
|
||||
export function ChatBarAltTitle(props: {
|
||||
conversationId: DConversationId | null,
|
||||
conversationTitle: string,
|
||||
}) {
|
||||
@@ -29,7 +29,7 @@ export function ChatTitle(props: {
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
setIsEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
await autoConversationTitle(conversationId, true);
|
||||
setIsEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
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 ChatDropdowns(props: {
|
||||
export function ChatBarDropdowns(props: {
|
||||
conversationId: DConversationId | null
|
||||
}) {
|
||||
|
||||
+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) => {
|
||||
+145
-89
@@ -1,17 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
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';
|
||||
@@ -26,9 +28,9 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folders/ChatFolderList';
|
||||
import { ChatNavGrouping, useChatNavRenderItems } from './useChatNavRenderItems';
|
||||
import { ClearFolderText } from './folders/useFolderDropdown';
|
||||
import { useChatShowRelativeSize } from '../store-app-chat';
|
||||
import { ChatNavGrouping, ChatSearchSorting, isDrawerSearching, useChatDrawerRenderItems } from './useChatDrawerRenderItems';
|
||||
import { ClearFolderText } from '../layout-bar/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../../store-app-chat';
|
||||
|
||||
|
||||
// this is here to make shallow comparisons work on the next hook
|
||||
@@ -37,7 +39,7 @@ const noFolders: DFolder[] = [];
|
||||
/*
|
||||
* Lists folders and returns the active folder
|
||||
*/
|
||||
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
|
||||
export const useFolders = (activeFolderId: string | null) => useFolderStore(useShallow(({ enableFolders, folders, toggleEnableFolders }) => {
|
||||
|
||||
// finds the active folder if any
|
||||
const activeFolder = (enableFolders && activeFolderId)
|
||||
@@ -50,7 +52,7 @@ export const useFolders = (activeFolderId: string | null) => useFolderStore(({ e
|
||||
enableFolders,
|
||||
toggleEnableFolders,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
|
||||
|
||||
export const ChatDrawerMemo = React.memo(ChatDrawer);
|
||||
@@ -74,20 +76,26 @@ function ChatDrawer(props: {
|
||||
|
||||
// local state
|
||||
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('date');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const { showRelativeSize, toggleRelativeSize } = useChatShowRelativeSize();
|
||||
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 } = useChatNavRenderItems(
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, navGrouping, showRelativeSize,
|
||||
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatDrawerRenderItems(
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, filterHasImageAssets, navGrouping, searchSorting, showRelativeSize,
|
||||
);
|
||||
const { contentScaling, showSymbols } = useUIPreferencesStore(state => ({
|
||||
const { contentScaling, showSymbols } = useUIPreferencesStore(useShallow(state => ({
|
||||
contentScaling: state.contentScaling,
|
||||
showSymbols: state.zenMode !== 'cleaner',
|
||||
}), shallow);
|
||||
})));
|
||||
|
||||
|
||||
// New/Activate/Delete Conversation
|
||||
@@ -140,6 +148,7 @@ function ChatDrawer(props: {
|
||||
|
||||
|
||||
// memoize the group dropdown
|
||||
const { isSearching } = isDrawerSearching(debouncedSearchQuery);
|
||||
const groupingComponent = React.useMemo(() => (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
@@ -147,34 +156,74 @@ function ChatDrawer(props: {
|
||||
slots={{ root: IconButton }}
|
||||
slotProps={{ root: { size: 'sm' } }}
|
||||
>
|
||||
<MoreVertIcon sx={{ fontSize: 'xl' }} />
|
||||
<MoreVertIcon />
|
||||
</MenuButton>
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 180, 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 => (
|
||||
<MenuItem
|
||||
key={'group-' + _gName}
|
||||
aria-label={`Group by ${_gName}`}
|
||||
selected={navGrouping === _gName}
|
||||
onClick={() => setNavGrouping(grouping => grouping === _gName ? false : _gName)}
|
||||
>
|
||||
<ListItemDecorator>{navGrouping === _gName && <CheckIcon />}</ListItemDecorator>
|
||||
{capitalizeFirstLetter(_gName)}
|
||||
|
||||
{!isSearching ? (
|
||||
// Search/Filter default menu: Grouping, Filtering, ...
|
||||
<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', 'dimension'] as Exclude<ChatNavGrouping, false>[]).map(_gName => (
|
||||
<MenuItem
|
||||
key={'group-' + _gName}
|
||||
aria-label={`Group by ${_gName}`}
|
||||
selected={navGrouping === _gName}
|
||||
onClick={() => setNavGrouping(grouping => grouping === _gName ? false : _gName)}
|
||||
>
|
||||
<ListItemDecorator>{navGrouping === _gName && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
{capitalizeFirstLetter(_gName)}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<ListDivider />
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Filter</Typography>
|
||||
</ListItem>
|
||||
<MenuItem onClick={toggleFilterHasStars}>
|
||||
<ListItemDecorator>{filterHasStars && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Starred <StarOutlineRoundedIcon />
|
||||
</MenuItem>
|
||||
))}
|
||||
<ListDivider />
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Show</Typography>
|
||||
</ListItem>
|
||||
<MenuItem onClick={toggleRelativeSize}>
|
||||
<ListItemDecorator>{showRelativeSize && <CheckIcon />}</ListItemDecorator>
|
||||
Relative Size
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<MenuItem onClick={toggleFilterHasImageAssets}>
|
||||
<ListItemDecorator>{filterHasImageAssets && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Has Images <FormatPaintOutlinedIcon />
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Show</Typography>
|
||||
</ListItem>
|
||||
<MenuItem onClick={toggleShowPersonaIcons}>
|
||||
<ListItemDecorator>{showPersonaIcons && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Icons
|
||||
</MenuItem>
|
||||
<MenuItem onClick={toggleShowRelativeSize}>
|
||||
<ListItemDecorator>{showRelativeSize && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Relative Size
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
) : (
|
||||
// While searching, show the sorting options
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Sort By</Typography>
|
||||
</ListItem>
|
||||
<MenuItem selected={searchSorting === 'frequency'} onClick={() => setSearchSorting('frequency')}>
|
||||
<ListItemDecorator>{searchSorting === 'frequency' && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Matches
|
||||
</MenuItem>
|
||||
<MenuItem selected={searchSorting === 'date'} onClick={() => setSearchSorting('date')}>
|
||||
<ListItemDecorator>{searchSorting === 'date' && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Date
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)}
|
||||
</Dropdown>
|
||||
), [navGrouping, showRelativeSize, toggleRelativeSize]);
|
||||
), [
|
||||
filterHasImageAssets, filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize,
|
||||
toggleFilterHasImageAssets, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize,
|
||||
]);
|
||||
|
||||
|
||||
return <>
|
||||
@@ -182,13 +231,13 @@ function ChatDrawer(props: {
|
||||
{/* Drawer Header */}
|
||||
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
|
||||
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
|
||||
<IconButton onClick={toggleEnableFolders}>
|
||||
<IconButton size='sm' onClick={toggleEnableFolders}>
|
||||
{enableFolders ? <FoldersToggleOn /> : <FoldersToggleOff />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</PageDrawerHeader>
|
||||
|
||||
{/* Folders List */}
|
||||
{/* Folders List (shrink at twice the rate as the Titles) */}
|
||||
{/*<Box sx={{*/}
|
||||
{/* display: 'grid',*/}
|
||||
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
|
||||
@@ -205,6 +254,12 @@ function ChatDrawer(props: {
|
||||
contentScaling={contentScaling}
|
||||
activeFolderId={props.activeFolderId}
|
||||
onFolderSelect={props.setActiveFolderId}
|
||||
sx={{
|
||||
// shrink this at twice the rate as the Titles list
|
||||
flexGrow: 0, flexShrink: 2, overflow: 'hidden',
|
||||
minHeight: '7.5rem',
|
||||
p: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/*</Box>*/}
|
||||
@@ -214,81 +269,81 @@ function ChatDrawer(props: {
|
||||
|
||||
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
|
||||
{/* Search Input Field */}
|
||||
<DebounceInputMemo
|
||||
minChars={2}
|
||||
onDebounce={setDebouncedSearchQuery}
|
||||
debounceTimeout={300}
|
||||
placeholder='Search...'
|
||||
aria-label='Search'
|
||||
endDecorator={groupingComponent}
|
||||
sx={{ m: 2 }}
|
||||
/>
|
||||
{/* Search / New Chat */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', m: 2, gap: 2 }}>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<ListItem sx={{ mx: '0.25rem', mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
{/* Search Input Field */}
|
||||
<DebounceInputMemo
|
||||
minChars={2}
|
||||
onDebounce={setDebouncedSearchQuery}
|
||||
debounceTimeout={300}
|
||||
placeholder='Search...'
|
||||
aria-label='Search'
|
||||
endDecorator={groupingComponent}
|
||||
/>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
// variant='outlined'
|
||||
variant={disableNewButton ? undefined : 'outlined'}
|
||||
variant={disableNewButton ? undefined : 'soft'}
|
||||
disabled={disableNewButton}
|
||||
onClick={handleButtonNew}
|
||||
sx={{
|
||||
// ...PageDrawerTallItemSx,
|
||||
px: 'calc(var(--ListItem-paddingX) - 0.25rem)',
|
||||
|
||||
// text size
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '0px 0.75rem',
|
||||
|
||||
// style
|
||||
borderRadius: 'md',
|
||||
boxShadow: (disableNewButton || props.isMobile) ? 'none' : 'sm',
|
||||
backgroundColor: 'background.popup',
|
||||
transition: 'box-shadow 0.2s',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator><AddIcon sx={{ '--Icon-fontSize': 'var(--joy-fontSize-xl)', pl: '0.125rem' }} /></ListItemDecorator>
|
||||
<ListItemDecorator><AddIcon sx={{ fontSize: '' }} /></ListItemDecorator>
|
||||
New chat
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</Button>
|
||||
|
||||
{/*<ListDivider sx={{ mt: 0 }} />*/}
|
||||
|
||||
{/* List of Chat Titles (and actions) */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', ...themeScalingMap[contentScaling].chatDrawerItemSx }}>
|
||||
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
|
||||
{/* <Typography level='body-sm'>*/}
|
||||
{/* Conversations*/}
|
||||
{/* </Typography>*/}
|
||||
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
|
||||
{/* <IconButton value='off'>*/}
|
||||
{/* <AccessTimeIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* <IconButton value='persona'>*/}
|
||||
{/* <PersonIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* </ToggleButtonGroup>*/}
|
||||
{/*</ListItem>*/}
|
||||
</Box>
|
||||
|
||||
{/* Chat Titles List (shrink as half the rate as the Folders List) */}
|
||||
<Box sx={{ flexGrow: 1, flexShrink: 1, flexBasis: '20rem', overflowY: 'auto', ...themeScalingMap[contentScaling].chatDrawerItemSx }}>
|
||||
{renderNavItems.map((item, idx) => item.type === 'nav-item-chat-data' ? (
|
||||
<ChatDrawerItemMemo
|
||||
key={'nav-chat-' + item.conversationId}
|
||||
item={item}
|
||||
showSymbols={showSymbols}
|
||||
showSymbols={showPersonaIcons && showSymbols}
|
||||
bottomBarBasis={filteredChatsBarBasis}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationBranch={onConversationBranch}
|
||||
onConversationDelete={handleConversationDeleteNoConfirmation}
|
||||
onConversationDeleteNoConfirmation={handleConversationDeleteNoConfirmation}
|
||||
onConversationExport={onConversationsExportDialog}
|
||||
onConversationFolderChange={handleConversationFolderChange}
|
||||
/>
|
||||
) : item.type === 'nav-item-group' ? (
|
||||
<Typography key={'nav-divider-' + idx} level='body-xs' sx={{ textAlign: 'center', my: 'calc(var(--ListItem-minHeight) / 4)' }}>
|
||||
<Typography key={'nav-divider-' + idx} level='body-xs' sx={{
|
||||
textAlign: 'center',
|
||||
my: 'calc(var(--ListItem-minHeight) / 4)',
|
||||
// keeps the group header sticky to the top
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: 'background.popup',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
) : item.type === 'nav-item-info-message' ? (
|
||||
<Typography key={'nav-info-' + idx} level='body-xs' sx={{ textAlign: 'center', my: 'calc(var(--ListItem-minHeight) / 2)' }}>
|
||||
<Typography key={'nav-info-' + idx} level='body-xs' sx={{ textAlign: 'center', color: 'primary.softColor', my: 'calc(var(--ListItem-minHeight) / 4)' }}>
|
||||
{filterHasStars && <StarOutlineRoundedIcon sx={{ color: 'primary.softColor', fontSize: 'xl', mb: -0.5, mr: 1 }} />}
|
||||
{item.message}
|
||||
{filterHasStars && <>
|
||||
<Button variant='soft' size='sm' onClick={toggleFilterHasStars} sx={{ display: 'block', mt: 2, mx: 'auto' }}>
|
||||
remove filters
|
||||
</Button>
|
||||
</>}
|
||||
</Typography>
|
||||
) : null,
|
||||
)}
|
||||
@@ -296,7 +351,8 @@ function ChatDrawer(props: {
|
||||
|
||||
<ListDivider sx={{ my: 0 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* Bottom commands */}
|
||||
<Box sx={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<ListItemButton onClick={props.onConversationsImportDialog} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadOutlinedIcon />
|
||||
+68
-29
@@ -5,22 +5,24 @@ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
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 { ANIM_BUSY_TYPING } from '../message/messageUtils';
|
||||
import { CHAT_NOVEL_TITLE } from '../../AppChat';
|
||||
|
||||
|
||||
// set to true to display the conversation IDs
|
||||
@@ -41,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,
|
||||
);
|
||||
@@ -53,10 +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;
|
||||
}
|
||||
@@ -74,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,
|
||||
}) {
|
||||
@@ -86,7 +91,20 @@ function ChatDrawerItem(props: {
|
||||
|
||||
// derived state
|
||||
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, isAlsoOpen, title, 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;
|
||||
|
||||
|
||||
@@ -146,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), []);
|
||||
|
||||
@@ -161,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;
|
||||
|
||||
@@ -174,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',
|
||||
@@ -200,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 && '...'}
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{beingGenerated && ' ...'}
|
||||
</Box>
|
||||
) : (
|
||||
<InlineTextarea
|
||||
@@ -219,16 +247,26 @@ function ChatDrawerItem(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display search frequency if it exists and is greater than 0 */}
|
||||
{searchFrequency > 0 && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
{/* Right text */}
|
||||
{searchFrequency > 0 ? (
|
||||
// Display search frequency if it exists and is greater than 0
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</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]);
|
||||
</>, [
|
||||
beingGenerated, containsImageAssets, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive,
|
||||
isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary,
|
||||
]);
|
||||
|
||||
const progressBarFixedComponent = React.useMemo(() =>
|
||||
progress > 0 && (
|
||||
@@ -259,6 +297,7 @@ function ChatDrawerItem(props: {
|
||||
}),
|
||||
|
||||
// style
|
||||
fontSize: 'inherit',
|
||||
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
|
||||
borderRadius: 'md',
|
||||
mx: '0.25rem',
|
||||
@@ -278,7 +317,7 @@ function ChatDrawerItem(props: {
|
||||
{/* buttons row */}
|
||||
{isActive && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, minHeight: '2.25rem', alignItems: 'center' }}>
|
||||
<ListItemDecorator />
|
||||
{props.showSymbols && <ListItemDecorator />}
|
||||
|
||||
{/* Current Folder color, and change initiator */}
|
||||
{!deleteArmed && <>
|
||||
@@ -300,7 +339,7 @@ function ChatDrawerItem(props: {
|
||||
|
||||
<Tooltip disableInteractive title='Rename'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle || isAutoEditingTitle} onClick={handleTitleEditBegin}>
|
||||
<EditIcon />
|
||||
<EditRoundedIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -311,7 +350,7 @@ function ChatDrawerItem(props: {
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disableInteractive title='Branch'>
|
||||
<Tooltip disableInteractive title='Duplicate (Branch)'>
|
||||
<FadeInButton size='sm' onClick={handleConversationBranch}>
|
||||
<ForkRightIcon />
|
||||
</FadeInButton>
|
||||
+11
-4
@@ -1,15 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { List, ListItem, ListItemButton, ListItemDecorator, Sheet } from '@mui/joy';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
import { StrictModeDroppable } from '~/common/components/StrictModeDroppable';
|
||||
|
||||
import { AddFolderButton } from './AddFolderButton';
|
||||
import { FolderListItem } from './FolderListItem';
|
||||
import { StrictModeDroppable } from './StrictModeDroppable';
|
||||
|
||||
|
||||
export function ChatFolderList(props: {
|
||||
@@ -17,6 +18,7 @@ export function ChatFolderList(props: {
|
||||
contentScaling: ContentScaling;
|
||||
activeFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
|
||||
// derived props
|
||||
@@ -31,13 +33,18 @@ export function ChatFolderList(props: {
|
||||
|
||||
|
||||
return (
|
||||
<Sheet variant='soft' sx={{ p: 2 }}>
|
||||
<Sheet variant='soft' sx={props.sx}>
|
||||
<List
|
||||
variant='plain'
|
||||
sx={(theme) => ({
|
||||
// added to be responsive to parent's layout sizing
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
|
||||
// original list properties
|
||||
'& ul': {
|
||||
'--List-gap': '0px',
|
||||
bgcolor: 'background.surface',
|
||||
bgcolor: 'background.popup',
|
||||
'& > li:first-of-type > [role="button"]': {
|
||||
borderTopRightRadius: 'var(--List-radius)',
|
||||
borderTopLeftRadius: 'var(--List-radius)',
|
||||
@@ -131,6 +138,6 @@ export function ChatFolderList(props: {
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
</Sheet>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
+8
-5
@@ -5,7 +5,7 @@ import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListI
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import Done from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
|
||||
@@ -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 */
|
||||
@@ -214,7 +217,7 @@ export function FolderListItem(props: {
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<EditIcon />
|
||||
<EditRoundedIcon />
|
||||
</ListItemDecorator>
|
||||
Edit
|
||||
</MenuItem>
|
||||
@@ -0,0 +1,283 @@
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
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';
|
||||
|
||||
|
||||
// configuration
|
||||
const SEARCH_MIN_CHARS = 3;
|
||||
|
||||
|
||||
export type ChatNavGrouping = false | 'date' | 'persona' | 'dimension';
|
||||
|
||||
export type ChatSearchSorting = 'frequency' | 'date';
|
||||
|
||||
interface ChatNavigationGroupData {
|
||||
type: 'nav-item-group',
|
||||
title: string,
|
||||
}
|
||||
|
||||
interface ChatNavigationInfoMessage {
|
||||
type: 'nav-item-info-message',
|
||||
message: string,
|
||||
}
|
||||
|
||||
type ChatRenderItemData = ChatNavigationItemData | ChatNavigationGroupData | ChatNavigationInfoMessage;
|
||||
|
||||
|
||||
// Returns a string with the pane indices where the conversation is also open, or false if it's not
|
||||
function findOpenInViewNumbers(chatPanesConversationIds: DConversationId[], ourId: DConversationId): string | false {
|
||||
if (chatPanesConversationIds.length <= 1) return false;
|
||||
return chatPanesConversationIds.reduce((acc: string[], id, idx) => {
|
||||
if (id === ourId)
|
||||
acc.push((idx + 1).toString());
|
||||
return acc;
|
||||
}, []).join(', ') || false;
|
||||
}
|
||||
|
||||
function getNextMidnightTime(): number {
|
||||
const midnight = new Date();
|
||||
// midnight.setDate(midnight.getDate() - 1);
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
return midnight.getTime();
|
||||
}
|
||||
|
||||
function getTimeBucketEn(currentTime: number, midnightTime: number): string {
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const oneWeek = oneDay * 7;
|
||||
const oneMonth = oneDay * 30; // approximation
|
||||
|
||||
const diff = midnightTime - currentTime;
|
||||
|
||||
if (diff < oneDay) {
|
||||
return 'Today';
|
||||
} else if (diff < oneDay * 2) {
|
||||
return 'Yesterday';
|
||||
} else if (diff < oneWeek) {
|
||||
return 'This Week';
|
||||
} else if (diff < oneWeek * 2) {
|
||||
return 'Last Week';
|
||||
} else if (diff < oneMonth) {
|
||||
return 'This Month';
|
||||
} else if (diff < oneMonth * 2) {
|
||||
return 'Last Month';
|
||||
} else {
|
||||
return 'Older';
|
||||
}
|
||||
}
|
||||
|
||||
export function isDrawerSearching(filterByQuery: string): { isSearching: boolean, lcTextQuery: string } {
|
||||
const lcTextQuery = filterByQuery.trim().toLowerCase();
|
||||
return {
|
||||
isSearching: lcTextQuery.length >= SEARCH_MIN_CHARS,
|
||||
lcTextQuery,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
|
||||
* to avoid unnecessary re-renders on each new character typed by the assistant
|
||||
*/
|
||||
export function useChatDrawerRenderItems(
|
||||
activeConversationId: DConversationId | null,
|
||||
chatPanesConversationIds: DConversationId[],
|
||||
filterByQuery: string,
|
||||
activeFolder: DFolder | null,
|
||||
allFolders: DFolder[],
|
||||
filterHasStars: boolean,
|
||||
filterHasImageAssets: boolean,
|
||||
grouping: ChatNavGrouping,
|
||||
searchSorting: ChatSearchSorting,
|
||||
showRelativeSize: boolean,
|
||||
): {
|
||||
renderNavItems: ChatRenderItemData[],
|
||||
filteredChatIDs: DConversationId[],
|
||||
filteredChatsCount: number,
|
||||
filteredChatsAreEmpty: boolean,
|
||||
filteredChatsBarBasis: number,
|
||||
filteredChatsIncludeActive: boolean,
|
||||
} {
|
||||
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));
|
||||
|
||||
// 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);
|
||||
const isAlsoOpen = findOpenInViewNumbers(chatPanesConversationIds, _c.id);
|
||||
|
||||
// set the frequency counters if filtering is enabled
|
||||
let searchFrequency: number = 0;
|
||||
if (isSearching) {
|
||||
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
|
||||
const messageFrequency = _c.messages.reduce((count, message) => {
|
||||
return count + messageFragmentsReduceText(message.fragments).toLowerCase().split(lcTextQuery).length - 1;
|
||||
}, 0);
|
||||
searchFrequency = titleFrequency + messageFrequency;
|
||||
}
|
||||
|
||||
// union of message flags -> emoji string
|
||||
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 {
|
||||
type: 'nav-item-chat-data',
|
||||
conversationId: _c.id,
|
||||
isActive: _c.id === activeConversationId,
|
||||
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)
|
||||
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
|
||||
: null,
|
||||
updatedAt: _c.updated || _c.created || 0,
|
||||
messageCount: _c.messages.length,
|
||||
beingGenerated: !!_c.abortController, // FIXME: when the AbortController is moved at the message level, derive the state in the conv
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
searchFrequency,
|
||||
};
|
||||
})
|
||||
.filter(item => !isSearching || item.searchFrequency > 0);
|
||||
|
||||
// check if the active conversation has an item in the list
|
||||
const filteredChatsIncludeActive = chatNavItems.some(_c => _c.conversationId === activeConversationId);
|
||||
|
||||
|
||||
// [sort by frequency, don't group] if there's a search query
|
||||
if (isSearching && searchSorting === 'frequency')
|
||||
chatNavItems.sort((a, b) => b.searchFrequency - a.searchFrequency);
|
||||
|
||||
// Render List
|
||||
let renderNavItems: ChatRenderItemData[] = chatNavItems;
|
||||
|
||||
// [search] add a header if searching
|
||||
if (isSearching) {
|
||||
|
||||
// only prepend a 'Results' group if there are results
|
||||
if (chatNavItems.length)
|
||||
renderNavItems = [{ type: 'nav-item-group', title: 'Search results' }, ...chatNavItems];
|
||||
|
||||
}
|
||||
// [grouping] group by date or persona
|
||||
else if (grouping) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const midnightTime = getNextMidnightTime();
|
||||
const grouped = chatNavItems.reduce((acc, item) => {
|
||||
|
||||
// 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[bucket])
|
||||
acc[bucket] = [];
|
||||
acc[bucket].push(item);
|
||||
return acc;
|
||||
}, {} as { [groupName: string]: ChatNavigationItemData[] });
|
||||
|
||||
// prepend group names as special items
|
||||
renderNavItems = Object.entries(grouped).flatMap(([groupName, items]) => [
|
||||
{ type: 'nav-item-group', title: groupName },
|
||||
...items,
|
||||
]);
|
||||
}
|
||||
|
||||
// [empty message] if there are no items
|
||||
if (!renderNavItems.length)
|
||||
renderNavItems.push({
|
||||
type: 'nav-item-info-message',
|
||||
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
|
||||
const filteredChatIDs = chatNavItems.map(_c => _c.conversationId);
|
||||
const filteredChatsCount = chatNavItems.length;
|
||||
const filteredChatsAreEmpty = !filteredChatsCount || (filteredChatsCount === 1 && chatNavItems[0].isEmpty);
|
||||
const filteredChatsBarBasis = ((showRelativeSize && filteredChatsCount >= 2) || isSearching)
|
||||
? chatNavItems.reduce((longest, _c) => Math.max(longest, isSearching ? _c.searchFrequency : _c.messageCount), 1)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
renderNavItems,
|
||||
filteredChatIDs,
|
||||
filteredChatsCount,
|
||||
filteredChatsAreEmpty,
|
||||
filteredChatsBarBasis,
|
||||
filteredChatsIncludeActive,
|
||||
};
|
||||
},
|
||||
(a, b) => {
|
||||
// we only compare the renderNavItems array, which shall be changed if the rest changes
|
||||
return a.renderNavItems.length === b.renderNavItems.length
|
||||
&& a.renderNavItems.every((_a, i) => shallow(_a, b.renderNavItems[i]))
|
||||
&& shallow(a.filteredChatIDs, b.filteredChatIDs)
|
||||
&& a.filteredChatsCount === b.filteredChatsCount
|
||||
&& a.filteredChatsAreEmpty === b.filteredChatsAreEmpty
|
||||
&& a.filteredChatsBarBasis === b.filteredChatsBarBasis
|
||||
&& a.filteredChatsIncludeActive === b.filteredChatsIncludeActive;
|
||||
},
|
||||
);
|
||||
}
|
||||
+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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user