mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
700 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| bc120bfb2b | |||
| 88966699e7 | |||
| 9a5db3dcfb | |||
| 392aa1e654 | |||
| f2b32e47ff | |||
| 58136d0181 | |||
| 02733e55cb | |||
| 60df8456a7 | |||
| 6d0ecc805c | |||
| a0e9dd24a3 | |||
| d1eb89057d | |||
| 161c6dc83a | |||
| 54848b8a7e | |||
| 990563c604 | |||
| 8489ca8c8d | |||
| b57e2c89e3 | |||
| 66bedf78ac | |||
| 592c5cce60 | |||
| 2ccf9a4e92 | |||
| ed333c0513 | |||
| 89b65b7009 | |||
| 0cc2d346af | |||
| 5f81e78bc4 | |||
| 554b5fd4b5 | |||
| a58c3a6a52 | |||
| 6147f1131b | |||
| 26552aa996 | |||
| 17cc31f376 | |||
| 41f7a63392 | |||
| 70474ce517 | |||
| 365f144c57 | |||
| ff1e1c249f | |||
| e3ed6f802d | |||
| b5ed078260 | |||
| 64310292da | |||
| 2656d0dfa5 | |||
| 70a7f0aaf4 | |||
| d405dcaa3a | |||
| 5ecef67855 | |||
| 8f6d9f8c31 | |||
| 8662437b1a | |||
| ce3e5629e7 | |||
| d4c487534d | |||
| 2b9577b87d | |||
| 6a0f8564f3 | |||
| e9f74946e3 | |||
| e043ab8710 | |||
| 79dd2f5f6b | |||
| 76e6ca8f0c | |||
| 0f310e866f | |||
| 1f66221bbd | |||
| 635b70fb6c | |||
| d113801b18 | |||
| ac74efed4a | |||
| 52e1dc2fb2 | |||
| 7564fd5e03 | |||
| 96810328ee | |||
| 5603a98df9 | |||
| 5c800e35f2 | |||
| dd15eecdf1 | |||
| b6cb68bfcf | |||
| 07c5143f1e | |||
| e8c0cf3306 | |||
| 5e86d16442 | |||
| 5ff246a241 | |||
| 58d54682ab | |||
| 5ab547d434 | |||
| 96a5868543 | |||
| 0422c03efe | |||
| 2745c7295e | |||
| 82f6ec5839 | |||
| 8e1a155cff | |||
| 521578c4aa | |||
| a04f5f8c94 | |||
| fb6f96689b | |||
| 69a12d45f3 | |||
| bf4dd37a1b | |||
| b1230a9758 | |||
| 23621c57ed | |||
| 5f49a9f8ef | |||
| c5b31c3975 | |||
| 74dbe11d4a | |||
| 64b18c0a0a | |||
| 7c6cec8eea | |||
| 2b1869e1b3 | |||
| 87e5a155ba | |||
| d5c7071f1b | |||
| 04eb2210e6 | |||
| 4748b00be1 | |||
| 18968ba985 | |||
| 59b300b71e | |||
| 5916ef74f9 | |||
| f5602723c7 | |||
| 59795dcd22 | |||
| 127a5cbf96 | |||
| 2b040664cb | |||
| 4ffbdfd16c | |||
| e200cbf312 | |||
| f4edd192fd | |||
| dd07167087 | |||
| 81aa8468a7 | |||
| 871e72b655 | |||
| 9825d8e2f3 | |||
| 58c5569beb | |||
| c975511c74 | |||
| e3c52fb1f9 | |||
| 397517e666 | |||
| 09088febe8 | |||
| bbf5dc078e | |||
| 14d57aa622 | |||
| bcfc4921ca | |||
| cff70ebadd | |||
| 4b9c958d65 | |||
| 7dc7116a2f | |||
| 92a2c93644 | |||
| 7be0d88794 | |||
| ff6ca01813 | |||
| ce0dca86ac | |||
| 6c51a36dbc | |||
| 72bb31881a | |||
| c6fcad03cd | |||
| 70de7133a9 | |||
| ef36751eac | |||
| dee1461b9c | |||
| 3b775fc817 | |||
| da52eff9d3 | |||
| a7efaa7720 | |||
| a42587c498 | |||
| d29265f042 | |||
| c305b44c41 | |||
| 32ff65be1c | |||
| b550cbdfc7 | |||
| f767ad81ce | |||
| 35d04055ac | |||
| c7fe75829f | |||
| 8299b4c148 | |||
| 5bb84f8930 | |||
| 047c9a2f07 | |||
| 8c11925444 | |||
| 1cbb4fd11a | |||
| 0a8d9ebd55 | |||
| 386724655e | |||
| 7b37b9e204 | |||
| 3b02612124 | |||
| 32b040cbcf | |||
| 75a15a12a6 | |||
| 0cb7be8381 | |||
| 20d3c267a3 | |||
| 84313ffa8c | |||
| be66ce0f32 | |||
| 12c1194009 | |||
| 82b83a39dd | |||
| ac617de4ae | |||
| b6731c9afa | |||
| 3a7ece6508 | |||
| 2c69d2805d | |||
| 87b03c67ec | |||
| 569b08288e | |||
| 049fa90832 | |||
| f23347de7e | |||
| 0272283f94 | |||
| 64640c1331 | |||
| ff1471cfe8 | |||
| aae3783f67 | |||
| 053aa12a91 | |||
| 17a006db8f | |||
| 56d912da3d | |||
| 3c60284e6e | |||
| 76ddff4820 | |||
| 1bd6dc0a1a | |||
| 5c7d289123 | |||
| 8f6d646a1f | |||
| c42123fe2a | |||
| 58bd84b600 | |||
| 621eb4a54c | |||
| 9073cff1c1 | |||
| d69516df5c | |||
| 7322280d3d | |||
| 5f79569ea9 | |||
| fe8b8472b7 | |||
| cb2b1a89b5 | |||
| 6ece7b884a | |||
| 04fc9264cb | |||
| 016c2df942 | |||
| bf6a2b60b9 | |||
| 5093e70552 | |||
| 3bd50e1b45 | |||
| 793383f70d | |||
| 3b84e42932 | |||
| 09efc9b148 | |||
| 90c2542486 | |||
| 9259fa3b6d | |||
| 0c8f102830 | |||
| 02972a0fb6 | |||
| 2a4a65f129 | |||
| e16270e1ec | |||
| 201a884828 | |||
| 2a32139be3 | |||
| 7955bf2b86 | |||
| a5d70e4ca3 | |||
| 12eb08ee08 | |||
| fe74583bae | |||
| b8b1dd2cfb | |||
| 9723b328c3 | |||
| edc3ab6d00 | |||
| 0e243cd167 | |||
| b8e0064381 | |||
| 018c77901d | |||
| 5849fd9c94 | |||
| 6a5d1eb5c2 | |||
| fc70857fae | |||
| 5cd6fe23d8 | |||
| beffcdcba9 | |||
| cdd39457ff | |||
| 937b2806ef | |||
| 34552190c6 | |||
| 7e762d5ddc | |||
| 8e78b21a5c | |||
| ae85fdf59f | |||
| e39dc428cc | |||
| cc178efacb | |||
| 8a7a3afc10 | |||
| e0f1689125 | |||
| 3acdd75863 | |||
| 1ca5ff726c | |||
| 464051c319 | |||
| 548859fa65 | |||
| f57c10508f | |||
| b7f53d965f | |||
| 566bf8d38e | |||
| 663306bd3b | |||
| 165a5e60d3 | |||
| 3b01a26eed | |||
| 65f997a2ba | |||
| c1217ed8ed | |||
| 6ae76c553f | |||
| 141096eace | |||
| c4003a888a | |||
| d1c22e12a7 | |||
| 9461cab182 | |||
| dcceead4ca | |||
| ae8ac5111c | |||
| 1e35fceb61 | |||
| 88d0ffd712 | |||
| 6cbc3fbf28 | |||
| 4eb6f6da9d | |||
| 5bc320385f |
@@ -1,7 +1,12 @@
|
||||
# big-AGI non-code files
|
||||
/docs/
|
||||
/dist/
|
||||
README.md
|
||||
|
||||
# Ignore build and log files
|
||||
Dockerfile
|
||||
/.dockerignore
|
||||
|
||||
# Node build artifacts
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
@@ -21,8 +21,9 @@ assignees: enricoros
|
||||
- [ ] Create a temporary tag `git tag v1.2.3 && git push opensource --tags`
|
||||
- [ ] Create a [New Draft GitHub Release](https://github.com/enricoros/big-agi/releases/new), and generate the automated changelog (for new contributors)
|
||||
- [ ] Update the release version in package.json, and `npm i`
|
||||
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
|
||||
- [ ] Update the in-app News version number
|
||||
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
|
||||
- [ ] Update in-app Cover graphics
|
||||
- [ ] Update the README.md with the new release
|
||||
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
|
||||
- Release:
|
||||
@@ -79,11 +80,32 @@ I need the following from you:
|
||||
|
||||
1. a table summarizing all the new features in 1.2.3 with the following columns: 4 words description (exactly what it is), short description, usefulness (what it does for the user), significance, link to the issue number (not the commit)), which will be used for the artifacts later
|
||||
2. then double-check the git log to see if there are any features of significance that are not in the table
|
||||
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
|
||||
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
|
||||
4. then improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
|
||||
5. then I want you then to update the news.data.tsx for the new release
|
||||
```
|
||||
|
||||
### release name
|
||||
|
||||
```markdown
|
||||
please brainstorm 10 different names for this release. see the former names here: https://big-agi.com/blog
|
||||
```
|
||||
|
||||
You can follow with 'What do you think of Modelmorphic?' or other selected name
|
||||
|
||||
### cover images
|
||||
|
||||
```markdown
|
||||
Great, now I need to generate images for this. Before I used the following prompts (2 releases before).
|
||||
|
||||
// An image of a capybara sculpted entirely from black cotton candy, set against a minimalist backdrop with splashes of bright, contrasting sparkles. The capybara is using a computer with split screen made of origami, split keyboard and is wearing origami sunglasses with very different split reflections. Split halves are very contrasting. Close up photography, bokeh, white background.
|
||||
import coverV113 from '../../../public/images/covers/release-cover-v1.13.0.png';
|
||||
// An image of a capybara sculpted entirely from black cotton candy, set against a minimalist backdrop with splashes of bright, contrasting sparkles. The capybara is calling on a 3D origami old-school pink telephone and the camera is zooming on the telephone. Close up photography, bokeh, white background.
|
||||
import coverV112 from '../../../public/images/covers/release-cover-v1.12.0.png';
|
||||
|
||||
What can I do now as far as images? Give me 4 prompt ideas with the same style as looks as the former, but different scene or action
|
||||
```
|
||||
|
||||
### Readme (and Changelog)
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -57,4 +57,5 @@ jobs:
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
@@ -1,5 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Frontend Build: ignore API files disabled for this build
|
||||
/app/**/*.backup
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@@ -10,6 +13,7 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/dist/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
+12
-4
@@ -2,22 +2,28 @@
|
||||
FROM node:18-alpine AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
|
||||
# Dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Dependency files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY src/server/prisma ./src/server/prisma
|
||||
|
||||
# Install dependencies, including dev (release builds should use npm ci)
|
||||
ENV NODE_ENV development
|
||||
RUN npm ci
|
||||
|
||||
|
||||
# Builder
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Optional argument to configure GA4 at build time (see: docs/deploy-analytics.md)
|
||||
ARG NEXT_PUBLIC_GA4_MEASUREMENT_ID
|
||||
ENV NEXT_PUBLIC_GA4_MEASUREMENT_ID=${NEXT_PUBLIC_GA4_MEASUREMENT_ID}
|
||||
|
||||
# Copy development deps and source
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
@@ -29,6 +35,7 @@ RUN npm run build
|
||||
# Reduce installed packages to production-only
|
||||
RUN npm prune --production
|
||||
|
||||
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -38,9 +45,10 @@ RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy Built app
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next .next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/server/prisma ./src/server/prisma
|
||||
|
||||
# Minimal ENV for production
|
||||
ENV NODE_ENV production
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
# BIG-AGI 🧠✨
|
||||
|
||||
Welcome to big-AGI 👋, the GPT application for professionals that need function, form,
|
||||
simplicity, and speed. Powered by the latest models from 11 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.
|
||||
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 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)
|
||||
|
||||
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)
|
||||
[](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)
|
||||
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
|
||||
|
||||
big-AGI is an open book; our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)**
|
||||
shows the current developments and future ideas.
|
||||
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
|
||||
|
||||
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
|
||||
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
|
||||
### 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
|
||||
|
||||
@@ -27,13 +44,14 @@ https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385
|
||||
|
||||
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
|
||||
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
|
||||
- **Export tables as CSV** - big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
|
||||
- **Adjustable Text Size**: enjoy denser chats. [#399](https://github.com/enricoros/big-AGI/issues/399)
|
||||
- **Export tables as CSV**: big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
|
||||
- Adjustable text size: customize density. [#399](https://github.com/enricoros/big-AGI/issues/399)
|
||||
- Dev2 Persona Technology Preview
|
||||
- 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-lmstudio.md), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/config-database.md) (thanks @ranfysvalle02), and speedups
|
||||
- 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
|
||||
|
||||
### What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
<details>
|
||||
<summary>What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
@@ -46,7 +64,10 @@ https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317be
|
||||
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
|
||||
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
|
||||
|
||||
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.11.0 · Jan 16, 2024 · Singularity</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
|
||||
@@ -57,44 +78,98 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
|
||||
- Enable adding up to five custom OpenAI-compatible endpoints
|
||||
- Developer enhancements: new 'Actiles' framework
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI</summary>
|
||||
|
||||
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
|
||||
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
|
||||
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
|
||||
- Resizable panes in split-screen conversations.
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
</details>
|
||||
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## ✨ Key Features 👊
|
||||
## 👉 Key Features ✨
|
||||
|
||||
|  |  |  |  |  |
|
||||
|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| **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 |
|
||||
|
||||

|
||||
|
||||
- **AI Personas**: Tailor your AI interactions with customizable personas
|
||||
- **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface
|
||||
- **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads
|
||||
- **Multiple AI Models**: Choose from a variety of leading AI providers
|
||||
- **Privacy First**: Self-host and use your own API keys for full control
|
||||
- **Advanced Tools**: Execute code, import PDFs, and summarize documents
|
||||
- **Seamless Integrations**: Enhance functionality with various third-party services
|
||||
- **Open Roadmap**: Contribute to the progress of big-AGI
|
||||
You can easily configure 100s of AI models in big-AGI:
|
||||
|
||||
## 💖 Support
|
||||
| **AI models** | _supported vendors_ |
|
||||
|:--------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Opensource Servers | [LocalAI](https://localai.com) (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/) |
|
||||
| Image services | [Prodia](https://prodia.com/) (SDXL) |
|
||||
| Speech services | [ElevenLabs](https://elevenlabs.io) (Voice synthesis / cloning) |
|
||||
|
||||
Add extra functionality with these integrations:
|
||||
|
||||
| **More** | _integrations_ |
|
||||
|:-------------|:---------------------------------------------------------------------------------------------------------------|
|
||||
| Web Browse | [Browserless](https://www.browserless.io/) · [Puppeteer](https://pptr.dev/)-based |
|
||||
| Web Search | [Google CSE](https://programmablesearchengine.google.com/) |
|
||||
| Code Editors | [CodePen](https://codepen.io/pen/) · [StackBlitz](https://stackblitz.com/) · [JSFiddle](https://jsfiddle.net/) |
|
||||
| Sharing | [Paste.gg](https://paste.gg/) (Paste chats) |
|
||||
| Tracking | [Helicone](https://www.helicone.ai) (LLM Observability) |
|
||||
|
||||
[//]: # (- [x] **Flow-state UX** for uncompromised productivity)
|
||||
|
||||
[//]: # (- [x] **AI Personas**: Tailor your AI interactions with customizable personas)
|
||||
|
||||
[//]: # (- [x] **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface)
|
||||
|
||||
[//]: # (- [x] **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads)
|
||||
|
||||
[//]: # (- [x] **Privacy First**: Self-host and use your own API keys for full control)
|
||||
|
||||
[//]: # (- [x] **Advanced Tools**: Execute code, import PDFs, and summarize documents)
|
||||
|
||||
[//]: # (- [x] **Seamless Integrations**: Enhance functionality with various third-party services)
|
||||
|
||||
[//]: # (- [x] **Open Roadmap**: Contribute to the progress of big-AGI)
|
||||
|
||||
<br/>
|
||||
|
||||
# 🌟 Get Involved!
|
||||
|
||||
[//]: # ([](https://discord.gg/MkH4qj2Jp9))
|
||||
[](https://discord.gg/MkH4qj2Jp9)
|
||||
|
||||
* Enjoy the hosted open-source app on [big-AGI.com](https://big-agi.com)
|
||||
* [Chat with us](https://discord.gg/MkH4qj2Jp9)
|
||||
* Deploy your [fork](https://github.com/enricoros/big-agi/fork) for your friends and family
|
||||
* send PRs! ...
|
||||
🎭[Editing Personas](https://github.com/enricoros/big-agi/issues/35),
|
||||
🧩[Reasoning Systems](https://github.com/enricoros/big-agi/issues/36),
|
||||
🌐[Community Templates](https://github.com/enricoros/big-agi/issues/35),
|
||||
and [your big-IDEAs](https://github.com/enricoros/big-agi/issues/new?labels=RFC&body=Describe+the+idea)
|
||||
- [ ] 📢️ [**Chat with us** on Discord](https://discord.gg/MkH4qj2Jp9)
|
||||
- [ ] ⭐ **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) |
|
||||
|
||||
<br/>
|
||||
|
||||
## 🧩 Develop
|
||||
# 🧩 Develop
|
||||
|
||||

|
||||

|
||||

|
||||
[//]: # ()
|
||||
|
||||
Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the
|
||||
[//]: # ()
|
||||
|
||||
[//]: # ()
|
||||
|
||||
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
|
||||
@@ -102,12 +177,18 @@ 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 manually
|
||||
## 🛠️ 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.
|
||||
@@ -146,25 +227,17 @@ Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare
|
||||
|
||||
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://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)
|
||||
|
||||
## Integrations:
|
||||
|
||||
* Local models: Ollama, Oobabooga, LocalAi, etc.
|
||||
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
|
||||
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Models > OpenAI > Advanced > API Host: 'oai.hconeai.com'
|
||||
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
|
||||
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/stargazers))
|
||||
|
||||
<br/>
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/network))
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/pulls))
|
||||
|
||||
[](https://github.com/enricoros/big-agi/stargazers)
|
||||
[](https://github.com/enricoros/big-agi/network)
|
||||
[](https://github.com/enricoros/big-agi/pulls)
|
||||
[](https://github.com/enricoros/big-agi/LICENSE)
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/LICENSE))
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/issues))
|
||||
---
|
||||
|
||||
Made with 💙
|
||||
2023-2024 · Enrico Ros x [big-AGI](https://big-agi.com) · License: [MIT](LICENSE) · Made with 💙
|
||||
|
||||
@@ -1,52 +1,2 @@
|
||||
import { createEmptyReadableStream, safeErrorString, serverFetchOrThrow } from '~/server/wire';
|
||||
|
||||
import { elevenlabsAccess, elevenlabsVoiceId, ElevenlabsWire, speechInputSchema } from '~/modules/elevenlabs/elevenlabs.router';
|
||||
|
||||
|
||||
/* NOTE: Why does this file even exist?
|
||||
|
||||
This file is a workaround for a limitation in tRPC; it does not support ArrayBuffer responses,
|
||||
and that would force us to use base64 encoding for the audio data, which would be a waste of
|
||||
bandwidth. So instead, we use this file to make the request to ElevenLabs, and then return the
|
||||
response as an ArrayBuffer. Unfortunately this means duplicating the code in the server-side
|
||||
and client-side vs. the tRPC implementation. So at lease we recycle the input structures.
|
||||
|
||||
*/
|
||||
const handler = async (req: Request) => {
|
||||
try {
|
||||
|
||||
// construct the upstream request
|
||||
const {
|
||||
elevenKey, text, voiceId, nonEnglish,
|
||||
streaming, streamOptimization,
|
||||
} = speechInputSchema.parse(await req.json());
|
||||
const path = `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}` + (streaming ? `/stream?optimize_streaming_latency=${streamOptimization || 1}` : '');
|
||||
const { headers, url } = elevenlabsAccess(elevenKey, path);
|
||||
const body: ElevenlabsWire.TTSRequest = {
|
||||
text: text,
|
||||
...(nonEnglish && { model_id: 'eleven_multilingual_v1' }),
|
||||
};
|
||||
|
||||
// elevenlabs POST
|
||||
const upstreamResponse: Response = await serverFetchOrThrow(url, 'POST', headers, body);
|
||||
|
||||
// NOTE: this is disabled, as we pass-through what we get upstream for speed, as it is not worthy
|
||||
// to wait for the entire audio to be downloaded before we send it to the client
|
||||
// if (!streaming) {
|
||||
// const audioArrayBuffer = await upstreamResponse.arrayBuffer();
|
||||
// return new NextResponse(audioArrayBuffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
|
||||
// }
|
||||
|
||||
// stream the data to the client
|
||||
const audioReadableStream = upstreamResponse.body || createEmptyReadableStream();
|
||||
return new Response(audioReadableStream, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
|
||||
|
||||
} catch (error: any) {
|
||||
const fetchOrVendorError = safeErrorString(error) + (error?.cause ? ' · ' + error.cause : '');
|
||||
console.log(`api/elevenlabs/speech: fetch issue: ${fetchOrVendorError}`);
|
||||
return new Response(`[Issue] elevenlabs: ${fetchOrVendorError}`, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const runtime = 'edge';
|
||||
export { handler as POST };
|
||||
export { elevenLabsHandler as POST } from '~/modules/elevenlabs/elevenlabs.server';
|
||||
@@ -11,7 +11,7 @@ const handlerEdgeRoutes = (req: Request) =>
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? '<no-path>'}:`, error)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? "<no-path>"}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ const handlerNodeRoutes = (req: Request) =>
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}:`, error)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// NOTE: the following statement breaks the build on non-pro deployments, and conditionals don't work either
|
||||
// so we resorted to raising the timeout from 10s to 25s in the vercel.json file instead
|
||||
// export const maxDuration = 25;
|
||||
export const dynamic = 'force-dynamic';
|
||||
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
|
||||
@@ -0,0 +1,65 @@
|
||||
# big-AGI Documentation
|
||||
|
||||
Find all the information you need to get started, configure, and effectively use big-AGI.
|
||||
|
||||
[//]: # (## Quick Start)
|
||||
|
||||
[//]: # (- **[Introduction](big-agi.md)**: Overview of big-AGI's features.)
|
||||
|
||||
## Configuration Guides
|
||||
|
||||
Detailed guides to configure your big-AGI interface and models.
|
||||
|
||||
👉 The following applies to the users of big-AGI.com, as the public instance is empty and to be configured by the user.
|
||||
|
||||
- **Cloud Model Services**:
|
||||
- **[Azure OpenAI](config-azure-openai.md)**
|
||||
- **[OpenRouter](config-openrouter.md)**
|
||||
- easy API key: **Anthropic**, **Google AI**, **Groq**, **Mistral**, **OpenAI**, **Perplexity**, **TogetherAI**
|
||||
|
||||
|
||||
- **Local Model Servers**:
|
||||
- **[LocalAI](config-local-localai.md)**
|
||||
- **[LM Studio](config-local-lmstudio.md)**
|
||||
- **[Ollama](config-local-ollama.md)**
|
||||
- **[Oobabooga](config-local-oobabooga.md)**
|
||||
|
||||
|
||||
- **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](environment-variables.md)'
|
||||
- **Prodia API**: Stable Diffusion XL image generation, only requires their API key, alternative to DALL·E
|
||||
|
||||
## Deployment
|
||||
|
||||
System integrators, administrators, whitelabelers: instead of using the public big-AGI instance on get.big-agi.com, you can deploy your own instance.
|
||||
|
||||
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
|
||||
|
||||
|
||||
- **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
|
||||
|
||||
## Support and Community
|
||||
|
||||
Join our community or get support:
|
||||
|
||||
- Visit our [GitHub repository](https://github.com/enricoros/big-AGI) for source code and issue tracking
|
||||
- Check the latest updates and features on [Changelog](changelog.md) or the in-app [News](https://get.big-agi.com/news)
|
||||
- Connect with us and other users on [Discord](https://discord.gg/MkH4qj2Jp9) for discussions, help, and sharing your experiences with big-AGI
|
||||
|
||||
Thank you for choosing big-AGI. We're excited to see what you'll build.
|
||||
+30
-10
@@ -5,24 +5,44 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.13.0 - Feb 2024
|
||||
### 1.16.0 - Mar 2024
|
||||
|
||||
- milestone: [1.13.0](https://github.com/enricoros/big-agi/milestone/13)
|
||||
- milestone: [1.16.0](https://github.com/enricoros/big-agi/milestone/16)
|
||||
- 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.13.0 · Feb 8, 2024 · Multi + Mind
|
||||
### 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
|
||||
- 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
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
|
||||
|
||||
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
|
||||
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
|
||||
- **Export tables as CSV** - big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
|
||||
- **Adjustable Text Size**: enjoy denser chats. [#399](https://github.com/enricoros/big-AGI/issues/399)
|
||||
- **Export tables as CSV**: big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
|
||||
- Adjustable text size: customize density. [#399](https://github.com/enricoros/big-AGI/issues/399)
|
||||
- Dev2 Persona Technology Preview
|
||||
- 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-lmstudio.md), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/config-database.md) (thanks @ranfysvalle02), and speedups
|
||||
- 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
|
||||
|
||||
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
### What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
@@ -81,7 +101,7 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
|
||||
|
||||
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
|
||||
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
|
||||
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
|
||||
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-feature-browse.md)
|
||||
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
|
||||
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
|
||||
- Optimized Voice Input and Performance
|
||||
@@ -90,7 +110,7 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
|
||||
|
||||
### What's New in 1.6.0 - Nov 28, 2023 · Surf's Up
|
||||
|
||||
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
|
||||
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-feature-browse.md)
|
||||
- **Branching Discussions**: Create new conversations from any message
|
||||
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
|
||||
- **Performance Boost**: Faster rendering for a smoother experience
|
||||
@@ -164,7 +184,7 @@ For Developers:
|
||||
- **[Install Mobile APP](../docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
|
||||
- **[UI language](../docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
|
||||
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
|
||||
- **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
|
||||
- **Code Execution: [Codepen](https://codepen.io/)** 💻 (@harlanlewis)
|
||||
- **[SVG Drawing](../docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
|
||||
- Chats: multiple chats, AI titles, Import/Export, Selection mode
|
||||
- Rendering: Markdown, SVG, improved Code blocks
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
Allows users to load web pages across various components of `big-AGI`. This feature is supported by Puppeteer-based
|
||||
browsing services, which are the most common way to render web pages in a headless environment.
|
||||
|
||||
Once configured, the Browsing service provides this functionality:
|
||||
Once configured, the Browsing service provides the following functionality:
|
||||
|
||||
- **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
|
||||
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
|
||||
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
|
||||
- ✅ **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
|
||||
- ✅ **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
|
||||
- ✅ **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
|
||||
|
||||
It does not yet support the following functionality:
|
||||
|
||||
- ✖️ **Auto-browsing by LLMs**: if an LLM encounters a URL, it will NOT load the page and will likely respond
|
||||
that it cannot browse the web - No technical limitation, just haven't gotten to implement this yet outside of `/react` yet
|
||||
|
||||
First of all, you need to procure a Puppteer web browsing service endpoint. `big-AGI` supports services like:
|
||||
|
||||
@@ -109,3 +114,5 @@ 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))
|
||||
@@ -1,34 +1,61 @@
|
||||
# Local LLM integration with `localai`
|
||||
# Run your models with `LocalAI` x `big-AGI`
|
||||
|
||||
Integrate local Large Language Models (LLMs) with [LocalAI](https://localai.io).
|
||||
[LocalAI](https://localai.io) lets you run your AI models locally, or in the cloud. It supports text, image, asr, speech, and more models.
|
||||
|
||||
_Last updated Nov 7, 2023_
|
||||
We are deepening the integration between the two products. As of the time of writing, we integrate the following features:
|
||||
|
||||
## Instructions
|
||||
- ✅ [Text generation](https://localai.io/features/text-generation/) with GPTs
|
||||
- ✅ [Function calling](https://localai.io/features/openai-functions/) by GPTs 🆕
|
||||
- ✅ [Model Gallery](https://localai.io/models/) to list and install models
|
||||
- ✖️ [Vision API](https://localai.io/features/gpt-vision/) for image chats
|
||||
- ✖️ [Image generation](https://localai.io/features/image-generation) with stable diffusion
|
||||
- ✖️ [Audio to Text](https://localai.io/features/audio-to-text/)
|
||||
- ✖️ [Text to Audio](https://localai.io/features/text-to-audio/)
|
||||
- ✖️ [Embeddings generation](https://localai.io/features/embeddings/)
|
||||
- ✖️ [Constrained grammars](https://localai.io/features/constrained_grammars/) (JSON output)
|
||||
- ✖️ Voice cloning 🆕
|
||||
|
||||
_Last updated Feb 21, 2024_
|
||||
|
||||
## Guide
|
||||
|
||||
### LocalAI installation and configuration
|
||||
|
||||
Follow the guide at: https://localai.io/basics/getting_started/
|
||||
|
||||
For instance with [Use luna-ai-llama2 with docker compose](https://localai.io/basics/getting_started/#example-use-luna-ai-llama2-model-with-docker-compose):
|
||||
- verify it works by browsing to [http://localhost:8080/v1/models](http://localhost:8080/v1/models)
|
||||
(or the IP:Port of the machine, if running remotely) and seeing listed the model(s) you downloaded
|
||||
listed in the JSON response.
|
||||
|
||||
- clone LocalAI
|
||||
- get the model
|
||||
- copy the prompt template
|
||||
- start docker
|
||||
- -> the server will be listening on `localhost:8080`
|
||||
- verify it works by going to [http://localhost:8080/v1/models](http://localhost:8080/v1/models) on
|
||||
your browser and seeing listed the model you downloaded
|
||||
|
||||
### Integrating LocalAI with big-AGI
|
||||
### Integration: chat with LocalAI
|
||||
|
||||
- Go to Models > Add a model source of type: **LocalAI**
|
||||
- Enter the address: `http://localhost:8080` (default)
|
||||
- If running remotely, replace localhost with the IP of the machine. Make sure to use the **IP:Port** format
|
||||
- Load the models
|
||||
- Select model & Chat
|
||||
- Enter the default address: `http://localhost:8080`, or the address of your localAI cloud instance
|
||||

|
||||
- If running remotely, replace localhost with the IP of the machine. Make sure to use the **IP:Port** format
|
||||
- Load the models (click on `Models 🔄`)
|
||||
- Select the model and chat
|
||||
|
||||
> NOTE: LocalAI does not list details about the mdoels. Every model is assumed to be
|
||||
> capable of chatting, and with a context window of 4096 tokens.
|
||||
> Please update the [src/modules/llms/transports/server/openai/models.data.ts](../src/modules/llms/server/openai/models.data.ts)
|
||||
> file with the mapping information between LocalAI model IDs and names/descriptions/tokens, etc.
|
||||
### Integration: Models Gallery
|
||||
|
||||
If the running LocalAI instance is configured with a [Model Gallery](https://localai.io/models/):
|
||||
|
||||
- Go to Models > LocalAI
|
||||
- Click on `Gallery Admin`
|
||||
- Select the models to install, and view installation progress
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
##### Unknown Context Window Size
|
||||
|
||||
At the time of writing, LocalAI does not publish the model `context window size`.
|
||||
Every model is assumed to be capable of chatting, and with a context window of 4096 tokens.
|
||||
Please update the [src/modules/llms/transports/server/openai/models.data.ts](../src/modules/llms/server/openai/models.data.ts)
|
||||
file with the mapping information between LocalAI model IDs and names/descriptions/tokens, etc.
|
||||
|
||||
# 🤝 Support
|
||||
|
||||
- Hop into the [LocalAI Discord](https://discord.gg/uJAeKSAGDy) for support and questions
|
||||
- Hop into the [big-AGI Discord](https://discord.gg/MkH4qj2Jp9) for questions
|
||||
- For big-AGI support, please open an issue in our [big-AGI issue tracker](https://bit.ly/agi-request)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Customizing and Creating Derivative Applications
|
||||
|
||||
This document outlines how to develop applications derived from big-AGI.
|
||||
|
||||
## Manual Customization
|
||||
|
||||
Application customization _requires manual code modifications or the use of environment variables_. Currently, **there is no admin panel to "managed" deployment customization** for enterprise use cases.
|
||||
|
||||
| Required Code Alteration | Not Required |
|
||||
|---------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
|
||||
| - Persona changes<br>- UI theme customization<br>- Feature additions or modifications | - Setting API keys in [environment variables](environment-variables.md)<br>- Toggling features with environment variables |
|
||||
| Apply these to the source code before building the application | Set these post-build on local machines or cloud deployment, before application launch |
|
||||
|
||||
<br/>
|
||||
|
||||
## Code Alterations
|
||||
|
||||
Start by creating a fork of the [big-AGI repository](https://github.com/enricoros/big-AGI) on GitHub for a personal development space.
|
||||
Understand the Architecture: big-AGI uses Next.js, React for the front end, and Node.js (Next.js edge functions) for the back end.
|
||||
|
||||
### Add Authentication
|
||||
|
||||
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.
|
||||
|
||||
- [ ] Modify `src/data.ts` to alter default personas
|
||||
|
||||
### Change the UI
|
||||
|
||||
Adapt the UI to match your project's aesthetic, incorporate new features, or exclude unnecessary ones.
|
||||
|
||||
- [ ] Adjust `src/common/app.theme.ts` for theme changes: colors, spacing, button appearance, animations, etc
|
||||
- [ ] Modify `src/common/app.config.tsx` to alter the application's name
|
||||
- [ ] Update `src/common/app.nav.tsx` to revise the navigation bar
|
||||
|
||||
## Testing & Deployment
|
||||
|
||||
Test your application thoroughly using local development (refer to README.md for local build instructions). Deploy using your preferred hosting service. big-AGI supports deployment on platforms like Vercel, Docker, or any Node.js-compatible service, especially those supporting NextJS's "Edge Runtime."
|
||||
|
||||
- [deploy-cloudflare.md](deploy-cloudflare.md): for Cloudflare Workers deployment
|
||||
- [deploy-docker.md](deploy-docker.md): for Docker deployment instructions and examples
|
||||
|
||||
## Debugging
|
||||
|
||||
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/>
|
||||
|
||||
## 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) |
|
||||
|
||||
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/>
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Stay Updated**: Frequently merge updates from the main big-AGI repository to incorporate bug fixes and new features.
|
||||
- **Keep It Open Source**: Consider maintaining your derivative as open source to foster community contributions.
|
||||
- **Engage with the Community**: Leverage platforms like GitHub, Discord, or Reddit for feedback, collaboration, and project promotion.
|
||||
|
||||
Developing a derivative application is an opportunity to explore new possibilities with AI and share your innovations with the global community. We look forward to seeing your contributions.
|
||||
@@ -0,0 +1,63 @@
|
||||
# big-AGI Analytics
|
||||
|
||||
The open-source big-AGI project provides support for the following analytics services:
|
||||
|
||||
- **Vercel Analytics**: automatic when deployed to Vercel
|
||||
- **Google Analytics 4**: manual setup required
|
||||
|
||||
The following is a quick overview of the Analytics options for the deployers of this open-source project.
|
||||
big-AGI is deployed to many large-scale and enterprise though various ways (custom builds, Docker, Vercel, Cloudflare, etc.),
|
||||
and this guide is for its customization.
|
||||
|
||||
## Service Configuration
|
||||
|
||||
### Vercel Analytics
|
||||
|
||||
- Why: understand coarse traction, and identify deployment issues - all without tracking individual users
|
||||
- What: top pages, top referrers, country of origin, operating system, browser, and page speed metrics
|
||||
|
||||
Vercel Analytics and Speed Insights are local API endpoints deployed to your domain, so everything stays within your
|
||||
domain. Furthermore, the Vercel Analytics service is privacy-friendly, and does not track individual users.
|
||||
|
||||
This service is avaialble to system administrators when deploying to Vercel. It is automatically enabled when deploying to Vercel.
|
||||
The code that activates Vercel Analytics is located in the `src/pages/_app.tsx` file:
|
||||
|
||||
```tsx
|
||||
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) => <>
|
||||
...
|
||||
{isVercelFromFrontend && <VercelAnalytics debug={false} />}
|
||||
{isVercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
|
||||
...
|
||||
</>;
|
||||
```
|
||||
|
||||
When big-AGI is served on Vercel hosts, the ```process.env.NEXT_PUBLIC_VERCEL_URL``` environment variable is trueish, and
|
||||
analytics will be sent by default to the Vercel Analytics service which is deployed by Vercel IF configured from the
|
||||
Vercel project dashboard.
|
||||
|
||||
In summary: to turn it on: activate the `Analytics` service in the Vercel project dashboard.
|
||||
|
||||
### Google Analytics 4
|
||||
|
||||
- Why: user engagement and retention, performance insights, personalization, content optimization
|
||||
- What: https://support.google.com/analytics/answer/11593727
|
||||
|
||||
Google Analytics 4 (GA4) is a powerful tool for understanding user behavior and engagement.
|
||||
This can help optimize big-AGI, understanding which features are needed/users and which aren't.
|
||||
|
||||
To enable Google Analytics 4, you need to set the `NEXT_PUBLIC_GA4_MEASUREMENT_ID` environment variable
|
||||
before starting the local build or the docker build (i.e. at build time), at which point the
|
||||
server/container will be able to report analytics to your Google Analytics 4 property.
|
||||
|
||||
As of Feb 27, 2024, this feature is in development.
|
||||
|
||||
## Configurations
|
||||
|
||||
| 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. |
|
||||
| [big-agi.com](https://big-agi.com) | Vercel + Google | The main website ([privacy policy](https://big-agi.com/privacy)) hosted for free for anyone. |
|
||||
| [official Docker packages](https://github.com/enricoros/big-AGI/pkgs/container/big-agi) | Google Analytics | **Vercel**: n/a · **Google Analytics**: set to the big-agi.com Google Analytics for analytics and improvements. |
|
||||
|
||||
Note: this information is updated as of Feb 27, 2024 and can change at any time.
|
||||
@@ -9,31 +9,33 @@ This guide outlines the database options and setup steps for enabling features l
|
||||
- Available on Vercel, Neon, and other platforms.
|
||||
- Less feature-rich but a suitable option depending on your needs.
|
||||
- **Connection String:** Replace placeholders with your Postgres credentials.
|
||||
- `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15`
|
||||
- `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15`
|
||||
|
||||
**2. MongoDB Atlas (alternative):**
|
||||
|
||||
- **Highly Recommended:** More than a database, it's a data platform. MongoDB Atlas is a robust cloud-based platform that offers scalability, security, and a suite of developer tools. No need for a separate vector database, you can query your vector embeddings right within your operational database!
|
||||
- **Additional Features:** MongoDB Atlas is packed with unique features designed to streamline the development process such as: Atlas App Services, Atlas search (with vector search), Atlas charts, Data Federation, and more.
|
||||
- **Highly Recommended:** More than a database, it's a data platform. MongoDB Atlas is a robust cloud-based platform that offers scalability, security, and a suite of developer tools. No need for a separate vector database, you can query your vector embeddings right within your operational database!
|
||||
- **Additional Features:** MongoDB Atlas is packed with unique features designed to streamline the development process such as: Atlas App Services, Atlas search (with vector search), Atlas charts, Data Federation, and more.
|
||||
- **Connection String:** Replace placeholders with your Atlas credentials.
|
||||
- `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority`
|
||||
- `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority`
|
||||
|
||||
### Environment Variables:
|
||||
|
||||
#### Postgres:
|
||||
| Variable | |
|
||||
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `POSTGRES_PRISMA_URL` | `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
|
||||
| `POSTGRES_URL_NON_POOLING` (optional) | URL for the Postgres database without pooling (specific use cases) |
|
||||
|
||||
| Variable | |
|
||||
|---------------------------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `POSTGRES_PRISMA_URL` | `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
|
||||
| `POSTGRES_URL_NON_POOLING` (optional) | URL for the Postgres database without pooling (specific use cases) |
|
||||
|
||||
#### MongoDB:
|
||||
| Variable | |
|
||||
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `MDB_URI` | `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority` |
|
||||
|
||||
| Variable | |
|
||||
|-----------|------------------------------------------------------------------------------------------|
|
||||
| `MDB_URI` | `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority` |
|
||||
|
||||
### MongoDB Atlas + Prisma
|
||||
When using MongoDB Atlas, you'll need to make the below changes to the file `prisma.schema`
|
||||
|
||||
When using MongoDB Atlas, you'll need to make the below changes to the file [`src/server/prisma/schema.prisma`](../src/server/prisma/schema.prisma).
|
||||
|
||||
```
|
||||
...
|
||||
@@ -53,8 +55,7 @@ model LinkStorage {
|
||||
|
||||
### Initial Setup Steps:
|
||||
|
||||
1. **Run `npx prisma db:push`:** Create or update the database schema (run once after connecting).
|
||||
|
||||
1. **Run `npx prisma db push`:** Create or update the database schema (run once after connecting).
|
||||
|
||||
### Additional Resources:
|
||||
|
||||
@@ -50,7 +50,7 @@ docker-compose up -d
|
||||
### Make Local Services Visible to Docker 🌐
|
||||
|
||||
To make local services running on your host machine accessible to a Docker container, such as a
|
||||
[Browseless](./config-browse.md) service or a local API, you can follow this simplified guide:
|
||||
[Browseless](./config-feature-browse.md) service or a local API, you can follow this simplified guide:
|
||||
|
||||
| Operating System | Steps to Make Local Services Visible to Docker |
|
||||
|:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Why big-AGI?
|
||||
Placeholder for a document that demonstrates the productivity and unique features of Big-AGI.
|
||||
|
||||
## Exclusive features
|
||||
- [x] Call AGI
|
||||
- [x] Continuous Voice mode
|
||||
- [x] Diagram generation
|
||||
- [ ] ...
|
||||
|
||||
## Productivity Features
|
||||
- [x] Multi-window to never wait
|
||||
- [x] Multi-Chat to explore different solutions
|
||||
- [x] Rendering of graphs, charts, mindmaps
|
||||
- [ ] ...
|
||||
@@ -28,9 +28,13 @@ AZURE_OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_API_HOST=
|
||||
GEMINI_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
LOCALAI_API_HOST=
|
||||
LOCALAI_API_KEY=
|
||||
MISTRAL_API_KEY=
|
||||
OLLAMA_API_HOST=
|
||||
OPENROUTER_API_KEY=
|
||||
PERPLEXITY_API_KEY=
|
||||
TOGETHERAI_API_KEY=
|
||||
|
||||
# Model Observability: Helicone
|
||||
@@ -54,15 +58,22 @@ BACKEND_ANALYTICS=
|
||||
# Backend HTTP Basic Authentication (see `deploy-authentication.md` for turning on authentication)
|
||||
HTTP_BASIC_AUTH_USERNAME=
|
||||
HTTP_BASIC_AUTH_PASSWORD=
|
||||
|
||||
# Frontend variables
|
||||
NEXT_PUBLIC_GA4_MEASUREMENT_ID=
|
||||
NEXT_PUBLIC_PLANTUML_SERVER_URL=
|
||||
```
|
||||
|
||||
## Variables Documentation
|
||||
## Backend Variables
|
||||
|
||||
These variables are used only by the server-side code, at runtime. Define them before running the nextjs local server (in development or
|
||||
cloud deployment), or pass them to Docker (--env-file or -e) when starting the container.
|
||||
|
||||
### Database
|
||||
|
||||
For Database configuration see [config-database.md](config-database.md).
|
||||
To enable Chat Link Sharing, you need to connect the backend to a database. We currently support Postgres and MongoDB.
|
||||
|
||||
To enable features such as Chat Link Sharing, you need to connect the backend to a database. We currently support Postgres and MongoDB.
|
||||
For Database configuration see [deploy-database.md](deploy-database.md).
|
||||
|
||||
### LLMs
|
||||
|
||||
@@ -79,12 +90,16 @@ requiring the user to enter an API key
|
||||
| `ANTHROPIC_API_KEY` | The API key for Anthropic | Optional |
|
||||
| `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_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-ollama.md](config-ollama.md) | |
|
||||
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-local-ollama.md](config-local-ollama) | |
|
||||
| `OPENROUTER_API_KEY` | The API key for OpenRouter | Optional |
|
||||
| `PERPLEXITY_API_KEY` | The API key for Perplexity | Optional |
|
||||
| `TOGETHERAI_API_KEY` | The API key for Together AI | Optional |
|
||||
|
||||
### Model Observability: Helicone
|
||||
### LLM Observability: Helicone
|
||||
|
||||
Helicone provides observability to your LLM calls. It is a paid service, with a generous free tier.
|
||||
It is currently supported for:
|
||||
@@ -96,7 +111,7 @@ It is currently supported for:
|
||||
|--------------------|--------------------------|
|
||||
| `HELICONE_API_KEY` | The API key for Helicone |
|
||||
|
||||
### Specials
|
||||
### Features
|
||||
|
||||
Enable the app to Talk, Draw, and Google things up.
|
||||
|
||||
@@ -106,16 +121,31 @@ Enable the app to Talk, Draw, and Google things up.
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
|
||||
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
|
||||
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
|
||||
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
|
||||
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
|
||||
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
|
||||
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
|
||||
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
|
||||
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
|
||||
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
|
||||
| **Browse** | |
|
||||
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing, etc. |
|
||||
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing (pade downloadeing), etc. |
|
||||
| **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. |
|
||||
|
||||
### Frontend Variables
|
||||
|
||||
The value of these variables are passed to the frontend (Web UI) - make sure they do not contain secrets.
|
||||
|
||||
| Variable | Description |
|
||||
|:----------------------------------|:-----------------------------------------------------------------------------------------|
|
||||
| `NEXT_PUBLIC_GA4_MEASUREMENT_ID` | The measurement ID for Google Analytics 4. (see [deploy-analytics](deploy-analytics.md)) |
|
||||
| `NEXT_PUBLIC_PLANTUML_SERVER_URL` | The URL of the PlantUML server, used for rendering UML diagrams. (code in RederCode.tsx) |
|
||||
|
||||
> Important: these variables must be set at build time, which is required by Next.js to pass them to the frontend.
|
||||
> This is in contrast to the backend variables, which can be set when starting the local server/container.
|
||||
|
||||
---
|
||||
|
||||
For a higher level overview of backend code and environment customization,
|
||||
see the [big-AGI Customization](customizations.md) guide.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
+30
-6
@@ -1,13 +1,26 @@
|
||||
// Non-default build types
|
||||
const buildType =
|
||||
process.env.BIG_AGI_BUILD === 'standalone' ? 'standalone'
|
||||
: process.env.BIG_AGI_BUILD === 'static' ? 'export'
|
||||
: undefined;
|
||||
|
||||
buildType && console.log(` 🧠 big-AGI: building for ${buildType}...\n`);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
let nextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
// Note: disabled to chech whether the project becomes slower with this
|
||||
// modularizeImports: {
|
||||
// '@mui/icons-material': {
|
||||
// transform: '@mui/icons-material/{{member}}',
|
||||
// },
|
||||
// },
|
||||
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
|
||||
...buildType && {
|
||||
output: buildType,
|
||||
distDir: 'dist',
|
||||
|
||||
// disable image optimization for exports
|
||||
images: { unoptimized: true },
|
||||
|
||||
// Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html`
|
||||
// trailingSlash: true,
|
||||
},
|
||||
|
||||
// [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052
|
||||
experimental: {
|
||||
@@ -24,9 +37,20 @@ let nextConfig = {
|
||||
layers: 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)
|
||||
config.optimization.splitChunks.minSize = 40 * 1024;
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
// Note: disabled to check whether the project becomes slower with this
|
||||
// modularizeImports: {
|
||||
// '@mui/icons-material': {
|
||||
// transform: '@mui/icons-material/{{member}}',
|
||||
// },
|
||||
// },
|
||||
|
||||
// Uncomment the following leave console messages in production
|
||||
// compiler: {
|
||||
// removeConsole: false,
|
||||
|
||||
Generated
+1271
-626
File diff suppressed because it is too large
Load Diff
+30
-23
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.13.0",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -12,28 +14,32 @@
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/server/prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.8",
|
||||
"@mui/joy": "5.0.0-beta.25",
|
||||
"@next/bundle-analyzer": "^14.1.0",
|
||||
"@prisma/client": "^5.9.1",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@mui/joy": "^5.0.0-beta.32",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@next/third-parties": "^14.1.4",
|
||||
"@prisma/client": "^5.11.0",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@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.1.3",
|
||||
"@vercel/speed-insights": "^1.0.9",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"@vercel/speed-insights": "^1.0.10",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"eventsource-parser": "^1.1.1",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"next": "^14.1.0",
|
||||
"next": "^14.1.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "4.0.379",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
@@ -44,35 +50,36 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-player": "^2.14.1",
|
||||
"react-resizable-panels": "^2.0.3",
|
||||
"react-player": "^2.15.1",
|
||||
"react-resizable-panels": "^2.0.13",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sharp": "^0.33.2",
|
||||
"superjson": "^2.2.1",
|
||||
"tesseract.js": "^5.0.4",
|
||||
"tesseract.js": "^5.0.5",
|
||||
"tiktoken": "^1.0.13",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.0"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.11.16",
|
||||
"@cloudflare/puppeteer": "0.0.5",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.9.1",
|
||||
"typescript": "^5.3.3"
|
||||
"prisma": "^5.11.0",
|
||||
"typescript": "^5.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
|
||||
+17
-14
@@ -1,10 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { MyAppProps } from 'next/app';
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/next';
|
||||
import { SpeedInsights as VercelSpeedInsights } from '@vercel/speed-insights/next';
|
||||
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
|
||||
@@ -14,12 +13,14 @@ import '~/common/styles/GithubMarkdown.css';
|
||||
import '~/common/styles/NProgress.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';
|
||||
|
||||
|
||||
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
@@ -32,20 +33,22 @@ 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>
|
||||
|
||||
<VercelAnalytics debug={false} />
|
||||
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
|
||||
{isVercelFromFrontend && <VercelAnalytics debug={false} />}
|
||||
{isVercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
|
||||
{hasGoogleAnalytics && <OptionalGoogleAnalytics />}
|
||||
|
||||
</>;
|
||||
|
||||
|
||||
+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 />);
|
||||
}
|
||||
+1
-4
@@ -1,16 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppChat } from '../src/apps/chat/AppChat';
|
||||
import { useRedirectToNewsOnUpdates } from '../src/apps/news/news.hooks';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function IndexPage() {
|
||||
// show the News page if there are unseen updates
|
||||
useRedirectToNewsOnUpdates();
|
||||
|
||||
// TODO: This Index page will point to the Dashboard (or a landing page) soon
|
||||
// TODO: This Index page will point to the Dashboard (or a landing page)
|
||||
// For now it offers the chat experience, but this will change. #299
|
||||
|
||||
return withLayout({ type: 'optima' }, <AppChat />);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as React from 'react';
|
||||
import { fileSave } from 'browser-fs-access';
|
||||
|
||||
import { Box, Button, Card, CardContent, Typography } from '@mui/joy';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { AppPlaceholder } from '../../src/apps/AppPlaceholder';
|
||||
|
||||
import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities';
|
||||
import { getPlantUmlServerUrl } from '~/modules/blocks/code/RenderCode';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
// app config
|
||||
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';
|
||||
|
||||
// capabilities access
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities';
|
||||
|
||||
// stores access
|
||||
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useChatStore } from '~/common/state/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 { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
|
||||
|
||||
function DebugCard(props: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography level='title-lg'>
|
||||
{props.title}
|
||||
</Typography>
|
||||
{props.children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function prettifyJsonString(jsonString: string, deleteChars: number, removeDoubleQuotes: boolean, removeTrailComma: boolean): string {
|
||||
return jsonString.split('\n').map(l => {
|
||||
if (deleteChars > 0)
|
||||
l = l.substring(deleteChars);
|
||||
if (removeDoubleQuotes)
|
||||
l = l.replaceAll('\"', '');
|
||||
if (removeTrailComma && l.endsWith(','))
|
||||
l = l.substring(0, l.length - 1);
|
||||
return l;
|
||||
}).join('\n').trim();
|
||||
}
|
||||
|
||||
function DebugJsonCard(props: { title: string, data: any }) {
|
||||
return (
|
||||
<DebugCard title={props.title}>
|
||||
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces', fontFamily: 'code', fontSize: { xs: 'xs' } }}>
|
||||
{prettifyJsonString(JSON.stringify(props.data, null, 2), 2, true, true)}
|
||||
</Typography>
|
||||
</DebugCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AppDebug() {
|
||||
|
||||
// state
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
|
||||
// external state
|
||||
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();
|
||||
|
||||
|
||||
// derived state
|
||||
const cClient = {
|
||||
// isBrowser,
|
||||
isChromeDesktop,
|
||||
isFirefox,
|
||||
isIPhone: isIPhoneUser,
|
||||
isMac: isMacUser,
|
||||
isPWA: isPwa(),
|
||||
supportsClipboardPaste: supportsClipboardRead,
|
||||
supportsScreenCapture,
|
||||
};
|
||||
const cProduct = {
|
||||
capabilities: {
|
||||
mic: useCapabilityBrowserSpeechRecognition(),
|
||||
elevenLabs: useCapabilityElevenLabs(),
|
||||
textToImage: useCapabilityTextToImage(),
|
||||
},
|
||||
models: getLLMsDebugInfo(),
|
||||
state: {
|
||||
chatsCount,
|
||||
foldersCount: folders?.length,
|
||||
foldersEnabled: enableFolders,
|
||||
newsCurrent: incrementalNewsVersion,
|
||||
newsSeen: lastSeenNewsVersion,
|
||||
labsActive: uxLabsExperiments,
|
||||
reloads: usageCount,
|
||||
},
|
||||
};
|
||||
const cBackend = {
|
||||
configuration: backendCaps,
|
||||
deployment: {
|
||||
home: Brand.URIs.Home,
|
||||
hostName: clientHostName(),
|
||||
isVercelFromFrontend,
|
||||
measurementId: getGA4MeasurementId(),
|
||||
plantUmlServerUrl: getPlantUmlServerUrl(),
|
||||
routeIndex: ROUTE_INDEX,
|
||||
routeChat: ROUTE_APP_CHAT,
|
||||
},
|
||||
};
|
||||
|
||||
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'] },
|
||||
)
|
||||
.then(() => setSaved(true))
|
||||
.catch(e => console.error('Error saving debug.json', e));
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPlaceholder title={`${Brand.Title.Common} Debug`}>
|
||||
<Box sx={{ display: 'grid', gap: 3, my: 3 }}>
|
||||
<Button
|
||||
variant={saved ? 'soft' : 'outlined'} color={saved ? 'success' : 'neutral'}
|
||||
onClick={handleDownload}
|
||||
endDecorator={<DownloadIcon />}
|
||||
sx={{
|
||||
backgroundColor: saved ? undefined : 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
placeSelf: 'start',
|
||||
minWidth: 260,
|
||||
}}
|
||||
>
|
||||
Download debug JSON
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'grid', gap: 3 }}>
|
||||
<DebugJsonCard title='Client' data={cClient} />
|
||||
<DebugJsonCard title='AGI' data={cProduct} />
|
||||
<DebugJsonCard title='Backend' data={cBackend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</AppPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function DebugPage() {
|
||||
return withLayout({ type: 'plain' }, <AppDebug />);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppLinkChat } from '../../../src/apps/link/AppLinkChat';
|
||||
import { AppLinkChat } from '../../../src/apps/link-chat/AppLinkChat';
|
||||
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
+2
-2
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppNews } from '../src/apps/news/AppNews';
|
||||
import { useMarkNewsAsSeen } from '../src/apps/news/news.hooks';
|
||||
import { markNewsAsSeen } from '../src/apps/news/news.version';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function NewsPage() {
|
||||
// 'touch' the last seen news version
|
||||
useMarkNewsAsSeen();
|
||||
React.useEffect(() => markNewsAsSeen(), []);
|
||||
|
||||
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppNews />);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
+25
-15
@@ -9,13 +9,17 @@ import { useRouterRoute } from '~/common/app.routes';
|
||||
/**
|
||||
* https://github.com/enricoros/big-AGI/issues/299
|
||||
*/
|
||||
export function AppPlaceholder(props: { text?: string }) {
|
||||
export function AppPlaceholder(props: {
|
||||
title?: string | null,
|
||||
text?: React.ReactNode,
|
||||
children?: React.ReactNode,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const route = useRouterRoute();
|
||||
|
||||
// derived state
|
||||
const placeholderAppName = capitalizeFirstLetter(route.replace('/', '') || 'Home');
|
||||
const placeholderAppName = props.title || capitalizeFirstLetter(route.replace('/', '') || 'Home');
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
@@ -25,21 +29,27 @@ export function AppPlaceholder(props: { text?: string }) {
|
||||
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>
|
||||
<Typography>
|
||||
{props.text || 'Intelligent applications to help you learn, think, and do'}
|
||||
</Typography>
|
||||
<Typography level='h1'>
|
||||
{placeholderAppName}
|
||||
</Typography>
|
||||
{!!props.text && (
|
||||
<Typography>
|
||||
{props.text}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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, createDMessage, DConversation, DMessage } from '~/common/state/store-chats';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
|
||||
function initTestConversation(): DConversation {
|
||||
const conversation = createDConversation();
|
||||
conversation.messages.push(createDMessage('system', 'You are a helpful assistant.'));
|
||||
conversation.messages.push(createDMessage('user', 'Hello, who are you? (please expand...)'));
|
||||
return conversation;
|
||||
}
|
||||
|
||||
function initTestBeamStore(messages: DMessage[], beamStore: BeamStoreApi = createBeamVanillaStore()): BeamStoreApi {
|
||||
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (text) => alert(text));
|
||||
return beamStore;
|
||||
}
|
||||
|
||||
|
||||
export function AppBeam() {
|
||||
|
||||
// state
|
||||
const [showDebug, setShowDebug] = React.useState(false);
|
||||
const conversation = React.useRef<DConversation>(initTestConversation());
|
||||
const beamStoreApi = React.useRef(initTestBeamStore(conversation.current.messages)).current;
|
||||
|
||||
// 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().terminate();
|
||||
}, [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={() => {
|
||||
conversation.current = initTestConversation();
|
||||
initTestBeamStore(conversation.current.messages, beamStoreApi);
|
||||
}}>
|
||||
.open
|
||||
</Button>
|
||||
|
||||
{/* 'close' */}
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={handleClose}>
|
||||
.close
|
||||
</Button>
|
||||
</>, [beamStoreApi, 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>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,8 @@ export function AppCall() {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: hasIntent ? 'space-evenly' : undefined,
|
||||
gap: hasIntent ? 1 : undefined,
|
||||
// shall force the contacts or telephone to stay within the container
|
||||
overflowY: hasIntent ? 'hidden' : undefined,
|
||||
}}>
|
||||
|
||||
{!hasIntent ? (
|
||||
|
||||
@@ -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 CloseIcon from '@mui/icons-material/Close';
|
||||
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 WarningIcon from '@mui/icons-material/Warning';
|
||||
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 { 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 ? <WarningIcon color='warning' /> : <CheckIcon color='success' />}
|
||||
{props.hasIssue ? <WarningRoundedIcon color='warning' /> : <CheckRoundedIcon color='success' />}
|
||||
</ListItemDecorator>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -122,9 +84,9 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
<Box sx={{ flexGrow: 0.5 }} />
|
||||
|
||||
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, textAlign: 'center' }}>
|
||||
<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' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
|
||||
{allGood ? <ArrowForwardRoundedIcon sx={{ fontSize: '1.5em' }} /> : <CloseRoundedIcon sx={{ fontSize: '1.5em' }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
|
||||
import { animationShadowRingLimey } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
@@ -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'
|
||||
@@ -125,7 +104,6 @@ function CallContactCard(props: {
|
||||
sx={{
|
||||
mx: 'auto',
|
||||
mt: '-2.5rem',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-22
@@ -7,10 +7,10 @@ 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 { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
|
||||
|
||||
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
@@ -57,7 +57,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>
|
||||
@@ -224,8 +224,9 @@ export function Telephone(props: {
|
||||
responseAbortController.current = new AbortController();
|
||||
let finalText = '';
|
||||
let error: any | null = null;
|
||||
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, (updatedMessage: Partial<DMessage>) => {
|
||||
const text = updatedMessage.text?.trim();
|
||||
setPersonaTextInterim('💭...');
|
||||
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
|
||||
const text = textSoFar?.trim();
|
||||
if (text) {
|
||||
finalText = text;
|
||||
setPersonaTextInterim(text);
|
||||
@@ -330,22 +331,9 @@ 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) =>
|
||||
@@ -354,7 +342,8 @@ export function Telephone(props: {
|
||||
text={message.text}
|
||||
variant={message.role === 'assistant' ? 'solid' : 'soft'}
|
||||
color={message.role === 'assistant' ? 'neutral' : 'primary'}
|
||||
role={message.role} />,
|
||||
role={message.role}
|
||||
/>,
|
||||
)}
|
||||
|
||||
{/* Persona streaming text... */}
|
||||
|
||||
@@ -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` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,13 +12,19 @@ export function CallMessage(props: {
|
||||
role: VChatMessageIn['role'],
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
const isUserMessage = props.role === 'user';
|
||||
return (
|
||||
<Chip
|
||||
color={props.color} variant={props.variant}
|
||||
sx={{
|
||||
alignSelf: props.role === 'user' ? 'end' : 'start',
|
||||
alignSelf: isUserMessage ? 'end' : 'start',
|
||||
whiteSpace: 'break-spaces',
|
||||
borderRadius: 'lg',
|
||||
...(isUserMessage ? {
|
||||
borderBottomRightRadius: 0,
|
||||
} : {
|
||||
borderBottomLeftRadius: 0,
|
||||
}),
|
||||
// boxShadow: 'md',
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
|
||||
+288
-183
@@ -9,47 +9,63 @@ import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
|
||||
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 { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
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, useConversation } from '~/common/state/store-chats';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
|
||||
import { getUXLabsHighPerformance, useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
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 type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatBarAltBeam } from './components/ChatBarAltBeam';
|
||||
import { ChatBarAltTitle } from './components/ChatBarAltTitle';
|
||||
import { ChatBarDropdowns } from './components/ChatBarDropdowns';
|
||||
import { ChatBeamWrapper } from './components/ChatBeamWrapper';
|
||||
import { ChatDrawerMemo } from './components/ChatDrawer';
|
||||
import { ChatDropdowns } from './components/ChatDropdowns';
|
||||
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { Ephemerals } from './components/Ephemerals';
|
||||
import { ScrollToBottom } from './components/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from './components/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { usePanesManager } from './components/panes/usePanesManager';
|
||||
import { getInstantAppChatPanesCount, usePanesManager } from './components/panes/usePanesManager';
|
||||
|
||||
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
|
||||
import { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
|
||||
import { runAssistantUpdatingState } from './editors/chat-stream';
|
||||
import { runBrowseUpdatingState } from './editors/browse-load';
|
||||
import { runBrowseGetPageUpdatingState } from './editors/browse-load';
|
||||
import { runImageGenerationUpdatingState } from './editors/image-generate';
|
||||
import { runReActUpdatingState } from './editors/react-tangent';
|
||||
|
||||
|
||||
// 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';
|
||||
|
||||
|
||||
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
|
||||
export interface AppChatIntent {
|
||||
initialConversationId: string | null;
|
||||
}
|
||||
|
||||
|
||||
export function AppChat() {
|
||||
|
||||
@@ -59,7 +75,7 @@ export function AppChat() {
|
||||
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
|
||||
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
|
||||
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
|
||||
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
|
||||
const [deleteConversationIds, setDeleteConversationIds] = React.useState<DConversationId[] | null>(null);
|
||||
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
|
||||
const showNextTitleChange = React.useRef(false);
|
||||
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
@@ -70,68 +86,94 @@ export function AppChat() {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const intent = useRouterQuery<Partial<AppChatIntent>>();
|
||||
|
||||
const showAltTitleBar = useUXLabsStore(state => DEV_MODE_SETTINGS && state.labsChatBarAlt === 'title');
|
||||
|
||||
const { openLlmOptions } = useOptimaLayout();
|
||||
|
||||
const { chatLLM } = useChatLLM();
|
||||
|
||||
const {
|
||||
// state
|
||||
chatPanes,
|
||||
focusedConversationId,
|
||||
focusedPaneIndex,
|
||||
focusedPaneConversationId,
|
||||
// actions
|
||||
navigateHistoryInFocusedPane,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
focusedPaneIndex,
|
||||
removePane,
|
||||
setFocusedPane,
|
||||
setFocusedPaneIndex,
|
||||
} = usePanesManager();
|
||||
|
||||
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
|
||||
return pane.conversationId ? ConversationsManager.getHandler(pane.conversationId) : null;
|
||||
}), [chatPanes]);
|
||||
|
||||
const beamsStores = React.useMemo(() => chatHandlers.map(handler => {
|
||||
return handler?.getBeamStore() ?? null;
|
||||
}), [chatHandlers]);
|
||||
|
||||
const beamsOpens = useAreBeamsOpen(beamsStores);
|
||||
const beamOpenStoreInFocusedPane = React.useMemo(() => {
|
||||
const open = focusedPaneIndex !== null ? (beamsOpens?.[focusedPaneIndex] ?? false) : false;
|
||||
return open ? beamsStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, beamsStores, focusedPaneIndex]);
|
||||
|
||||
const {
|
||||
// focused
|
||||
title: focusedChatTitle,
|
||||
chatIdx: focusedChatNumber,
|
||||
isNoChat: isNoChat,
|
||||
isChatEmpty: isFocusedChatEmpty,
|
||||
areChatsEmpty,
|
||||
newConversationId,
|
||||
conversationsLength,
|
||||
_remove_systemPurposeId: focusedSystemPurposeId,
|
||||
isEmpty: isFocusedChatEmpty,
|
||||
isDeveloper: isFocusedChatDeveloper,
|
||||
conversationIdx: focusedChatNumber,
|
||||
// all
|
||||
hasConversations,
|
||||
recycleNewConversationId,
|
||||
// actions
|
||||
prependNewConversation,
|
||||
branchConversation,
|
||||
deleteConversation,
|
||||
wipeAllConversations,
|
||||
setMessages,
|
||||
} = useConversation(focusedConversationId);
|
||||
deleteConversations,
|
||||
} = useConversation(focusedPaneConversationId);
|
||||
|
||||
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
|
||||
|
||||
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
|
||||
const { activeFolderId } = useFolderStore(({ enableFolders, folders }) => {
|
||||
const activeFolderId = enableFolders ? _activeFolderId : null;
|
||||
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
|
||||
return {
|
||||
activeFolderId: activeFolder?.id ?? null,
|
||||
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Window actions
|
||||
|
||||
const isMultiPane = chatPanes.length >= 2;
|
||||
const isMultiAddable = chatPanes.length < 4;
|
||||
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
|
||||
const willMulticast = isComposerMulticast && isMultiConversationId;
|
||||
const disableNewButton = isFocusedChatEmpty && !isMultiPane;
|
||||
|
||||
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;
|
||||
@@ -141,89 +183,119 @@ export function AppChat() {
|
||||
}
|
||||
}, [focusedChatNumber, focusedChatTitle]);
|
||||
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
|
||||
// "/command ...": overrides the chat mode
|
||||
// Update the system message from the active persona to the history
|
||||
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
|
||||
// 1. all the callers need to pass a new array
|
||||
// 2. all the exit points need to call setMessages
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
cHandler.inlineUpdatePurposeInHistory(history, chatLLMId);
|
||||
|
||||
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
|
||||
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
|
||||
if (lastMessage?.role === 'user') {
|
||||
const chatCommand = extractChatCommand(lastMessage.text)[0];
|
||||
if (chatCommand && chatCommand.type === 'cmd') {
|
||||
switch (chatCommand.providerId) {
|
||||
case 'ass-browse':
|
||||
setMessages(conversationId, history);
|
||||
return await runBrowseUpdatingState(conversationId, chatCommand.params!);
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'ass-t2i':
|
||||
setMessages(conversationId, history);
|
||||
return await runImageGenerationUpdatingState(conversationId, chatCommand.params!);
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'ass-react':
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
|
||||
|
||||
case 'chat-alter':
|
||||
// /clear
|
||||
if (chatCommand.command === '/clear') {
|
||||
if (chatCommand.params === 'all')
|
||||
return 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]);
|
||||
return cHandler.messagesReplace([]);
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: this command requires the \'all\' parameter to confirm the operation.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
// /assistant, /system
|
||||
Object.assign(lastMessage, {
|
||||
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
|
||||
sender: 'Bot',
|
||||
text: chatCommand.params || '',
|
||||
} satisfies Partial<DMessage>);
|
||||
return setMessages(conversationId, history);
|
||||
return cHandler.messagesReplace(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]);
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Available Chat Commands:\n' + chatCommandsText, undefined, 'help', false);
|
||||
return;
|
||||
|
||||
case 'mode-beam':
|
||||
if (chatCommand.isError)
|
||||
return cHandler.messagesReplace(history);
|
||||
// remove '/beam ', as we want to be a user chat message
|
||||
Object.assign(lastMessage, { text: chatCommand.params || '' });
|
||||
cHandler.messagesReplace(history);
|
||||
return ConversationsManager.getHandler(conversationId).beamInvoke(history, [], null);
|
||||
|
||||
default:
|
||||
return cHandler.messagesReplace([...history, createDMessage('assistant', 'This command is not supported.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
if (!getConversationSystemPurposeId(conversationId)) {
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: no Persona selected.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
if (chatLLMId && focusedSystemPurposeId) {
|
||||
switch (chatModeId) {
|
||||
case 'generate-text':
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
|
||||
switch (chatModeId) {
|
||||
case 'generate-text':
|
||||
cHandler.messagesReplace(history);
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'append-user':
|
||||
return setMessages(conversationId, history);
|
||||
case 'generate-text-beam':
|
||||
cHandler.messagesReplace(history);
|
||||
return cHandler.beamInvoke(history, [], null);
|
||||
|
||||
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 'append-user':
|
||||
return cHandler.messagesReplace(history);
|
||||
|
||||
case 'generate-react':
|
||||
if (!lastMessage?.text)
|
||||
break;
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, lastMessage.text, chatLLMId);
|
||||
}
|
||||
case 'generate-image':
|
||||
if (!lastMessage?.text) break;
|
||||
// also add a 'fake' user message with the '/draw' command
|
||||
cHandler.messagesReplace(history.map(message => (message.id !== lastMessage.id) ? message : {
|
||||
...message,
|
||||
text: `/draw ${lastMessage.text}`,
|
||||
}));
|
||||
return await runImageGenerationUpdatingState(cHandler, lastMessage.text);
|
||||
|
||||
case 'generate-react':
|
||||
if (!lastMessage?.text) break;
|
||||
cHandler.messagesReplace(history);
|
||||
return await runReActUpdatingState(cHandler, lastMessage.text, chatLLMId);
|
||||
}
|
||||
|
||||
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
|
||||
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
|
||||
setMessages(conversationId, history);
|
||||
}, [focusedSystemPurposeId, setMessages]);
|
||||
console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage);
|
||||
cHandler.messagesReplace(history);
|
||||
}, []);
|
||||
|
||||
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
|
||||
const handleComposerAction = React.useCallback((chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
|
||||
// validate inputs
|
||||
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
|
||||
addSnackbar({
|
||||
@@ -249,30 +321,37 @@ export function AppChat() {
|
||||
const _conversation = getConversation(_cId);
|
||||
if (_conversation) {
|
||||
// start execution fire/forget
|
||||
void _handleExecute(chatModeId, _cId, [
|
||||
..._conversation.messages,
|
||||
createDMessage('user', userText),
|
||||
]);
|
||||
void _handleExecute(chatModeId, _cId, [..._conversation.messages, createDMessage('user', userText)]);
|
||||
enqueued = true;
|
||||
}
|
||||
}
|
||||
return enqueued;
|
||||
};
|
||||
}, [chatPanes, willMulticast, _handleExecute]);
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
await _handleExecute('generate-text', conversationId, history);
|
||||
}, [_handleExecute]);
|
||||
|
||||
const handleMessageRegenerateLast = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedConversationId);
|
||||
const handleMessageRegenerateLastInFocusedPane = React.useCallback(async () => {
|
||||
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],
|
||||
);
|
||||
const history = lastMessage.role === 'assistant' ? focusedConversation.messages.slice(0, -1) : [...focusedConversation.messages];
|
||||
return await _handleExecute('generate-text', focusedConversation.id, history);
|
||||
}
|
||||
}, [focusedConversationId, _handleExecute]);
|
||||
}, [_handleExecute, focusedPaneConversationId]);
|
||||
|
||||
const handleMessageBeamLastInFocusedPane = React.useCallback(async () => {
|
||||
// Ctrl + Shift + B
|
||||
const focusedConversation = getConversation(focusedPaneConversationId);
|
||||
if (focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
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);
|
||||
}
|
||||
}, [focusedPaneConversationId]);
|
||||
|
||||
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
|
||||
|
||||
@@ -291,15 +370,18 @@ export function AppChat() {
|
||||
await speakText(text);
|
||||
}, []);
|
||||
|
||||
|
||||
// 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(focusedSystemPurposeId ?? 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)
|
||||
@@ -308,7 +390,7 @@ export function AppChat() {
|
||||
// focus the composer
|
||||
composerTextAreaRef.current?.focus();
|
||||
|
||||
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
}, [activeFolderId, focusedPaneConversationId, handleOpenConversationInFocusedPane, prependNewConversation, recycleNewConversationId]);
|
||||
|
||||
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
|
||||
|
||||
@@ -326,43 +408,42 @@ 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).messagesReplace([]);
|
||||
setClearConversationId(null);
|
||||
}
|
||||
}, [clearConversationId, setMessages]);
|
||||
}, [clearConversationId]);
|
||||
|
||||
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
|
||||
|
||||
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
// show dialog if not bypassed
|
||||
const handleDeleteConversations = React.useCallback((conversationIds: DConversationId[], bypassConfirmation: boolean) => {
|
||||
if (!bypassConfirmation)
|
||||
return setDeleteConversationId(conversationId);
|
||||
return setDeleteConversationIds(conversationIds);
|
||||
|
||||
const nextConversationId = conversationId === SPECIAL_ID_WIPE_ALL
|
||||
? wipeAllConversations(activeFolderId /* restricted to this folder (or null for all) */, /*focusedSystemPurposeId ??*/ undefined)
|
||||
: deleteConversation(conversationId, /*focusedSystemPurposeId ??*/ undefined);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
// perform deletion, and return the next (or a new) conversation
|
||||
const nextConversationId = deleteConversations(conversationIds, /*focusedSystemPurposeId ??*/ undefined);
|
||||
|
||||
setDeleteConversationId(null);
|
||||
}, [activeFolderId, deleteConversation, setFocusedConversationId, wipeAllConversations]);
|
||||
// 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, handleOpenConversationInFocusedPane]);
|
||||
|
||||
const handleConfirmedDeleteConversations = React.useCallback(() => {
|
||||
!!deleteConversationIds?.length && handleDeleteConversations(deleteConversationIds, true);
|
||||
}, [deleteConversationIds, handleDeleteConversations]);
|
||||
|
||||
const handleConfirmedDeleteConversation = React.useCallback(() => {
|
||||
deleteConversationId && handleConversationDelete(deleteConversationId, true);
|
||||
}, [deleteConversationId, handleConversationDelete]);
|
||||
|
||||
// Shortcuts
|
||||
|
||||
@@ -373,60 +454,71 @@ export function AppChat() {
|
||||
}, [openLlmOptions]);
|
||||
|
||||
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
|
||||
// focused conversation
|
||||
['b', true, true, false, handleMessageBeamLastInFocusedPane],
|
||||
['r', true, true, false, handleMessageRegenerateLastInFocusedPane],
|
||||
['n', true, false, true, handleConversationNewInFocusedPane],
|
||||
['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 && handleConversationDelete(focusedConversationId, false)],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
['+', true, true, false, useUIPreferencesStore.getState().increaseContentScaling],
|
||||
['-', true, true, false, useUIPreferencesStore.getState().decreaseContentScaling],
|
||||
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
|
||||
const centerItems = React.useMemo(() =>
|
||||
<ChatDropdowns
|
||||
conversationId={focusedConversationId}
|
||||
/>,
|
||||
[focusedConversationId],
|
||||
// Pluggable Optima components
|
||||
|
||||
const barAltTitle = showAltTitleBar ? focusedChatTitle ?? 'No Chat' : null;
|
||||
|
||||
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
|
||||
activeConversationId={focusedConversationId}
|
||||
isMobile={isMobile}
|
||||
activeConversationId={focusedPaneConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
|
||||
disableNewButton={isFocusedChatEmpty && !isNoChat}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationExportDialog={handleConversationExport}
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
disableNewButton={disableNewButton}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationNew={handleConversationNewInFocusedPane}
|
||||
onConversationsDelete={handleDeleteConversations}
|
||||
onConversationsExportDialog={handleConversationExport}
|
||||
onConversationsImportDialog={handleConversationImportDialog}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[activeFolderId, chatPanes, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, isNoChat, setFocusedConversationId],
|
||||
[activeFolderId, chatPanes, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile],
|
||||
);
|
||||
|
||||
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, centerItems, menuItems, 'AppChat');
|
||||
usePluggableOptimaLayout(drawerContent, focusedBarContent, focusedMenuItems, 'AppChat');
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
@@ -436,10 +528,14 @@ export function AppChat() {
|
||||
>
|
||||
|
||||
{chatPanes.map((pane, idx) => {
|
||||
const _paneIsFocused = idx === focusedPaneIndex;
|
||||
const _paneConversationId = pane.conversationId;
|
||||
const _paneChatHandler = chatHandlers[idx] ?? null;
|
||||
const _paneChatBeamStore = beamsStores[idx] ?? null;
|
||||
const _paneChatBeamIsOpen = !!beamsOpens?.[idx];
|
||||
const _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
|
||||
@@ -450,7 +546,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
|
||||
@@ -463,33 +559,37 @@ 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
|
||||
// disables further interaction with the chat. This is a workaround to re-enable the pointer events.
|
||||
// The root cause seems to be a Dragstate not being reset properly, however the pointerEvents has been set since 0.0.56 while
|
||||
// it was optional before: https://github.com/bvaughn/react-resizable-panels/issues/241
|
||||
pointerEvents: 'auto',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottom
|
||||
sx={{
|
||||
// allows the content to be scrolled (all browsers)
|
||||
overflowY: 'auto',
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
}}
|
||||
stickToBottomInitial
|
||||
sx={_paneChatBeamIsOpen ? { display: 'none' } : undefined}
|
||||
>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={_paneConversationId}
|
||||
conversationHandler={_paneChatHandler}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
|
||||
fitScreen={isMobile || isMultiPane}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
isMobile={isMobile}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
@@ -501,20 +601,26 @@ export function AppChat() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Ephemerals
|
||||
conversationId={_paneConversationId}
|
||||
sx={{
|
||||
// TODO: Fixme post panels?
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}}
|
||||
/>
|
||||
{/*<Ephemerals*/}
|
||||
{/* conversationId={_paneConversationId}*/}
|
||||
{/* sx={{*/}
|
||||
{/* // TODO: Fixme post panels?*/}
|
||||
{/* // flexGrow: 0.1,*/}
|
||||
{/* flexShrink: 0.5,*/}
|
||||
{/* overflowY: 'auto',*/}
|
||||
{/* minHeight: 64,*/}
|
||||
{/* }}*/}
|
||||
{/*/>*/}
|
||||
|
||||
{/* Visibility and actions are handled via Context */}
|
||||
<ScrollToBottomButton />
|
||||
|
||||
</ScrollToBottom>
|
||||
|
||||
{(_paneChatBeamIsOpen && !!_paneChatBeamStore) && (
|
||||
<ChatBeamWrapper beamStore={_paneChatBeamStore} isMobile={isMobile} />
|
||||
)}
|
||||
|
||||
</Panel>
|
||||
|
||||
{/* Panel Separators & Resizers */}
|
||||
@@ -533,15 +639,17 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
chatLLM={chatLLM}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
conversationId={focusedConversationId}
|
||||
conversationId={focusedPaneConversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
|
||||
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
|
||||
isDeveloperMode={isFocusedChatDeveloper}
|
||||
onAction={handleComposerAction}
|
||||
onTextImagine={handleTextImagine}
|
||||
setIsMulticast={setIsComposerMulticast}
|
||||
sx={{
|
||||
zIndex: 21, // position: 'sticky', bottom: 0,
|
||||
sx={beamOpenStoreInFocusedPane ? {
|
||||
display: 'none',
|
||||
} : {
|
||||
zIndex: 21, // just to allocate a surface, and potentially have a shadow
|
||||
backgroundColor: themeBgAppChatComposer,
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
@@ -565,7 +673,7 @@ export function AppChat() {
|
||||
{!!tradeConfig && (
|
||||
<TradeModal
|
||||
config={tradeConfig}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onClose={() => setTradeConfig(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -573,23 +681,20 @@ export function AppChat() {
|
||||
{/* [confirmation] Reset Conversation */}
|
||||
{!!clearConversationId && (
|
||||
<ConfirmationModal
|
||||
open
|
||||
onClose={() => setClearConversationId(null)}
|
||||
onPositive={handleConfirmedClearConversation}
|
||||
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
confirmationText='Are you sure you want to discard all messages?'
|
||||
positiveActionText='Clear conversation'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [confirmation] Delete All */}
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Are you absolutely sure you want to delete ${activeFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
|
||||
: 'Are you sure you want to delete this conversation?'}
|
||||
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Yes, delete all ${activeFolderConversationsCount} conversations`
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
{!!deleteConversationIds?.length && (
|
||||
<ConfirmationModal
|
||||
open onClose={() => setDeleteConversationIds(null)} onPositive={handleConfirmedDeleteConversations}
|
||||
confirmationText={`Are you absolutely sure you want to delete ${deleteConversationIds.length === 1 ? 'this conversation' : 'these conversations'}? This action cannot be undone.`}
|
||||
positiveActionText={deleteConversationIds.length === 1 ? 'Delete conversation' : `Yes, delete all ${deleteConversationIds.length} conversations`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBeam: ICommandsProvider = {
|
||||
id: 'mode-beam',
|
||||
rank: 9,
|
||||
|
||||
getCommands: () => useUXLabsStore.getState().labsBeam ? [{
|
||||
primary: '/beam',
|
||||
arguments: ['prompt'],
|
||||
description: 'Combine the smarts of models',
|
||||
Icon: ChatBeamIcon,
|
||||
}] : [],
|
||||
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CommandsDraw: ICommandsProvider = {
|
||||
alternatives: ['/imagine', '/img'],
|
||||
arguments: ['prompt'],
|
||||
description: 'Assistant will draw the text',
|
||||
Icon: FormatPaintIcon,
|
||||
Icon: FormatPaintTwoToneIcon,
|
||||
}],
|
||||
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ChatCommand, ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
import { CommandsAlter } from './CommandsAlter';
|
||||
import { CommandsBeam } from './CommandsBeam';
|
||||
import { CommandsBrowse } from './CommandsBrowse';
|
||||
import { CommandsDraw } from './CommandsDraw';
|
||||
import { CommandsHelp } from './CommandsHelp';
|
||||
import { CommandsReact } from './CommandsReact';
|
||||
|
||||
|
||||
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help';
|
||||
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help' | 'mode-beam';
|
||||
|
||||
type TextCommandPiece =
|
||||
| { type: 'text'; value: string; }
|
||||
@@ -20,6 +21,7 @@ const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
|
||||
'ass-t2i': CommandsDraw,
|
||||
'chat-alter': CommandsAlter,
|
||||
'cmd-help': CommandsHelp,
|
||||
'mode-beam': CommandsBeam,
|
||||
};
|
||||
|
||||
export function findAllChatCommands(): ChatCommand[] {
|
||||
@@ -38,7 +40,10 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
|
||||
// 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)) {
|
||||
@@ -46,22 +51,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,
|
||||
isError: !textAfterCommand || undefined,
|
||||
}];
|
||||
|
||||
// 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: 'text',
|
||||
value: textAfterCommand,
|
||||
});
|
||||
return pieces;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No command found, return the entire input as text
|
||||
return [{ type: 'text', value: input }];
|
||||
return [{
|
||||
type: 'text',
|
||||
value: input,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
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.terminate,
|
||||
})));
|
||||
|
||||
|
||||
// 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
|
||||
useGlobalShortcut(ShortcutKeyName.Esc, false, false, false, handleCloseBeam);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 1, md: 3 }, alignItems: 'center' }}>
|
||||
|
||||
{/* [desktop] maximize button, or a disabled spacer */}
|
||||
{props.isMobile ? null : (
|
||||
<GoodTooltip title='Maximize'>
|
||||
<IconButton size='sm' onClick={handleMaximizeBeam}>
|
||||
<FullscreenRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<GoodTooltip usePlain title={<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>Close Beam Mode <KeyStroke combo='Esc' /></Box>}>
|
||||
<IconButton aria-label='Close' size='sm' onClick={handleCloseBeam}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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 type { DConversationId } from '~/common/state/store-chats';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
|
||||
import { FadeInButton } from './ChatDrawerItem';
|
||||
|
||||
|
||||
export function ChatBarAltTitle(props: {
|
||||
conversationId: DConversationId | null,
|
||||
conversationTitle: string,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState<boolean>(false);
|
||||
|
||||
// derived state
|
||||
const { conversationId, conversationTitle } = props;
|
||||
const hasConversation = !!conversationId;
|
||||
|
||||
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
setIsEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
setIsEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 1, md: 3 }, alignItems: 'center' }}>
|
||||
|
||||
<Typography>
|
||||
{capitalizeFirstLetter(conversationTitle?.trim() || CHAT_NOVEL_TITLE)}
|
||||
</Typography>
|
||||
|
||||
{hasConversation && (
|
||||
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
|
||||
<AutoFixHighIcon />
|
||||
</FadeInButton>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
import { useFolderDropdown } from './folders/useFolderDropdown';
|
||||
|
||||
|
||||
export function ChatDropdowns(props: {
|
||||
export function ChatBarDropdowns(props: {
|
||||
conversationId: DConversationId | null
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Modal, ModalClose } from '@mui/joy';
|
||||
|
||||
import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
|
||||
import { BeamView } from '~/modules/beam/BeamView';
|
||||
|
||||
import { themeZIndexBeamView } from '~/common/app.theme';
|
||||
|
||||
|
||||
export function ChatBeamWrapper(props: {
|
||||
beamStore: BeamStoreApi,
|
||||
isMobile: boolean,
|
||||
}) {
|
||||
|
||||
// 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,
|
||||
}}>
|
||||
{beamView}
|
||||
<ModalClose sx={{ color: 'white', backgroundColor: 'background.surface', boxShadow: 'xs', mr: 2 }} />
|
||||
</Box>
|
||||
</Modal>
|
||||
) : (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: themeZIndexBeamView, // stay on top of Message > Chips (:1), and Overlays (:2) - note: Desktop Drawer (:26)
|
||||
}}>
|
||||
{beamView}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } 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 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 FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
|
||||
|
||||
import DebounceInput from '~/common/components/DebounceInput';
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
import { DebounceInputMemo } from '~/common/components/DebounceInput';
|
||||
import { FoldersToggleOff } from '~/common/components/icons/FoldersToggleOff';
|
||||
import { FoldersToggleOn } from '~/common/components/icons/FoldersToggleOn';
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { themeScalingMap, themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folders/ChatFolderList';
|
||||
import { ChatNavGrouping, ChatSearchSorting, isDrawerSearching, useChatDrawerRenderItems } from './useChatDrawerRenderItems';
|
||||
import { ClearFolderText } from './folders/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../store-app-chat';
|
||||
|
||||
|
||||
// this is here to make shallow comparisons work on the next hook
|
||||
@@ -32,7 +38,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)
|
||||
@@ -45,95 +51,61 @@ export const useFolders = (activeFolderId: string | null) => useFolderStore(({ e
|
||||
enableFolders,
|
||||
toggleEnableFolders,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* 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 const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null, chatPanesConversationIds: DConversationId[]): ChatNavigationItemData[] =>
|
||||
useChatStore(({ conversations }) => {
|
||||
|
||||
const activeConversations = activeFolder
|
||||
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
|
||||
: conversations;
|
||||
|
||||
return activeConversations.map((_c): ChatNavigationItemData => ({
|
||||
conversationId: _c.id,
|
||||
isActive: _c.id === activeConversationId,
|
||||
isAlsoOpen: findOpenInViewNumbers(chatPanesConversationIds, _c.id),
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title: conversationTitle(_c),
|
||||
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,
|
||||
messageCount: _c.messages.length,
|
||||
assistantTyping: !!_c.abortController,
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
}));
|
||||
|
||||
}, (a, b) => {
|
||||
// custom equality function to avoid unnecessary re-renders
|
||||
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
export const ChatDrawerMemo = React.memo(ChatDrawer);
|
||||
|
||||
function ChatDrawer(props: {
|
||||
isMobile: boolean,
|
||||
activeConversationId: DConversationId | null,
|
||||
activeFolderId: string | null,
|
||||
chatPanesConversationIds: DConversationId[],
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
onConversationExportDialog: (conversationId: DConversationId | null, exportAll: boolean) => void,
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationNew: (forceNoRecycle: boolean) => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
onConversationsDelete: (conversationIds: DConversationId[], bypassConfirmation: boolean) => void,
|
||||
onConversationsExportDialog: (conversationId: DConversationId | null, exportAll: boolean) => void,
|
||||
onConversationsImportDialog: () => void,
|
||||
setActiveFolderId: (folderId: string | null) => void,
|
||||
}) {
|
||||
|
||||
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
|
||||
const { onConversationActivate, onConversationBranch, onConversationNew, onConversationsDelete, onConversationsExportDialog } = props;
|
||||
|
||||
// local state
|
||||
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('frequency');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const {
|
||||
filterHasStars, toggleFilterHasStars,
|
||||
showPersonaIcons, toggleShowPersonaIcons,
|
||||
showRelativeSize, toggleShowRelativeSize,
|
||||
} = useChatDrawerFilters();
|
||||
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
||||
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId, props.chatPanesConversationIds);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatDrawerRenderItems(
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, navGrouping, searchSorting, showRelativeSize,
|
||||
);
|
||||
const { contentScaling, showSymbols } = useUIPreferencesStore(useShallow(state => ({
|
||||
contentScaling: state.contentScaling,
|
||||
showSymbols: state.zenMode !== 'cleaner',
|
||||
})));
|
||||
|
||||
// derived state
|
||||
const selectConversationsCount = chatNavItems.length;
|
||||
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
|
||||
const softMaxReached = selectConversationsCount >= 40 && showSymbols;
|
||||
|
||||
// New/Activate/Delete Conversation
|
||||
|
||||
const isMultiPane = props.chatPanesConversationIds.length >= 2;
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew(isMultiPane);
|
||||
closeDrawerOnMobile();
|
||||
}, [closeDrawerOnMobile, isMultiPane, onConversationNew]);
|
||||
const disableNewButton = props.disableNewButton && filteredChatsIncludeActive;
|
||||
const newButtonDontRecycle = isMultiPane || !filteredChatsIncludeActive;
|
||||
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew(newButtonDontRecycle);
|
||||
closeDrawerOnMobile();
|
||||
}, [closeDrawerOnMobile, newButtonDontRecycle, onConversationNew]);
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
||||
onConversationActivate(conversationId);
|
||||
@@ -141,10 +113,17 @@ function ChatDrawer(props: {
|
||||
closeDrawerOnMobile();
|
||||
}, [closeDrawerOnMobile, onConversationActivate]);
|
||||
|
||||
const handleConversationsDeleteFiltered = React.useCallback(() => {
|
||||
!!filteredChatIDs?.length && onConversationsDelete(filteredChatIDs, false);
|
||||
}, [filteredChatIDs, onConversationsDelete]);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
|
||||
conversationId && onConversationDelete(conversationId, true);
|
||||
}, [onConversationDelete]);
|
||||
const handleConversationDeleteNoConfirmation = React.useCallback((conversationId: DConversationId) => {
|
||||
conversationId && onConversationsDelete([conversationId], true);
|
||||
}, [onConversationsDelete]);
|
||||
|
||||
const handleConversationsExport = React.useCallback(() => {
|
||||
props.activeConversationId && onConversationsExportDialog(props.activeConversationId, true);
|
||||
}, [onConversationsExportDialog, props.activeConversationId]);
|
||||
|
||||
|
||||
// Folder change request
|
||||
@@ -166,67 +145,90 @@ function ChatDrawer(props: {
|
||||
}, []);
|
||||
|
||||
|
||||
// Filter chatNavItems based on the search query and rank them by search frequency
|
||||
const filteredChatNavItems = React.useMemo(() => {
|
||||
if (!debouncedSearchQuery) return chatNavItems;
|
||||
return chatNavItems
|
||||
.map(item => {
|
||||
// Get the conversation by ID
|
||||
const conversation = useChatStore.getState().conversations.find(c => c.id === item.conversationId);
|
||||
// Calculate the frequency of the search term in the title and messages
|
||||
const titleFrequency = (item.title.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
||||
const messageFrequency = conversation?.messages.reduce((count, message) => {
|
||||
return count + (message.text.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
||||
}, 0) || 0;
|
||||
// Return the item with the searchFrequency property
|
||||
return {
|
||||
...item,
|
||||
searchFrequency: titleFrequency + messageFrequency,
|
||||
};
|
||||
})
|
||||
// Exclude items with a searchFrequency of 0
|
||||
.filter(item => item.searchFrequency > 0)
|
||||
// Sort the items by searchFrequency in descending order
|
||||
.sort((a, b) => b.searchFrequency! - a.searchFrequency!);
|
||||
}, [chatNavItems, debouncedSearchQuery]);
|
||||
// memoize the group dropdown
|
||||
const { isSearching } = isDrawerSearching(debouncedSearchQuery);
|
||||
const groupingComponent = React.useMemo(() => (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
aria-label='View options'
|
||||
slots={{ root: IconButton }}
|
||||
slotProps={{ root: { size: 'sm' } }}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</MenuButton>
|
||||
|
||||
{!isSearching ? (
|
||||
// Search/Filter default menu: Grouping, Filtering, ...
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<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 && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
{capitalizeFirstLetter(_gName)}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
// basis for the underline bar
|
||||
const bottomBarBasis = filteredChatNavItems.reduce((longest, _c) => Math.max(longest, _c.searchFrequency ?? _c.messageCount), 1);
|
||||
<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={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>
|
||||
), [filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize]);
|
||||
|
||||
// grouping
|
||||
/*let sortedIds = conversationIDs;
|
||||
if (grouping === 'persona') {
|
||||
const conversations = useChatStore.getState().conversations;
|
||||
|
||||
// group conversations by persona
|
||||
const groupedConversations: { [personaId: string]: string[] } = {};
|
||||
conversations.forEach(conversation => {
|
||||
const persona = conversation.systemPurposeId;
|
||||
if (persona) {
|
||||
if (!groupedConversations[persona])
|
||||
groupedConversations[persona] = [];
|
||||
groupedConversations[persona].push(conversation.id);
|
||||
}
|
||||
});
|
||||
|
||||
// flatten grouped conversations
|
||||
sortedIds = Object.values(groupedConversations).flat();
|
||||
}*/
|
||||
|
||||
return <>
|
||||
|
||||
{/* Drawer Header */}
|
||||
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
|
||||
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
|
||||
<IconButton onClick={toggleEnableFolders}>
|
||||
{enableFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
|
||||
<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',*/}
|
||||
@@ -240,8 +242,15 @@ function ChatDrawer(props: {
|
||||
{enableFolders && (
|
||||
<ChatFolderList
|
||||
folders={allFolders}
|
||||
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>*/}
|
||||
@@ -251,69 +260,96 @@ function ChatDrawer(props: {
|
||||
|
||||
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
|
||||
{/* Search Input Field */}
|
||||
<DebounceInput
|
||||
minChars={2}
|
||||
onDebounce={setDebouncedSearchQuery}
|
||||
debounceTimeout={300}
|
||||
placeholder='Search...'
|
||||
aria-label='Search'
|
||||
sx={{ m: 2 }}
|
||||
/>
|
||||
{/* Search / New Chat */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', m: 2, gap: 2 }}>
|
||||
|
||||
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
|
||||
<ListItemButton disabled={props.disableNewButton && !isMultiPane} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
<Box sx={{
|
||||
// style
|
||||
{/* 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 : 'soft'}
|
||||
color='primary'
|
||||
disabled={disableNewButton}
|
||||
onClick={handleButtonNew}
|
||||
sx={{
|
||||
// ...PageDrawerTallItemSx,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '0px 0.75rem',
|
||||
|
||||
// text size
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
// content
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}>
|
||||
New chat
|
||||
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/*<ListDivider sx={{ mt: 0 }} />*/}
|
||||
// style
|
||||
// backgroundColor: 'background.popup',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
'--ListItemDecorator-size': 'calc(2.5rem - 1px)', // compensate for the border
|
||||
// boxShadow: (disableNewButton || props.isMobile) ? 'none' : 'xs',
|
||||
// transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator><AddIcon sx={{ fontSize: '' }} /></ListItemDecorator>
|
||||
New chat
|
||||
</Button>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/*<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>*/}
|
||||
|
||||
{filteredChatNavItems.map(item =>
|
||||
<ChatDrawerItemMemo
|
||||
key={'nav-' + item.conversationId}
|
||||
item={item}
|
||||
showSymbols={showSymbols}
|
||||
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationExport={onConversationExportDialog}
|
||||
onConversationFolderChange={handleConversationFolderChange}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
{/* 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={showPersonaIcons && showSymbols}
|
||||
bottomBarBasis={filteredChatsBarBasis}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationBranch={onConversationBranch}
|
||||
onConversationDelete={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)',
|
||||
// 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', 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,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
|
||||
<ListDivider sx={{ my: 0 }} />
|
||||
|
||||
{/* Bottom commands */}
|
||||
<Box sx={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<ListItemButton onClick={props.onConversationsImportDialog} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadOutlinedIcon />
|
||||
</ListItemDecorator>
|
||||
@@ -321,7 +357,7 @@ function ChatDrawer(props: {
|
||||
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId, true)} sx={{ flex: 1 }}>
|
||||
<ListItemButton disabled={filteredChatsAreEmpty} onClick={handleConversationsExport} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</ListItemDecorator>
|
||||
@@ -329,11 +365,11 @@ function ChatDrawer(props: {
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
|
||||
<ListItemButton disabled={!nonEmptyChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemButton disabled={filteredChatsAreEmpty} onClick={handleConversationsDeleteFiltered}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Delete {selectConversationsCount >= 2 ? `all ${selectConversationsCount} chats` : 'chat'}
|
||||
Delete {filteredChatsCount >= 2 ? `all ${filteredChatsCount} chats` : 'chat'}
|
||||
</ListItemButton>
|
||||
|
||||
</PageDrawerList>
|
||||
|
||||
@@ -2,13 +2,14 @@ import * as React from 'react';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
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';
|
||||
|
||||
@@ -19,13 +20,16 @@ import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { isDeepEqual } from '~/common/util/jsUtils';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
import { STREAM_TEXT_INDICATOR } from '../editors/chat-stream';
|
||||
|
||||
|
||||
// set to true to display the conversation IDs
|
||||
// const DEBUG_CONVERSATION_IDS = false;
|
||||
|
||||
|
||||
export const FadeInButton = styled(IconButton)({
|
||||
opacity: 0.667,
|
||||
opacity: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': { opacity: 1 },
|
||||
});
|
||||
@@ -37,22 +41,26 @@ export const ChatDrawerItemMemo = React.memo(ChatDrawerItem, (prev, next) =>
|
||||
prev.showSymbols === next.showSymbols &&
|
||||
prev.bottomBarBasis === next.bottomBarBasis &&
|
||||
prev.onConversationActivate === next.onConversationActivate &&
|
||||
prev.onConversationBranch === next.onConversationBranch &&
|
||||
prev.onConversationDelete === next.onConversationDelete &&
|
||||
prev.onConversationExport === next.onConversationExport &&
|
||||
prev.onConversationFolderChange === next.onConversationFolderChange,
|
||||
);
|
||||
|
||||
export interface ChatNavigationItemData {
|
||||
type: 'nav-item-chat-data',
|
||||
conversationId: DConversationId;
|
||||
isActive: boolean;
|
||||
isAlsoOpen: string | false;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
userFlagsSummary: string | undefined;
|
||||
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
searchFrequency?: number;
|
||||
searchFrequency: number;
|
||||
}
|
||||
|
||||
export interface FolderChangeRequest {
|
||||
@@ -67,6 +75,7 @@ function ChatDrawerItem(props: {
|
||||
showSymbols: boolean,
|
||||
bottomBarBasis: number,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId, exportAll: boolean) => void,
|
||||
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
|
||||
@@ -74,11 +83,12 @@ function ChatDrawerItem(props: {
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [isAutoEditingTitle, setIsAutoEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// derived state
|
||||
const { onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, isAlsoOpen, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, isAlsoOpen, title, userFlagsSummary, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
|
||||
@@ -95,6 +105,14 @@ function ChatDrawerItem(props: {
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
|
||||
// branch
|
||||
|
||||
const handleConversationBranch = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
conversationId && onConversationBranch(conversationId, null);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
|
||||
// export
|
||||
|
||||
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
|
||||
@@ -128,8 +146,10 @@ function ChatDrawerItem(props: {
|
||||
useChatStore.getState().setUserTitle(conversationId, text.trim());
|
||||
}, [conversationId]);
|
||||
|
||||
const handleTitleEditAuto = React.useCallback(() => {
|
||||
conversationAutoTitle(conversationId, true);
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
setIsAutoEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
setIsAutoEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
|
||||
@@ -150,8 +170,7 @@ function ChatDrawerItem(props: {
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency || messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
const titleRowComponent = React.useMemo(() => <>
|
||||
|
||||
@@ -178,8 +197,8 @@ function ChatDrawerItem(props: {
|
||||
|
||||
{/* Title */}
|
||||
{!isEditingTitle ? (
|
||||
<Typography
|
||||
// level={isActive ? 'title-md' : 'body-md'}
|
||||
// using Box to not reset the parent font scaling
|
||||
<Box
|
||||
onDoubleClick={handleTitleEditBegin}
|
||||
sx={{
|
||||
color: isActive ? 'text.primary' : 'text.secondary',
|
||||
@@ -187,8 +206,8 @@ function ChatDrawerItem(props: {
|
||||
}}
|
||||
>
|
||||
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
|
||||
{title.trim() ? title : 'Chat'}{assistantTyping && '...'}
|
||||
</Typography>
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{assistantTyping && STREAM_TEXT_INDICATOR}
|
||||
</Box>
|
||||
) : (
|
||||
<InlineTextarea
|
||||
invertedColors
|
||||
@@ -202,21 +221,24 @@ function ChatDrawerItem(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display search frequency if it exists and is greater than 0 */}
|
||||
{searchFrequency && searchFrequency > 0 && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Right text */}
|
||||
{searchFrequency > 0 ? (
|
||||
// Display search frequency if it exists and is greater than 0
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
) : (userFlagsSummary && props.showSymbols) ? (
|
||||
<Typography sx={{ mr: '5px' }}>
|
||||
{userFlagsSummary}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title]);
|
||||
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary]);
|
||||
|
||||
const progressBarFixedComponent = React.useMemo(() =>
|
||||
progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softBg',
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
|
||||
}} />
|
||||
), [progress]);
|
||||
@@ -260,67 +282,74 @@ function ChatDrawerItem(props: {
|
||||
|
||||
{/* buttons row */}
|
||||
{isActive && (
|
||||
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>
|
||||
<ListItemDecorator />
|
||||
<Box sx={{ display: 'flex', gap: 0.5, minHeight: '2.25rem', alignItems: 'center' }}>
|
||||
{props.showSymbols && <ListItemDecorator />}
|
||||
|
||||
{/* Current Folder color, and change initiator */}
|
||||
{(folder !== undefined) && <>
|
||||
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
|
||||
{folder ? (
|
||||
<IconButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderIcon style={{ color: folder.color || 'inherit' }} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderOutlinedIcon />
|
||||
{!deleteArmed && <>
|
||||
{(folder !== undefined) && <>
|
||||
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
|
||||
{folder ? (
|
||||
<IconButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderIcon style={{ color: folder.color || 'inherit' }} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderOutlinedIcon />
|
||||
</FadeInButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
|
||||
</>}
|
||||
|
||||
<Tooltip disableInteractive title='Rename'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle || isAutoEditingTitle} onClick={handleTitleEditBegin}>
|
||||
<EditRoundedIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
{!isNew && <>
|
||||
<Tooltip disableInteractive title='Auto-Title'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle || isAutoEditingTitle} onClick={handleTitleEditAuto}>
|
||||
<AutoFixHighIcon />
|
||||
</FadeInButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
|
||||
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
|
||||
</>}
|
||||
<Tooltip disableInteractive title='Branch'>
|
||||
<FadeInButton size='sm' onClick={handleConversationBranch}>
|
||||
<ForkRightIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disableInteractive title='Rename'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
|
||||
<EditIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
<Tooltip disableInteractive title='Export Chat'>
|
||||
<FadeInButton size='sm' onClick={handleConversationExport}>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
{!isNew && <>
|
||||
<Tooltip disableInteractive title='Auto-Title'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
|
||||
<AutoFixHighIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
|
||||
|
||||
<Tooltip disableInteractive title='Export Chat'>
|
||||
<FadeInButton size='sm' onClick={handleConversationExport}>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
{/* --> */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Delete [armed, arming] buttons */}
|
||||
{!searchFrequency && <>
|
||||
{deleteArmed && (
|
||||
<Tooltip disableInteractive title='Confirm Deletion'>
|
||||
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
|
||||
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
|
||||
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
|
||||
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
|
||||
{/*{!searchFrequency && <>*/}
|
||||
{deleteArmed && (
|
||||
<Tooltip disableInteractive title='Confirm Deletion'>
|
||||
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1, mr: 0.5 }}>
|
||||
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
</>}
|
||||
)}
|
||||
|
||||
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
|
||||
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
|
||||
{deleteArmed ? <CloseRoundedIcon /> : <DeleteOutlineIcon />}
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
{/*</>}*/}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -342,7 +371,9 @@ function ChatDrawerItem(props: {
|
||||
) : (
|
||||
|
||||
// Inactive Conversation - click to activate
|
||||
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
|
||||
<ListItem
|
||||
// sx={{ '--ListItem-minHeight': '2.75rem' }}
|
||||
>
|
||||
|
||||
<ListItemButton
|
||||
onClick={handleConversationActivate}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
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 { 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 { createDMessage, DConversationId, DMessage, DMessageUserFlag, getConversation, messageToggleUserFlag, useChatStore } from '~/common/state/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 { ChatMessageMemo } from './message/ChatMessage';
|
||||
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';
|
||||
|
||||
|
||||
/**
|
||||
@@ -24,10 +28,11 @@ import { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
|
||||
*/
|
||||
export function ChatMessageList(props: {
|
||||
conversationId: DConversationId | null,
|
||||
conversationHandler: ConversationHandler | null,
|
||||
capabilityHasT2I: boolean,
|
||||
chatLLMContextTokens: number | null,
|
||||
fitScreen: boolean,
|
||||
isMessageSelectionMode: boolean,
|
||||
isMobile: boolean,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
|
||||
@@ -46,7 +51,8 @@ export function ChatMessageList(props: {
|
||||
const { notifyBooting } = useScrollToBottom();
|
||||
const { openPreferencesTab } = useOptimaLayout();
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
|
||||
const optionalTranslationWarning = useBrowserTranslationWarning();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
conversationMessages: conversation ? conversation.messages : [],
|
||||
@@ -55,7 +61,8 @@ export function ChatMessageList(props: {
|
||||
editMessage: state.editMessage,
|
||||
setMessages: state.setMessages,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
const ephemerals = useEphemerals(props.conversationHandler);
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
// derived state
|
||||
@@ -64,18 +71,14 @@ export function ChatMessageList(props: {
|
||||
|
||||
// text actions
|
||||
|
||||
const handleRunExample = React.useCallback(async (text: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
|
||||
const handleRunExample = React.useCallback(async (examplePrompt: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', examplePrompt)]);
|
||||
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
const handleConversationBranch = React.useCallback((messageId: string) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number) => {
|
||||
const handleMessageAssistantFrom = React.useCallback(async (messageId: string, offset: number) => {
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (messages) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
|
||||
@@ -83,7 +86,35 @@ export function ChatMessageList(props: {
|
||||
}
|
||||
}, [conversationId, onConversationExecuteHistory]);
|
||||
|
||||
const handleConversationTruncate = React.useCallback((messageId: string) => {
|
||||
const handleMessageBeam = React.useCallback(async (messageId: string) => {
|
||||
// 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: string) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleMessageTruncate = React.useCallback((messageId: string) => {
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (conversationId && messages) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1);
|
||||
@@ -99,6 +130,12 @@ export function ChatMessageList(props: {
|
||||
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
|
||||
}, [conversationId, editMessage]);
|
||||
|
||||
const handleMessageToggleUserFlag = React.useCallback((messageId: string, userFlag: DMessageUserFlag) => {
|
||||
conversationId && editMessage(conversationId, messageId, (message) => ({
|
||||
userFlags: messageToggleUserFlag(message, userFlag),
|
||||
}), false);
|
||||
}, [conversationId, editMessage]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
}, [conversationId, onTextDiagram]);
|
||||
@@ -196,6 +233,8 @@ export function ChatMessageList(props: {
|
||||
// marginBottom: '-1px',
|
||||
}}>
|
||||
|
||||
{optionalTranslationWarning}
|
||||
|
||||
{props.isMessageSelectionMode && (
|
||||
<MessagesSelectionHeader
|
||||
hasSelected={selectedMessages.size > 0}
|
||||
@@ -206,37 +245,56 @@ export function ChatMessageList(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredMessages.map((message, idx, { length: count }) =>
|
||||
props.isMessageSelectionMode ? (
|
||||
{filteredMessages.map((message, idx, { length: count }) => {
|
||||
|
||||
<CleanerMessage
|
||||
key={'sel-' + message.id}
|
||||
message={message}
|
||||
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
|
||||
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
|
||||
/>
|
||||
// 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;
|
||||
|
||||
) : (
|
||||
return props.isMessageSelectionMode ? (
|
||||
|
||||
<ChatMessageMemo
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
isBottom={idx === count - 1}
|
||||
isImagining={isImagining}
|
||||
isMobile={props.isMobile}
|
||||
isSpeaking={isSpeaking}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationRestartFrom={handleConversationRestartFrom}
|
||||
onConversationTruncate={handleConversationTruncate}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
/>
|
||||
<CleanerMessage
|
||||
key={'sel-' + message.id}
|
||||
message={message}
|
||||
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
|
||||
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
|
||||
/>
|
||||
|
||||
),
|
||||
) : (
|
||||
|
||||
<ChatMessageMemoOrNot
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
fitScreen={props.fitScreen}
|
||||
isBottom={idx === count - 1}
|
||||
isImagining={isImagining}
|
||||
isSpeaking={isSpeaking}
|
||||
onMessageAssistantFrom={handleMessageAssistantFrom}
|
||||
onMessageBeam={handleMessageBeam}
|
||||
onMessageBranch={handleMessageBranch}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onMessageToggleUserFlag={handleMessageToggleUserFlag}
|
||||
onMessageTruncate={handleMessageTruncate}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
/>
|
||||
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{!!ephemerals.length && (
|
||||
<Ephemerals
|
||||
ephemerals={ephemerals}
|
||||
conversationId={props.conversationId}
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</List>
|
||||
|
||||
@@ -127,11 +127,9 @@ export function ChatPageMenuItems(props: {
|
||||
|
||||
<ListDivider />
|
||||
|
||||
<MenuItem disabled={props.disableItems} onClick={handleToggleMessageSelectionMode}>
|
||||
<MenuItem disabled={props.disableItems} onClick={handleToggleMessageSelectionMode} sx={props.isMessageSelectionMode ? { fontWeight: 'lg' } : {}}>
|
||||
<ListItemDecorator>{props.isMessageSelectionMode ? <CheckBoxOutlinedIcon /> : <CheckBoxOutlineBlankOutlinedIcon />}</ListItemDecorator>
|
||||
<span style={props.isMessageSelectionMode ? { fontWeight: 800 } : {}}>
|
||||
Cleanup ...
|
||||
</span>
|
||||
Cleanup ...
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={props.disableItems} onClick={handleConversationFlatten}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
|
||||
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
import { lineHeightChatText } from '~/common/app.theme';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DConversationId } from '~/common/state/store-chats';
|
||||
import { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { lineHeightChatTextMd } from '~/common/app.theme';
|
||||
|
||||
|
||||
const StateLine = styled(Typography)(({ theme }) => ({
|
||||
@@ -16,7 +17,7 @@ const StateLine = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.code,
|
||||
marginLeft: theme.spacing(1),
|
||||
lineHeight: lineHeightChatText,
|
||||
lineHeight: lineHeightChatTextMd,
|
||||
}));
|
||||
|
||||
function isPrimitive(value: any): boolean {
|
||||
@@ -75,6 +76,11 @@ function StateRenderer(props: { state: object }) {
|
||||
|
||||
|
||||
function EphemeralItem({ conversationId, ephemeral }: { conversationId: string, ephemeral: DEphemeral }) {
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
ConversationsManager.getHandler(conversationId).ephemeralsStore.delete(ephemeral.id);
|
||||
}, [conversationId, ephemeral.id]);
|
||||
|
||||
return <Box
|
||||
sx={{
|
||||
p: { xs: 1, md: 2 },
|
||||
@@ -93,7 +99,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
|
||||
{/* Left pane (console) */}
|
||||
<Grid xs={12} md={ephemeral.state ? 6 : 12}>
|
||||
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatText }}>
|
||||
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatTextMd }}>
|
||||
{ephemeral.text}
|
||||
</Typography>
|
||||
</Grid>
|
||||
@@ -112,12 +118,12 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
{/* Close button (right of title) */}
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={() => useChatStore.getState().deleteEphemeral(conversationId, ephemeral.id)}
|
||||
onClick={handleDelete}
|
||||
sx={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
opacity: { xs: 1, sm: 0.5 }, transition: 'opacity 0.3s',
|
||||
}}>
|
||||
<CloseIcon />
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
</Box>;
|
||||
@@ -130,19 +136,22 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
// `);
|
||||
|
||||
|
||||
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
|
||||
export function Ephemerals(props: { ephemerals: DEphemeral[], conversationId: DConversationId | null, sx?: SxProps }) {
|
||||
// global state
|
||||
const ephemerals = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return conversation ? conversation.ephemerals : [];
|
||||
}, shallow);
|
||||
// const ephemerals = useChatStore(state => {
|
||||
// const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
// return conversation ? conversation.ephemerals : [];
|
||||
// }, shallow);
|
||||
|
||||
if (!ephemerals?.length) return null;
|
||||
const ephemerals = props.ephemerals;
|
||||
// if (!ephemerals?.length) return null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
variant='soft' color='success' invertedColors
|
||||
sx={{
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: 'divider',
|
||||
// backgroundImage: `url("data:image/svg+xml,${dashedBorderSVG.replace('currentColor', '%23A1E8A1')}")`,
|
||||
// backgroundSize: '100% 100%',
|
||||
// backgroundRepeat: 'no-repeat',
|
||||
|
||||
@@ -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 }}>*/}
|
||||
|
||||
@@ -3,16 +3,19 @@ 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 { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ChatModeId } from '../../AppChat';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
interface ChatModeDescription {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
highlight?: boolean;
|
||||
shortcut?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
requiresTTI?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,9 +24,15 @@ const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'generate-text-beam': {
|
||||
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
|
||||
description: 'Combine multiple models', // Smarter: combine...
|
||||
shortcut: 'Ctrl + Enter',
|
||||
hideOnDesktop: true,
|
||||
},
|
||||
'append-user': {
|
||||
label: 'Write',
|
||||
description: 'Appends a message',
|
||||
description: 'Append a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'generate-image': {
|
||||
@@ -32,8 +41,8 @@ const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
requiresTTI: true,
|
||||
},
|
||||
'generate-react': {
|
||||
label: 'Reason + Act · α',
|
||||
description: 'Answers questions in multiple steps',
|
||||
label: 'Reason + Act', // · α
|
||||
description: 'Answer questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,12 +54,16 @@ function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
}
|
||||
|
||||
export function ChatModeMenu(props: {
|
||||
anchorEl: HTMLAnchorElement | null, onClose: () => void,
|
||||
chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void
|
||||
isMobile: boolean,
|
||||
anchorEl: HTMLAnchorElement | null,
|
||||
onClose: () => void,
|
||||
chatModeId: ChatModeId,
|
||||
onSetChatModeId: (chatMode: ChatModeId) => void,
|
||||
capabilityHasTTI: boolean,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const labsBeam = useUXLabsStore(state => state.labsBeam);
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
|
||||
return (
|
||||
@@ -68,16 +81,18 @@ export function ChatModeMenu(props: {
|
||||
|
||||
{/* ChatMode items */}
|
||||
{Object.entries(ChatModeItems)
|
||||
.filter(([key, _data]) => key !== 'generate-text-beam' || labsBeam)
|
||||
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
|
||||
.map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
<Radio checked={key === props.chatModeId} />
|
||||
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatModeId} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatModeId || !!data.shortcut) && (
|
||||
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
|
||||
<KeyStroke combo={platformAwareKeystrokes(fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline))} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/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,7 +9,7 @@ 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';
|
||||
@@ -23,22 +22,28 @@ 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 { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversationId, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { isMacUser } from '~/common/util/pwaUtils';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { playSoundUrl } from '~/common/util/audioUtils';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useDebouncer } from '~/common/components/useDebouncer';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
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';
|
||||
@@ -51,10 +56,11 @@ import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonA
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
import { ButtonAttachScreenCaptureMemo } from './buttons/ButtonAttachScreenCapture';
|
||||
import { ButtonCall } from './buttons/ButtonCall';
|
||||
import { ButtonBeamMemo } from './buttons/ButtonBeam';
|
||||
import { ButtonCallMemo } from './buttons/ButtonCall';
|
||||
import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './buttons/ButtonMic';
|
||||
import { ButtonMultiChat } from './buttons/ButtonMultiChat';
|
||||
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
|
||||
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
|
||||
import { ChatModeMenu } from './ChatModeMenu';
|
||||
import { TokenBadgeMemo } from './TokenBadge';
|
||||
@@ -62,16 +68,23 @@ 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',
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
border: '2px dashed',
|
||||
borderRadius: 'xs',
|
||||
boxShadow: 'none',
|
||||
zIndex: zIndexComposerOverlayDrop,
|
||||
} as const;
|
||||
|
||||
const dropppedCardDraggingSx: SxProps = {
|
||||
...dropperCardSx,
|
||||
display: 'flex',
|
||||
} as const;
|
||||
|
||||
|
||||
/**
|
||||
@@ -92,6 +105,7 @@ export function Composer(props: {
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
|
||||
const [micContinuation, setMicContinuation] = React.useState(false);
|
||||
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
|
||||
@@ -100,12 +114,15 @@ export function Composer(props: {
|
||||
|
||||
// external state
|
||||
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
|
||||
const { labsAttachScreenCapture, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
const { labsAttachScreenCapture, labsBeam, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
labsAttachScreenCapture: state.labsAttachScreenCapture,
|
||||
labsBeam: state.labsBeam,
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
}), shallow);
|
||||
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();
|
||||
@@ -119,7 +136,7 @@ export function Composer(props: {
|
||||
};
|
||||
}, shallow);
|
||||
const { inComposer: browsingInComposer } = useBrowseCapability();
|
||||
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
|
||||
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoMessage, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
|
||||
useAttachments(browsingInComposer && !composeText.startsWith('/'));
|
||||
|
||||
|
||||
@@ -180,41 +197,56 @@ export function Composer(props: {
|
||||
return enqueued;
|
||||
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
|
||||
|
||||
const handleSendClicked = () => handleSendAction(chatModeId, composeText);
|
||||
const handleSendClicked = React.useCallback(() => {
|
||||
handleSendAction(chatModeId, composeText);
|
||||
}, [chatModeId, composeText, handleSendAction]);
|
||||
|
||||
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
|
||||
const handleSendTextBeamClicked = React.useCallback(() => {
|
||||
labsBeam && handleSendAction('generate-text-beam', composeText);
|
||||
}, [composeText, handleSendAction, labsBeam]);
|
||||
|
||||
const handleStopClicked = React.useCallback(() => {
|
||||
!!props.conversationId && stopTyping(props.conversationId);
|
||||
}, [props.conversationId, stopTyping]);
|
||||
|
||||
|
||||
// Secondary buttons
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
const handleCallClicked = React.useCallback(() => {
|
||||
props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
}, [props.conversationId, systemPurposeId]);
|
||||
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
|
||||
const handleDrawOptionsClicked = React.useCallback(() => {
|
||||
openPreferencesTab(PreferencesTab.Draw);
|
||||
}, [openPreferencesTab]);
|
||||
|
||||
const handleTextImagineClicked = () => {
|
||||
const handleTextImagineClicked = React.useCallback(() => {
|
||||
if (!composeText || !props.conversationId)
|
||||
return;
|
||||
props.onTextImagine(props.conversationId, composeText);
|
||||
setComposeText('');
|
||||
};
|
||||
}, [composeText, props, setComposeText]);
|
||||
|
||||
|
||||
// Mode menu
|
||||
|
||||
const handleModeSelectorHide = () => setChatModeMenuAnchor(null);
|
||||
const handleModeSelectorHide = React.useCallback(() => {
|
||||
setChatModeMenuAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleModeChange = (_chatModeId: ChatModeId) => {
|
||||
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
};
|
||||
}, [handleModeSelectorHide]);
|
||||
|
||||
|
||||
// Actiles
|
||||
|
||||
const onActileCommandSelect = React.useCallback((item: ActileItem) => {
|
||||
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
|
||||
if (props.composerTextAreaRef.current) {
|
||||
const textArea = props.composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
@@ -235,9 +267,22 @@ export function Composer(props: {
|
||||
}
|
||||
}, [props.composerTextAreaRef, setComposeText]);
|
||||
|
||||
const actileProviders: ActileProvider[] = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandSelect)];
|
||||
}, [onActileCommandSelect]);
|
||||
const onActileMessageAttach = React.useCallback((item: StarredMessageItem) => {
|
||||
// get the message
|
||||
const conversation = getConversation(item.conversationId);
|
||||
const messageToAttach = conversation?.messages.find(m => m.id === item.messageId);
|
||||
if (conversation && messageToAttach && messageToAttach.text) {
|
||||
// Testing with this serialization for LLM. Note it will still be within a multi-part message,
|
||||
// this could be in a titled markdown block. Don't know yet how this fares with different LLMs.
|
||||
const chatTitle = conversationTitle(conversation);
|
||||
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageToAttach.text.trim()}\n`;
|
||||
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageToAttach.text.slice(0, 10)}...`);
|
||||
}
|
||||
}, [attachAppendEgoMessage]);
|
||||
|
||||
const actileProviders = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileMessageAttach)];
|
||||
}, [onActileCommandPaste, onActileMessageAttach]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
|
||||
@@ -257,12 +302,20 @@ 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) {
|
||||
touchAltEnter();
|
||||
handleSendAction('append-user', composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
|
||||
if (labsBeam && ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey))) {
|
||||
touchCtrlEnter();
|
||||
handleSendAction('generate-text-beam', composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Shift: toggles the 'enter is newline'
|
||||
if (e.shiftKey)
|
||||
touchShiftEnter();
|
||||
@@ -273,7 +326,7 @@ export function Composer(props: {
|
||||
}
|
||||
}
|
||||
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchShiftEnter]);
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, labsBeam, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
@@ -331,7 +384,9 @@ export function Composer(props: {
|
||||
toggleRecording();
|
||||
}, [micContinuation, micIsRunning, toggleRecording]);
|
||||
|
||||
const handleToggleMicContinuation = () => setMicContinuation(continued => !continued);
|
||||
const handleToggleMicContinuation = React.useCallback(() => {
|
||||
setMicContinuation(continued => !continued);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// autostart the microphone if the assistant stopped typing
|
||||
@@ -435,26 +490,52 @@ export function Composer(props: {
|
||||
|
||||
|
||||
const isText = chatModeId === 'generate-text';
|
||||
const isTextBeam = chatModeId === 'generate-text-beam';
|
||||
const isAppend = chatModeId === 'append-user';
|
||||
const isChat = isText || isAppend;
|
||||
const isReAct = chatModeId === 'generate-react';
|
||||
const isDraw = chatModeId === 'generate-image';
|
||||
const buttonColor: ColorPaletteProp = assistantAbortible
|
||||
? 'warning'
|
||||
: isReAct ? 'success' : isDraw ? 'warning' : 'primary';
|
||||
|
||||
const showChatExtras = isText;
|
||||
|
||||
const buttonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
|
||||
const buttonColor: ColorPaletteProp =
|
||||
assistantAbortible ? 'warning'
|
||||
: isReAct ? 'success'
|
||||
: isTextBeam ? 'primary'
|
||||
: isDraw ? 'warning'
|
||||
: 'primary';
|
||||
|
||||
const buttonText =
|
||||
isAppend ? 'Write'
|
||||
: isReAct ? 'ReAct'
|
||||
: isTextBeam ? 'Beam'
|
||||
: isDraw ? 'Draw'
|
||||
: 'Chat';
|
||||
|
||||
const buttonIcon =
|
||||
micContinuation ? <AutoModeIcon />
|
||||
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
|
||||
: isReAct ? <PsychologyIcon />
|
||||
: isTextBeam ? <ChatBeamIcon /> /* <GavelIcon /> */
|
||||
: isDraw ? <FormatPaintTwoToneIcon />
|
||||
: <TelegramIcon />;
|
||||
|
||||
let textPlaceholder: string =
|
||||
isDraw
|
||||
? 'Describe an idea or a drawing...'
|
||||
: isReAct
|
||||
? 'Multi-step reasoning question...'
|
||||
: 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';
|
||||
isDraw ? 'Describe an idea or a drawing...'
|
||||
: isReAct ? 'Multi-step reasoning question...'
|
||||
: isTextBeam ? 'Beam: combine the smarts of models...'
|
||||
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
|
||||
: props.capabilityHasT2I ? 'Chat · /beam · /draw · drop files...'
|
||||
: 'Chat · /react · drop files...';
|
||||
if (isDesktop && timeToShowTips) {
|
||||
if (explainShiftEnter)
|
||||
textPlaceholder += !enterIsNewline ? '\n\n💡 Shift + Enter to add a new line' : '\n\n💡 Shift + Enter to send';
|
||||
else if (explainAltEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Alt + Enter to just append the message');
|
||||
else if (labsBeam && explainCtrlEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Ctrl + Enter to beam');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box aria-label='User Message' component='section' sx={props.sx}>
|
||||
@@ -496,7 +577,7 @@ export function Composer(props: {
|
||||
</Dropdown>
|
||||
|
||||
{/* [Mobile] MultiChat button */}
|
||||
{props.isMulticast !== null && <ButtonMultiChat isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
{props.isMulticast !== null && <ButtonMultiChatMemo isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
</> : <>
|
||||
|
||||
@@ -561,7 +642,7 @@ export function Composer(props: {
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': { backgroundColor: 'background.popup' },
|
||||
lineHeight: lineHeightTextarea,
|
||||
lineHeight: lineHeightTextareaMd,
|
||||
}} />
|
||||
|
||||
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
|
||||
@@ -578,7 +659,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,
|
||||
@@ -605,7 +686,7 @@ export function Composer(props: {
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.solidBg',
|
||||
borderRadius: 'sm',
|
||||
zIndex: 20,
|
||||
zIndex: zIndexComposerOverlayMic,
|
||||
px: 1.5, py: 1,
|
||||
}}>
|
||||
<Typography>
|
||||
@@ -618,16 +699,8 @@ export function Composer(props: {
|
||||
{/* overlay: Drag & Drop*/}
|
||||
{!isMobile && (
|
||||
<Card
|
||||
color='success' variant='soft' invertedColors
|
||||
sx={{
|
||||
display: isDragging ? 'flex' : 'none',
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
border: '2px dashed',
|
||||
borderRadius: 'xs',
|
||||
boxShadow: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
color={isDragging ? 'success' : undefined} variant={isDragging ? 'soft' : undefined} invertedColors={isDragging}
|
||||
sx={isDragging ? dropppedCardDraggingSx : dropperCardSx}
|
||||
onDragLeave={handleOverlayDragLeave}
|
||||
onDragOver={handleOverlayDragOver}
|
||||
onDrop={handleOverlayDrop}
|
||||
@@ -655,14 +728,15 @@ export function Composer(props: {
|
||||
|
||||
|
||||
<Grid xs={12} md={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' }}>
|
||||
<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 && (isChat
|
||||
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
{isMobile && (showChatExtras
|
||||
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
|
||||
: isDraw
|
||||
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
@@ -670,11 +744,12 @@ export function Composer(props: {
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
<ButtonGroup
|
||||
variant={isAppend ? 'outlined' : 'solid'}
|
||||
variant={buttonVariant}
|
||||
color={buttonColor}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
boxShadow: isMobile ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
|
||||
backgroundColor: (isMobile && buttonVariant === 'outlined') ? 'background.popup' : undefined,
|
||||
boxShadow: (isMobile && buttonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
|
||||
}}
|
||||
>
|
||||
{!assistantAbortible ? (
|
||||
@@ -682,16 +757,10 @@ export function Composer(props: {
|
||||
key='composer-act'
|
||||
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
onClick={handleSendClicked}
|
||||
endDecorator={
|
||||
micContinuation ? <AutoModeIcon /> :
|
||||
isAppend ? <SendIcon sx={{ fontSize: 18 }} /> :
|
||||
isReAct ? <PsychologyIcon /> :
|
||||
isDraw ? <FormatPaintIcon />
|
||||
: <TelegramIcon />
|
||||
}
|
||||
endDecorator={buttonIcon}
|
||||
sx={{ '--Button-gap': '1rem' }}
|
||||
>
|
||||
{micContinuation && 'Voice '}
|
||||
{isAppend ? 'Write' : isReAct ? 'ReAct' : isDraw ? 'Draw' : 'Chat'}
|
||||
{micContinuation && 'Voice '}{buttonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -699,12 +768,19 @@ export function Composer(props: {
|
||||
fullWidth variant='soft' disabled={!props.conversationId}
|
||||
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={!props.conversationId || !chatLLMId} onClick={handleSendTextBeamClicked}>*/}
|
||||
{/* <ChatBeamIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/*</Tooltip>}*/}
|
||||
|
||||
{/* [Draw] Imagine */}
|
||||
{isDraw && !!composeText && <Tooltip title='Imagine a drawing prompt'>
|
||||
<IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleTextImagineClicked}>
|
||||
@@ -722,16 +798,24 @@ export function Composer(props: {
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* [desktop] secondary-top buttons */}
|
||||
{labsBeam && isDesktop && showChatExtras && !assistantAbortible && (
|
||||
<ButtonBeamMemo
|
||||
disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
onClick={handleSendTextBeamClicked}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
{/* [desktop] Multicast switch (under the Chat button) */}
|
||||
{isDesktop && props.isMulticast !== null && <ButtonMultiChat multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
{isDesktop && props.isMulticast !== null && <ButtonMultiChatMemo multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
{showChatExtras && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -746,6 +830,7 @@ export function Composer(props: {
|
||||
{/* Mode selector */}
|
||||
{!!chatModeMenuAnchor && (
|
||||
<ChatModeMenu
|
||||
isMobile={isMobile}
|
||||
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
|
||||
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
|
||||
capabilityHasTTI={props.capabilityHasT2I}
|
||||
|
||||
@@ -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)}
|
||||
@@ -64,7 +64,7 @@ export function ActilePopup(props: {
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
|
||||
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
|
||||
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
|
||||
</Typography>
|
||||
{item.argument && <Typography level='body-sm'>
|
||||
{item.argument}
|
||||
|
||||
@@ -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,46 @@
|
||||
import { conversationTitle, DConversationId, messageHasUserFlag, useChatStore } from '~/common/state/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) + ' - ' + message.text.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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import CodeIcon from '@mui/icons-material/Code';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
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';
|
||||
@@ -73,6 +74,7 @@ const converterTypeToIconMap: { [key in AttachmentConverterType]: React.Componen
|
||||
'pdf-images': PictureAsPdfIcon,
|
||||
'image': ImageOutlinedIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'ego-message-md': TelegramIcon,
|
||||
'unhandled': TextureIcon,
|
||||
};
|
||||
|
||||
@@ -126,7 +128,7 @@ export function AttachmentItem(props: {
|
||||
|
||||
|
||||
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
onItemMenuToggle(attachment.id, event.currentTarget);
|
||||
}, [attachment, onItemMenuToggle]);
|
||||
|
||||
@@ -179,12 +181,12 @@ export function AttachmentItem(props: {
|
||||
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,
|
||||
borderColor: variant === 'soft' ? `${color}.solidBg` : undefined,
|
||||
borderRadius: 'sm',
|
||||
fontWeight: 'normal',
|
||||
...ATTACHMENT_MIN_STYLE,
|
||||
px: 1, py: 0.5,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
|
||||
@@ -68,8 +68,10 @@ export function Attachments(props: {
|
||||
|
||||
const handleOverallMenuHide = () => setOverallMenuAnchor(null);
|
||||
|
||||
const handleOverallMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
const handleOverallMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
};
|
||||
|
||||
|
||||
// overall operations
|
||||
@@ -112,6 +114,7 @@ export function Attachments(props: {
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
onClick={handleOverallMenuToggle}
|
||||
onContextMenu={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
|
||||
@@ -18,6 +18,7 @@ const PLAIN_TEXT_MIMETYPES: string[] = [
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
@@ -131,6 +132,18 @@ export async function attachmentLoadInputAsync(source: Readonly<AttachmentSource
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ego':
|
||||
edit({
|
||||
label: source.label,
|
||||
ref: source.blockTitle,
|
||||
input: {
|
||||
mimeType: 'ego/message',
|
||||
data: source.textPlain,
|
||||
dataSize: source.textPlain.length,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ inputLoading: false });
|
||||
@@ -191,6 +204,11 @@ export function attachmentDefineConverters(sourceType: AttachmentSource['media']
|
||||
converters.push({ id: 'image-ocr', name: 'As Text (OCR)' });
|
||||
break;
|
||||
|
||||
// EGO
|
||||
case input.mimeType === 'ego/message':
|
||||
converters.push({ id: 'ego-message-md', name: 'Message' });
|
||||
break;
|
||||
|
||||
// catch-all
|
||||
default:
|
||||
converters.push({ id: 'unhandled', name: `${input.mimeType}`, unsupported: true });
|
||||
@@ -332,6 +350,15 @@ export async function attachmentPerformConversion(attachment: Readonly<Attachmen
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ego-message-md':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: inputDataToString(input.data),
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'unhandled':
|
||||
// force the user to explicitly select 'as text' if they want to proceed
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,12 @@ export type AttachmentSource = {
|
||||
method: 'clipboard-read' | AttachmentSourceOriginDTO;
|
||||
textPlain?: string;
|
||||
textHtml?: string;
|
||||
} | {
|
||||
media: 'ego';
|
||||
method: 'ego-message';
|
||||
label: string;
|
||||
blockTitle: string;
|
||||
textPlain: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +47,7 @@ export type AttachmentConverterType =
|
||||
| 'text' | 'rich-text' | 'rich-text-table'
|
||||
| 'pdf-text' | 'pdf-images'
|
||||
| 'image' | 'image-ocr'
|
||||
| 'ego-message-md'
|
||||
| 'unhandled';
|
||||
|
||||
export type AttachmentConverter = {
|
||||
@@ -62,7 +69,7 @@ export type Attachment = {
|
||||
readonly id: AttachmentId;
|
||||
readonly source: AttachmentSource,
|
||||
label: string;
|
||||
ref: string;
|
||||
ref: string; // will be used in ```ref\n...``` for instance
|
||||
|
||||
inputLoading: boolean;
|
||||
inputError: string | null;
|
||||
|
||||
@@ -100,6 +100,16 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
}, [attachAppendFile, createAttachment, enableLoadURLs]);
|
||||
|
||||
|
||||
const attachAppendEgoMessage = React.useCallback((blockTitle: string, textPlain: string, attachmentLabel: string) => {
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendEgo', { blockTitle, textPlain, attachmentLabel });
|
||||
|
||||
return createAttachment({
|
||||
media: 'ego', method: 'ego-message', label: attachmentLabel, blockTitle: blockTitle, textPlain: textPlain,
|
||||
});
|
||||
}, [createAttachment]);
|
||||
|
||||
|
||||
const attachAppendClipboardItems = React.useCallback(async () => {
|
||||
|
||||
// if there's an issue accessing the clipboard, show it passively
|
||||
@@ -178,6 +188,7 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
// create attachments
|
||||
attachAppendClipboardItems,
|
||||
attachAppendDataTransfer,
|
||||
attachAppendEgoMessage,
|
||||
attachAppendFile,
|
||||
|
||||
// manage attachments
|
||||
|
||||
@@ -131,11 +131,13 @@ function attachmentCollapseOutputs(initialTextBlockText: string | null, outputs:
|
||||
// start a new part
|
||||
else {
|
||||
if (output.type === 'text-block') {
|
||||
// THIS IS NOT CORRECT - we seem to be doing it just for downstream token counting - FIX IT
|
||||
// Do not serialize here
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
|
||||
title: null,
|
||||
collapsible: false,
|
||||
collapsible: false, // Wrong
|
||||
});
|
||||
} else {
|
||||
accumulatedOutputs.push(output);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 />
|
||||
{/*{platformAwareKeystrokes('Ctrl + Enter')}*/}
|
||||
<KeyStroke combo='Ctrl + Enter' sx={{ mt: 0.5, mb: 0.25 }} />
|
||||
</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, onClick: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
|
||||
<ChatBeamIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={desktopLegend}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<ChatBeamIcon />} sx={desktopSx}>
|
||||
Beam
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,25 @@ const callConversationLegend =
|
||||
Quick call regarding this chat
|
||||
</Box>;
|
||||
|
||||
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void, sx?: SxProps }) {
|
||||
const mobileSx: SxProps = {
|
||||
mr: { xs: 1, md: 2 },
|
||||
} as const;
|
||||
|
||||
const desktopSx: SxProps = {
|
||||
'--Button-gap': '1rem',
|
||||
} as const;
|
||||
|
||||
|
||||
export const ButtonCallMemo = React.memo(ButtonCall);
|
||||
|
||||
function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={props.sx}>
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={desktopSx}>
|
||||
Call
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ChatMulticastOnIcon } from '~/common/components/icons/ChatMulticastOnIc
|
||||
import { ChatMulticastOffIcon } from '~/common/components/icons/ChatMulticastOffIcon';
|
||||
|
||||
|
||||
export const ButtonMultiChatMemo = React.memo(ButtonMultiChat);
|
||||
|
||||
export function ButtonMultiChat(props: { isMobile?: boolean, multiChat: boolean, onSetMultiChat: (multiChat: boolean) => void }) {
|
||||
const { multiChat } = props;
|
||||
return props.isMobile ? (
|
||||
@@ -20,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,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, ListItem, ListItemDecorator } from '@mui/joy';
|
||||
import { ListItem, ListItemButton, ListItemDecorator } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
@@ -31,41 +31,37 @@ export function AddFolderButton() {
|
||||
};
|
||||
|
||||
return isAddingFolder ? (
|
||||
<ListItem sx={{
|
||||
'--ListItem-paddingLeft': '0.75rem',
|
||||
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
}}>
|
||||
<ListItem>
|
||||
<ListItemDecorator>
|
||||
<FolderIcon style={{ color: newFolderColor || 'inherit' }} />
|
||||
</ListItemDecorator>
|
||||
<InlineTextarea
|
||||
initialText='' placeholder='Folder Name'
|
||||
initialText=''
|
||||
placeholder='Folder Name'
|
||||
onEdit={handleCreateFolder}
|
||||
onCancel={handleCancelAddFolder}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
}} />
|
||||
sx={{ ml: -1.5, mr: -0.5, flexGrow: 1, minWidth: 100 }}
|
||||
/>
|
||||
{/*<IconButton color='danger' onClick={handleCancelAddFolder}>*/}
|
||||
{/* <CloseIcon />*/}
|
||||
{/* <CloseRoundedIcon />*/}
|
||||
{/*</IconButton>*/}
|
||||
</ListItem>
|
||||
) : (
|
||||
<Button
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
startDecorator={<AddIcon />}
|
||||
onClick={handleAddFolder}
|
||||
sx={{
|
||||
// display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||
// minHeight: '3rem', // --Folder-ListItem-height
|
||||
// match the forder elements
|
||||
paddingInline: '1.2rem',
|
||||
gap: '0.75rem',
|
||||
// fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
onClick={handleAddFolder}
|
||||
sx={{
|
||||
// equal to the 'new chat' button
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
color: 'neutral.outlinedColor',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<AddIcon sx={{ '--Icon-fontSize': 'var(--joy-fontSize-xl)', pl: '0.125rem' }} />
|
||||
</ListItemDecorator>
|
||||
New folder
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { List, ListItem, ListItemButton, ListItemContent, ListItemDecorator, Sheet, Typography } from '@mui/joy';
|
||||
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: {
|
||||
folders: DFolder[];
|
||||
contentScaling: ContentScaling;
|
||||
activeFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
|
||||
// derived props
|
||||
@@ -29,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)',
|
||||
@@ -47,8 +56,11 @@ export function ChatFolderList(props: {
|
||||
},
|
||||
// copied from the former PageDrawerList as this was contained
|
||||
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
|
||||
'--ListItemDecorator-size': '2.75rem',
|
||||
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
|
||||
|
||||
// dynamic sizing
|
||||
...themeScalingMap[props.contentScaling].chatDrawerItemFolderSx,
|
||||
// '--ListItemDecorator-size': '2.75rem',
|
||||
// '--ListItem-minHeight': '2.75rem',
|
||||
|
||||
'--List-radius': '8px',
|
||||
'--List-gap': '1rem',
|
||||
@@ -64,6 +76,7 @@ export function ChatFolderList(props: {
|
||||
'--joy-palette-neutral-plainHoverBg': 'rgba(255 255 255 / 0.1)',
|
||||
'--joy-palette-neutral-plainActiveBg': 'rgba(255 255 255 / 0.16)',
|
||||
},
|
||||
boxShadow: 'sm',
|
||||
})}
|
||||
>
|
||||
<ListItem nested>
|
||||
@@ -92,21 +105,12 @@ export function ChatFolderList(props: {
|
||||
onFolderSelect(null);
|
||||
}}
|
||||
selected={!activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
'&:hover .menu-icon': {
|
||||
visibility: 'visible', // Hide delete icon for default folder
|
||||
},
|
||||
}}
|
||||
sx={{ border: 0 }}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<FolderIcon />
|
||||
</ListItemDecorator>
|
||||
|
||||
<ListItemContent>
|
||||
<Typography>All</Typography>
|
||||
</ListItemContent>
|
||||
All
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
@@ -123,7 +127,10 @@ export function ChatFolderList(props: {
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
|
||||
<AddFolderButton />
|
||||
</List>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
@@ -131,7 +138,6 @@ export function ChatFolderList(props: {
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<AddFolderButton />
|
||||
</Sheet>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
|
||||
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet, Typography } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet } from '@mui/joy';
|
||||
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';
|
||||
|
||||
@@ -182,7 +182,7 @@ export function FolderListItem(props: {
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography>{folder.title}</Typography>
|
||||
{folder.title}
|
||||
</ListItemContent>
|
||||
)}
|
||||
|
||||
@@ -193,6 +193,7 @@ export function FolderListItem(props: {
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
visibility: 'hidden',
|
||||
my: '-0.25rem', /* absorb the button padding */
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
@@ -213,7 +214,7 @@ export function FolderListItem(props: {
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<EditIcon />
|
||||
<EditRoundedIcon />
|
||||
</ListItemDecorator>
|
||||
Edit
|
||||
</MenuItem>
|
||||
@@ -229,7 +230,7 @@ export function FolderListItem(props: {
|
||||
<>
|
||||
<MenuItem onClick={handleDeleteCanceled}>
|
||||
<ListItemDecorator>
|
||||
<CloseIcon />
|
||||
<CloseRoundedIcon />
|
||||
</ListItemDecorator>
|
||||
Cancel
|
||||
</MenuItem>
|
||||
@@ -256,7 +257,7 @@ export function FolderListItem(props: {
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'xl',
|
||||
fontWeight: 'xl', /* 700: this COLOR labels stands out positively */
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import AccountTreeTwoToneIcon from '@mui/icons-material/AccountTreeTwoTone';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { BlocksRenderer, editBlocksSx } from '~/modules/blocks/BlocksRenderer';
|
||||
import { useSanityTextDiffs } from '~/modules/blocks/RenderTextDiff';
|
||||
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
import { DMessage, DMessageUserFlag, messageHasUserFlag } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { adjustContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
import { prettyBaseModel } from '~/common/util/modelUtils';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { BlocksRenderer, editBlocksSx } from './blocks/BlocksRenderer';
|
||||
import { useChatShowTextDiff } from '../../store-app-chat';
|
||||
import { useSanityTextDiffs } from './blocks/RenderTextDiff';
|
||||
|
||||
|
||||
// Enable the menu on text selection
|
||||
@@ -48,17 +56,37 @@ export function messageBackground(messageRole: DMessage['role'] | string, wasEdi
|
||||
case 'assistant':
|
||||
return unknownAssistantIssue ? 'danger.softBg' : 'background.surface';
|
||||
case 'system':
|
||||
return wasEdited ? 'warning.softHoverBg' : 'background.surface';
|
||||
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
|
||||
default:
|
||||
return '#ff0000';
|
||||
}
|
||||
}
|
||||
|
||||
const avatarIconSx = { width: 36, height: 36 };
|
||||
const avatarIconSx = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
};
|
||||
|
||||
const personaSx: SxProps = {
|
||||
// make this stick to the top of the screen
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
minWidth: { xs: 50, md: 64 },
|
||||
maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
|
||||
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
|
||||
if (typeof messageAvatar === 'string' && messageAvatar)
|
||||
return <Avatar alt={messageSender} src={messageAvatar} />;
|
||||
|
||||
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
|
||||
switch (messageRole) {
|
||||
case 'system':
|
||||
@@ -69,36 +97,40 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
|
||||
|
||||
case 'assistant':
|
||||
// typing gif (people seem to love this, so keeping it after april fools')
|
||||
const isDownload = messageOriginLLM === 'web';
|
||||
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
|
||||
const isReact = messageOriginLLM?.startsWith('react-');
|
||||
if (messageTyping) {
|
||||
|
||||
// animation: message typing
|
||||
if (messageTyping)
|
||||
return <Avatar
|
||||
alt={messageSender} variant='plain'
|
||||
src={isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp'
|
||||
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp'
|
||||
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'}
|
||||
src={isDownload ? 'https://i.giphy.com/26u6dIwIphLj8h10A.webp' // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
|
||||
: isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp' // brush
|
||||
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp' // mind
|
||||
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'} // typing
|
||||
sx={{ ...mascotSx, borderRadius: 'sm' }}
|
||||
/>;
|
||||
}
|
||||
|
||||
// text-to-image: icon
|
||||
// icon: text-to-image
|
||||
if (isTextToImage)
|
||||
return <FormatPaintIcon sx={{
|
||||
return <FormatPaintTwoToneIcon sx={{
|
||||
...avatarIconSx,
|
||||
animation: `${cssRainbowColorKeyframes} 1s linear 2.66`,
|
||||
animation: `${animationColorRainbow} 1s linear 2.66`,
|
||||
}} />;
|
||||
|
||||
// purpose symbol (if present)
|
||||
const symbol = SystemPurposes[messagePurposeId!]?.symbol;
|
||||
if (symbol) return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
if (symbol)
|
||||
return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
|
||||
// default assistant avatar
|
||||
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
@@ -177,22 +209,29 @@ export const ChatMessageMemo = React.memo(ChatMessage);
|
||||
* or collapsing long user messages.
|
||||
*
|
||||
*/
|
||||
function ChatMessage(props: {
|
||||
export function ChatMessage(props: {
|
||||
message: DMessage,
|
||||
diffPreviousText?: string,
|
||||
fitScreen: boolean,
|
||||
isBottom?: boolean,
|
||||
isMobile?: boolean,
|
||||
isImagining?: boolean,
|
||||
isSpeaking?: boolean,
|
||||
blocksShowDate?: boolean,
|
||||
onConversationBranch?: (messageId: string) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
|
||||
onConversationTruncate?: (messageId: string) => void,
|
||||
showAvatar?: boolean, // auto if undefined
|
||||
showBlocksDate?: boolean,
|
||||
showUnsafeHtml?: boolean,
|
||||
adjustContentScaling?: number,
|
||||
topDecorator?: React.ReactNode,
|
||||
onMessageAssistantFrom?: (messageId: string, offset: number) => Promise<void>,
|
||||
onMessageBeam?: (messageId: string) => Promise<void>,
|
||||
onMessageBranch?: (messageId: string) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
onMessageToggleUserFlag?: (messageId: string, flag: DMessageUserFlag) => void,
|
||||
onMessageTruncate?: (messageId: string) => void,
|
||||
onTextDiagram?: (messageId: string, text: string) => Promise<void>
|
||||
onTextImagine?: (text: string) => Promise<void>
|
||||
onTextSpeak?: (text: string) => Promise<void>
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -203,10 +242,11 @@ function ChatMessage(props: {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { cleanerLooks, doubleClickToEdit, messageTextSize, renderMarkdown } = useUIPreferencesStore(state => ({
|
||||
cleanerLooks: state.zenMode === 'cleaner',
|
||||
const labsBeam = useUXLabsStore(state => state.labsBeam);
|
||||
const { showAvatar, contentScaling, doubleClickToEdit, renderMarkdown } = useUIPreferencesStore(state => ({
|
||||
showAvatar: props.showAvatar !== undefined ? props.showAvatar : state.zenMode !== 'cleaner',
|
||||
contentScaling: adjustContentScaling(state.contentScaling, props.adjustContentScaling),
|
||||
doubleClickToEdit: state.doubleClickToEdit,
|
||||
messageTextSize: state.messageTextSize,
|
||||
renderMarkdown: state.renderMarkdown,
|
||||
}), shallow);
|
||||
const [showDiff, setShowDiff] = useChatShowTextDiff();
|
||||
@@ -226,12 +266,12 @@ function ChatMessage(props: {
|
||||
updated: messageUpdated,
|
||||
} = props.message;
|
||||
|
||||
const isUserStarred = messageHasUserFlag(props.message, 'starred');
|
||||
|
||||
const fromAssistant = messageRole === 'assistant';
|
||||
const fromSystem = messageRole === 'system';
|
||||
const wasEdited = !!messageUpdated;
|
||||
|
||||
const showAvatars = !cleanerLooks;
|
||||
|
||||
const textSel = selMenuText ? selMenuText : messageText;
|
||||
const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img ');
|
||||
const couldDiagram = textSel?.length >= 100 && !isSpecialT2I;
|
||||
@@ -248,6 +288,8 @@ function ChatMessage(props: {
|
||||
|
||||
// Operations Menu
|
||||
|
||||
const { onMessageToggleUserFlag } = props;
|
||||
|
||||
const closeOpsMenu = () => setOpsMenuAnchor(null);
|
||||
|
||||
const handleOpsCopy = (e: React.MouseEvent) => {
|
||||
@@ -264,17 +306,27 @@ function ChatMessage(props: {
|
||||
closeOpsMenu();
|
||||
}, [isEditing, messageTyping]);
|
||||
|
||||
const handleOpsConversationBranch = (e: React.MouseEvent) => {
|
||||
const handleOpsToggleStarred = React.useCallback(() => {
|
||||
onMessageToggleUserFlag?.(messageId, 'starred');
|
||||
}, [messageId, onMessageToggleUserFlag]);
|
||||
|
||||
const handleOpsAssistantFrom = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // to try to not steal the focus from the banched conversation
|
||||
props.onConversationBranch && props.onConversationBranch(messageId);
|
||||
closeOpsMenu();
|
||||
await props.onMessageAssistantFrom?.(messageId, fromAssistant ? -1 : 0);
|
||||
};
|
||||
|
||||
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const handleOpsBeamFrom = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeOpsMenu();
|
||||
labsBeam && await props.onMessageBeam?.(messageId);
|
||||
};
|
||||
|
||||
const handleOpsBranch = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // to try to not steal the focus from the banched conversation
|
||||
props.onMessageBranch?.(messageId);
|
||||
closeOpsMenu();
|
||||
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
};
|
||||
|
||||
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
|
||||
@@ -307,12 +359,12 @@ function ChatMessage(props: {
|
||||
};
|
||||
|
||||
const handleOpsTruncate = (_e: React.MouseEvent) => {
|
||||
props.onConversationTruncate && props.onConversationTruncate(messageId);
|
||||
props.onMessageTruncate?.(messageId);
|
||||
closeOpsMenu();
|
||||
};
|
||||
|
||||
const handleOpsDelete = (_e: React.MouseEvent) => {
|
||||
props.onMessageDelete && props.onMessageDelete(messageId);
|
||||
props.onMessageDelete?.(messageId);
|
||||
};
|
||||
|
||||
|
||||
@@ -386,53 +438,84 @@ function ChatMessage(props: {
|
||||
|
||||
// avatar
|
||||
const avatarEl: React.JSX.Element | null = React.useMemo(
|
||||
() => showAvatars ? makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping) : null,
|
||||
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatars],
|
||||
() => showAvatar ? makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping) : null,
|
||||
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatar],
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
role='chat-message'
|
||||
sx={{
|
||||
display: 'flex', flexDirection: !fromAssistant ? 'row-reverse' : 'row', alignItems: 'flex-start',
|
||||
gap: { xs: 0, md: 1 },
|
||||
px: { xs: 1, md: 2 },
|
||||
py: 2,
|
||||
backgroundColor,
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'divider',
|
||||
...(ENABLE_COPY_MESSAGE_OVERLAY && { position: 'relative' }),
|
||||
// style
|
||||
backgroundColor: backgroundColor,
|
||||
px: { xs: 1, md: themeScalingMap[contentScaling]?.chatMessagePadding ?? 2 },
|
||||
py: themeScalingMap[contentScaling]?.chatMessagePadding ?? 2,
|
||||
|
||||
// style: omit border if set externally
|
||||
...(!('borderBottom' in (props.sx || {})) && {
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'divider',
|
||||
}),
|
||||
|
||||
// style: when starred
|
||||
...(isUserStarred && {
|
||||
outline: '3px solid',
|
||||
outlineColor: 'primary.solidBg',
|
||||
boxShadow: 'lg',
|
||||
borderRadius: 'lg',
|
||||
zIndex: 1,
|
||||
}),
|
||||
|
||||
// style: make room for a top decorator if set
|
||||
...(!!props.topDecorator && {
|
||||
pt: '2.5rem',
|
||||
}),
|
||||
'&:hover > button': { opacity: 1 },
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: !fromAssistant ? 'row-reverse' : 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: { xs: 0, md: 1 },
|
||||
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Avatar */}
|
||||
{showAvatars && (
|
||||
<Box
|
||||
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
|
||||
onClick={event => setOpsMenuAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
minWidth: { xs: 50, md: 64 }, maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* (Optional) underlayed top decorator */}
|
||||
{props.topDecorator && (
|
||||
<Box sx={{ position: 'absolute', left: 0, right: 0, top: 0, textAlign: 'center' }}>
|
||||
{props.topDecorator}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isHovering ? (
|
||||
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'} sx={avatarIconSx}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
avatarEl
|
||||
)}
|
||||
{/* Avatar (Persona) */}
|
||||
{showAvatar && (
|
||||
<Box sx={personaSx}>
|
||||
|
||||
{/* Persona Avatar or Menu Button */}
|
||||
<Box
|
||||
onClick={event => setOpsMenuAnchor(event.currentTarget)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
sx={{ display: 'flex' }}
|
||||
>
|
||||
{(isHovering || opsMenuAnchor) ? (
|
||||
<IconButton variant={opsMenuAnchor ? 'solid' : 'soft'} color={(fromAssistant || fromSystem) ? 'neutral' : 'primary'} sx={avatarIconSx}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
avatarEl
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Assistant model name */}
|
||||
{fromAssistant && (
|
||||
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
|
||||
<Tooltip arrow title={messageTyping ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
|
||||
<Typography level='body-xs' sx={{
|
||||
overflowWrap: 'anywhere',
|
||||
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
|
||||
...(messageTyping ? { animation: `${animationColorRainbow} 5s linear infinite` } : {}),
|
||||
}}>
|
||||
{prettyBaseModel(messageOriginLLM)}
|
||||
</Typography>
|
||||
@@ -456,16 +539,18 @@ function ChatMessage(props: {
|
||||
<BlocksRenderer
|
||||
text={messageText}
|
||||
fromRole={messageRole}
|
||||
renderTextAsMarkdown={renderMarkdown}
|
||||
messageTextSize={messageTextSize}
|
||||
contentScaling={contentScaling}
|
||||
errorMessage={errorMessage}
|
||||
fitScreen={props.fitScreen}
|
||||
isBottom={props.isBottom}
|
||||
isMobile={props.isMobile}
|
||||
showDate={props.blocksShowDate === true ? messageUpdated || messageCreated || undefined : undefined}
|
||||
renderTextAsMarkdown={renderMarkdown}
|
||||
renderTextDiff={textDiffs || undefined}
|
||||
showDate={props.showBlocksDate === true ? messageUpdated || messageCreated || undefined : undefined}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
wasUserEdited={wasEdited}
|
||||
onContextMenu={(props.onMessageEdit && ENABLE_SELECTION_RIGHT_CLICK_MENU) ? handleBlocksContextMenu : undefined}
|
||||
onDoubleClick={(props.onMessageEdit && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
|
||||
optiAllowMemo={messageTyping}
|
||||
/>
|
||||
|
||||
)}
|
||||
@@ -473,7 +558,7 @@ function ChatMessage(props: {
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
|
||||
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
|
||||
<Tooltip title={messageTyping ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' onClick={handleOpsCopy}
|
||||
sx={{
|
||||
@@ -493,31 +578,44 @@ function ChatMessage(props: {
|
||||
open anchorEl={opsMenuAnchor} onClose={closeOpsMenu}
|
||||
sx={{ minWidth: 280 }}
|
||||
>
|
||||
|
||||
{fromSystem && (
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>
|
||||
System message
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{/* Edit / Copy */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* Edit */}
|
||||
{!!props.onMessageEdit && (
|
||||
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><EditIcon /></ListItemDecorator>
|
||||
<ListItemDecorator><EditRoundedIcon /></ListItemDecorator>
|
||||
{isEditing ? 'Discard' : 'Edit'}
|
||||
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Copy */}
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy
|
||||
</MenuItem>
|
||||
{/* Starred */}
|
||||
{!!onMessageToggleUserFlag && (
|
||||
<MenuItem onClick={handleOpsToggleStarred} sx={{ flexGrow: 0, px: 1 }}>
|
||||
{isUserStarred
|
||||
? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />
|
||||
: <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />
|
||||
}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Box>
|
||||
{/* Delete / Branch / Truncate */}
|
||||
{!!props.onMessageDelete && <ListDivider />}
|
||||
{!!props.onMessageDelete && (
|
||||
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Delete
|
||||
<span style={{ opacity: 0.5 }}>message</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onConversationBranch && (
|
||||
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
|
||||
{!!props.onMessageBranch && <ListDivider />}
|
||||
{!!props.onMessageBranch && (
|
||||
<MenuItem onClick={handleOpsBranch} disabled={fromSystem}>
|
||||
<ListItemDecorator>
|
||||
<ForkRightIcon />
|
||||
</ListItemDecorator>
|
||||
@@ -525,7 +623,14 @@ function ChatMessage(props: {
|
||||
{!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onConversationTruncate && (
|
||||
{!!props.onMessageDelete && (
|
||||
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Delete
|
||||
<span style={{ opacity: 0.5 }}>message</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onMessageTruncate && (
|
||||
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Truncate
|
||||
@@ -545,35 +650,44 @@ function ChatMessage(props: {
|
||||
{!!props.onTextDiagram && <ListDivider />}
|
||||
{!!props.onTextDiagram && (
|
||||
<MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Diagram ...
|
||||
<ListItemDecorator><AccountTreeTwoToneIcon /></ListItemDecorator>
|
||||
Auto-Diagram ...
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onTextImagine && (
|
||||
<MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
Draw ...
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintTwoToneIcon />}</ListItemDecorator>
|
||||
Auto-Draw
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onTextSpeak && (
|
||||
<MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverTwoToneIcon />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Restart/try */}
|
||||
{!!props.onConversationRestartFrom && <ListDivider />}
|
||||
{!!props.onConversationRestartFrom && (
|
||||
<MenuItem onClick={handleOpsConversationRestartFrom}>
|
||||
{/* Beam/Restart */}
|
||||
{(!!props.onMessageAssistantFrom || !!props.onMessageBeam) && <ListDivider />}
|
||||
{!!props.onMessageAssistantFrom && (
|
||||
<MenuItem disabled={fromSystem} onClick={handleOpsAssistantFrom}>
|
||||
<ListItemDecorator>{fromAssistant ? <ReplayIcon color='primary' /> : <TelegramIcon color='primary' />}</ListItemDecorator>
|
||||
{!fromAssistant
|
||||
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: !props.isBottom
|
||||
? <>Retry <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Retry
|
||||
<KeyStroke combo='Ctrl + Shift + R' />
|
||||
</Box>}
|
||||
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>Retry<KeyStroke combo='Ctrl + Shift + R' /></Box>}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onMessageBeam && labsBeam && (
|
||||
<MenuItem disabled={fromSystem} onClick={handleOpsBeamFrom}>
|
||||
<ListItemDecorator>
|
||||
<ChatBeamIcon color={fromSystem ? undefined : 'primary'} />
|
||||
</ListItemDecorator>
|
||||
{!fromAssistant
|
||||
? <>Beam <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: !props.isBottom
|
||||
? <>Beam <span style={{ opacity: 0.5 }}>this message</span></>
|
||||
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>Beam<KeyStroke combo='Ctrl + Shift + B' /></Box>}
|
||||
</MenuItem>
|
||||
)}
|
||||
</CloseableMenu>
|
||||
@@ -586,20 +700,21 @@ function ChatMessage(props: {
|
||||
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1, alignItems: 'center' }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy <span style={{ opacity: 0.5 }}>selection</span>
|
||||
Copy
|
||||
</MenuItem>
|
||||
{!!props.onTextDiagram && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Diagram ...
|
||||
<ListItemDecorator><AccountTreeTwoToneIcon /></ListItemDecorator>
|
||||
Auto-Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
Imagine
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintTwoToneIcon />}</ListItemDecorator>
|
||||
Auto-Draw
|
||||
</MenuItem>}
|
||||
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverTwoToneIcon />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>}
|
||||
</CloseableMenu>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { makeAvatar, messageBackground } from './ChatMessage';
|
||||
export const MessagesSelectionHeader = (props: { hasSelected: boolean, sumTokens: number, onClose: () => void, onSelectAll: (selected: boolean) => void, onDeleteMessages: () => void }) =>
|
||||
<Sheet color='warning' variant='solid' invertedColors sx={{
|
||||
display: 'flex', flexDirection: 'row', alignItems: 'center',
|
||||
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 101,
|
||||
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 101 /* Cleanup Selection Header on top of messages */,
|
||||
boxShadow: 'md',
|
||||
gap: { xs: 1, sm: 2 }, px: { xs: 1, md: 2 }, py: 1,
|
||||
}}>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, IconButton, Tooltip, Typography } from '@mui/joy';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
import type { ImageBlock } from './blocks';
|
||||
import { overlayButtonsSx } from './code/RenderCode';
|
||||
|
||||
|
||||
const mdImageReferenceRegex = /^!\[([^\]]*)]\(([^)]+)\)$/;
|
||||
const imageExtensions = /\.(jpg|jpeg|png|gif|bmp|svg)/i;
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the entire content consists solely of Markdown image references.
|
||||
* If so, returns an array of ImageBlock objects for each image reference.
|
||||
* If any non-image content is present or if there are no image references, returns null.
|
||||
*/
|
||||
export function heuristicMarkdownImageReferenceBlocks(fullText: string) {
|
||||
|
||||
// Check if all lines are valid Markdown image references with image URLs
|
||||
const imageBlocks: ImageBlock[] = [];
|
||||
for (const line of fullText.split('\n')) {
|
||||
if (line.trim() === '') continue; // skip empty lines
|
||||
const match = mdImageReferenceRegex.exec(line);
|
||||
if (match && imageExtensions.test(match[2])) {
|
||||
const alt = match[1];
|
||||
const url = match[2];
|
||||
imageBlocks.push({ type: 'image', url, alt });
|
||||
} else {
|
||||
// if there is any outlier line, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the image blocks if all lines are image references with valid image URLs
|
||||
return imageBlocks.length > 0 ? imageBlocks : null;
|
||||
}
|
||||
|
||||
const prodiaUrlRegex = /^(https?:\/\/images\.prodia\.\S+)$/i;
|
||||
|
||||
/**
|
||||
* Legacy heuristic for detecting images from "images.prodia." URLs.
|
||||
*/
|
||||
export function heuristicLegacyImageBlocks(fullText: string): ImageBlock[] | null {
|
||||
|
||||
// Check if all lines are URLs starting with "http://images.prodia." or "https://images.prodia."
|
||||
const imageBlocks: ImageBlock[] = [];
|
||||
for (const line of fullText.split('\n')) {
|
||||
const match = prodiaUrlRegex.exec(line);
|
||||
if (match) {
|
||||
const url = match[1];
|
||||
imageBlocks.push({ type: 'image', url });
|
||||
} else {
|
||||
// if there is any outlier line, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the image blocks if all lines are URLs from "images.prodia."
|
||||
return imageBlocks.length > 0 ? imageBlocks : null;
|
||||
}
|
||||
|
||||
|
||||
export const RenderImage = (props: { imageBlock: ImageBlock, isFirst: boolean, allowRunAgain: boolean, onRunAgain?: (e: React.MouseEvent) => void }) => {
|
||||
const { url, alt } = props.imageBlock;
|
||||
const imageUrls = url.split('\n');
|
||||
|
||||
return imageUrls.map((url, index) => {
|
||||
|
||||
// display a notice for temporary images DallE
|
||||
const isTempDalleUrl = url.startsWith('https://oaidalle');
|
||||
|
||||
return <Box
|
||||
key={'gen-img-' + index}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
|
||||
mx: 1.5, mb: 1.5, // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
|
||||
// p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||
minWidth: 128, minHeight: 128,
|
||||
boxShadow: 'md',
|
||||
backgroundColor: 'neutral.solidBg',
|
||||
'& picture': { display: 'flex' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%' },
|
||||
'&:hover > .overlay-buttons': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
|
||||
{/* External Image */}
|
||||
{alt ? (
|
||||
<Tooltip
|
||||
variant='outlined' color='neutral'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{isTempDalleUrl && <Alert variant='soft' color='warning' sx={{ flexDirection: 'column', alignItems: 'start' }}>
|
||||
<Typography level='title-sm'>⚠️ Temporary Image</Typography>
|
||||
<Typography level='body-sm'>
|
||||
This image will be deleted from the OpenAI servers in one hour. <b>Please save it to your device</b>.
|
||||
</Typography>
|
||||
{/*<Typography level='body-xs'>*/}
|
||||
{/* The following is the re-written DALL·E prompt that generated this image.*/}
|
||||
{/*</Typography>*/}
|
||||
</Alert>}
|
||||
<Typography level='title-sm' sx={{ p: 2 }}>
|
||||
{alt}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
placement='top-start'
|
||||
sx={{
|
||||
maxWidth: { sm: '90vw', md: '70vw' },
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<picture><img src={url} alt={`Generated Image: ${alt}`} /></picture>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<picture><img src={url} alt='Generated Image' /></picture>
|
||||
)}
|
||||
|
||||
{/* Image Buttons */}
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, pt: 0.5, px: 0.5, gap: 0.5 }}>
|
||||
{props.allowRunAgain && !!props.onRunAgain && (
|
||||
<Tooltip title='Draw again' variant='solid'>
|
||||
<IconButton variant='solid' onClick={props.onRunAgain}>
|
||||
<ReplayIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title='Open in new tab'>
|
||||
<IconButton component={Link} href={url} download={alt || 'image'} target='_blank' variant='solid'>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>;
|
||||
});
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { CSVLink } from 'react-csv';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button, styled } from '@mui/joy';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { lineHeightChatText } from '~/common/app.theme';
|
||||
|
||||
import type { TextBlock } from './blocks';
|
||||
|
||||
|
||||
/*
|
||||
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
|
||||
*/
|
||||
const RenderMarkdownBox = styled(Box)({
|
||||
// same look as the other RenderComponents
|
||||
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
|
||||
lineHeight: lineHeightChatText,
|
||||
|
||||
// patch the CSS
|
||||
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
|
||||
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
|
||||
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
|
||||
});
|
||||
|
||||
|
||||
// Dynamically import ReactMarkdown using React.lazy
|
||||
const DynamicReactGFM = React.lazy(async () => {
|
||||
const [markdownModule, remarkGfmModule] = await Promise.all([
|
||||
import('react-markdown'),
|
||||
import('remark-gfm'),
|
||||
]);
|
||||
|
||||
// NOTE: extracted here instead of inline as a large performance optimization
|
||||
const remarkPlugins = [remarkGfmModule.default];
|
||||
|
||||
//Extracts table data from jsx element in table renderer
|
||||
const extractTableData = (children: React.JSX.Element) => {
|
||||
// Function to extract text from a React element or component
|
||||
const extractText = (element: any): String => {
|
||||
// Base case: if the element is a string, return it
|
||||
if (typeof element === 'string') {
|
||||
return element;
|
||||
}
|
||||
// If the element has children, recursively extract text from them
|
||||
if (element.props && element.props.children) {
|
||||
if (Array.isArray(element.props.children)) {
|
||||
return element.props.children.map(extractText).join('');
|
||||
}
|
||||
return extractText(element.props.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Function to traverse and extract data from table rows and cells
|
||||
const traverseAndExtract = (elements: any, tableData: any[] = []) => {
|
||||
React.Children.forEach(elements, (element) => {
|
||||
if (element.type === 'tr') {
|
||||
const rowData = React.Children.map(element.props.children, (cell) => {
|
||||
// Extract and return the text content of each cell
|
||||
return extractText(cell);
|
||||
});
|
||||
tableData.push(rowData);
|
||||
} else if (element.props && element.props.children) {
|
||||
traverseAndExtract(element.props.children, tableData);
|
||||
}
|
||||
});
|
||||
return tableData;
|
||||
};
|
||||
|
||||
return traverseAndExtract(children);
|
||||
};
|
||||
|
||||
interface TableRendererProps {
|
||||
children: React.JSX.Element;
|
||||
node?: any; // an optional field we want to not pass to the <table/> element
|
||||
}
|
||||
|
||||
// Define a custom table renderer
|
||||
const TableRenderer = ({ children, node, ...props }: TableRendererProps) => {
|
||||
// Apply custom styles or modifications here
|
||||
const tableData = extractTableData(children);
|
||||
|
||||
return (
|
||||
<>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: '0.5rem' }} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
<CSVLink filename='big-agi-export' data={tableData}>
|
||||
<Button variant='outlined' color='neutral' size='md' endDecorator={<DownloadIcon />} sx={{
|
||||
mb: '1rem',
|
||||
backgroundColor: 'background.popup', // make this button 'pop' a bit from the page
|
||||
}}>
|
||||
Download table as .csv
|
||||
</Button>
|
||||
</CSVLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Use the custom renderer for tables
|
||||
const components = {
|
||||
table: TableRenderer,
|
||||
// Add custom renderers for other elements if needed
|
||||
};
|
||||
|
||||
// Pass the dynamically imported remarkGfm as children
|
||||
const ReactMarkdownWithRemarkGfm = (props: any) =>
|
||||
<markdownModule.default
|
||||
remarkPlugins={remarkPlugins}
|
||||
{...props}
|
||||
components={components}
|
||||
/>;
|
||||
|
||||
return { default: ReactMarkdownWithRemarkGfm };
|
||||
});
|
||||
|
||||
function RenderMarkdown(props: { textBlock: TextBlock; sx?: SxProps; }) {
|
||||
return (
|
||||
<RenderMarkdownBox
|
||||
className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */
|
||||
sx={props.sx}
|
||||
>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<DynamicReactGFM>
|
||||
{props.textBlock.content}
|
||||
</DynamicReactGFM>
|
||||
</React.Suspense>
|
||||
</RenderMarkdownBox>
|
||||
);
|
||||
}
|
||||
|
||||
export const RenderMarkdownMemo = React.memo(RenderMarkdown);
|
||||
@@ -1,50 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, Tooltip } from '@mui/joy';
|
||||
|
||||
interface CodeBlockProps {
|
||||
codeBlock: {
|
||||
code: string;
|
||||
language?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ButtonCodepen({ codeBlock }: CodeBlockProps): React.JSX.Element {
|
||||
const { code, language } = codeBlock;
|
||||
const hasCSS = language === 'css';
|
||||
const hasJS = ['javascript', 'json', 'typescript'].includes(language || '');
|
||||
const hasHTML = !hasCSS && !hasJS; // use HTML as fallback if an unanticipated frontend language is used
|
||||
|
||||
const handleOpenInCodepen = () => {
|
||||
const data = {
|
||||
title: `GPT ${new Date().toISOString()}`, // eg "GPT 2021-08-31T15:00:00.000Z"
|
||||
css: hasCSS ? code : '',
|
||||
html: hasHTML ? code : '',
|
||||
js: hasJS ? code : '',
|
||||
editors: `${hasHTML ? 1 : 0}${hasCSS ? 1 : 0}${hasJS ? 1 : 0}` // eg '101' for HTML, JS
|
||||
};
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = 'https://codepen.io/pen/define';
|
||||
form.target = '_blank';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'data';
|
||||
input.value = JSON.stringify(data);
|
||||
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title='Open in Codepen' variant='solid'>
|
||||
<Button variant='outlined' color='neutral' onClick={handleOpenInCodepen}>
|
||||
Codepen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, Tooltip } from '@mui/joy';
|
||||
|
||||
interface CodeBlockProps {
|
||||
codeBlock: {
|
||||
code: string;
|
||||
language?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ButtonReplit({ codeBlock }: CodeBlockProps): React.JSX.Element {
|
||||
const { language } = codeBlock;
|
||||
|
||||
const replitLanguageMap: Record<string, string> = {
|
||||
python: 'python3',
|
||||
csharp: 'csharp',
|
||||
java: 'java',
|
||||
};
|
||||
|
||||
const handleOpenInReplit = () => {
|
||||
const replitLanguage = replitLanguageMap[language || 'python'];
|
||||
const url = new URL(`https://replit.com/languages/${replitLanguage}`);
|
||||
window.open(url.toString(), '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={`Open in Replit (${codeBlock.language})`} variant='solid'>
|
||||
<Button variant='outlined' color='neutral' onClick={handleOpenInReplit}>
|
||||
Replit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
@@ -9,25 +10,36 @@ import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
// change this to increase/decrease the number history steps per pane
|
||||
const MAX_HISTORY_LENGTH = 10;
|
||||
|
||||
// change this to allow for more/less panes
|
||||
const MAX_CONCURRENT_PANES = 4;
|
||||
|
||||
// change to true to enable verbose console logging
|
||||
const DEBUG_PANES_MANAGER = false;
|
||||
|
||||
|
||||
interface ChatPane {
|
||||
|
||||
paneId: string;
|
||||
|
||||
conversationId: DConversationId | null;
|
||||
|
||||
// other per-pane storage? or would this be cluttering the panes(view)-only abstaction?
|
||||
// ... we are currently creating companion ConversationHandler obects for this
|
||||
|
||||
history: DConversationId[]; // History of the conversationIds for this pane
|
||||
historyIndex: number; // Current position in the history for this pane
|
||||
|
||||
}
|
||||
|
||||
interface AppChatPanesStore {
|
||||
interface AppChatPanesState {
|
||||
|
||||
// state
|
||||
chatPanes: ChatPane[];
|
||||
chatPaneFocusIndex: number | null;
|
||||
|
||||
}
|
||||
|
||||
interface AppChatPanesStore extends AppChatPanesState {
|
||||
|
||||
// actions
|
||||
openConversationInFocusedPane: (conversationId: DConversationId) => void;
|
||||
openConversationInSplitPane: (conversationId: DConversationId) => void;
|
||||
@@ -35,19 +47,29 @@ interface AppChatPanesStore {
|
||||
duplicateFocusedPane: (/*paneIndex: number*/) => void;
|
||||
removeOtherPanes: () => void;
|
||||
removePane: (paneIndex: number) => void;
|
||||
setFocusedPane: (paneIndex: number) => void;
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) => void;
|
||||
setFocusedPaneIndex: (paneIndex: number) => void;
|
||||
_onConversationsChanged: (conversationIds: DConversationId[]) => void;
|
||||
|
||||
}
|
||||
|
||||
function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
conversationId,
|
||||
history: conversationId ? [conversationId] : [],
|
||||
historyIndex: conversationId ? 0 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
function duplicatePane(pane: ChatPane): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
conversationId: pane.conversationId,
|
||||
history: [...pane.history],
|
||||
historyIndex: pane.historyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
@@ -68,8 +90,14 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the conversation is already open in the focused pane.
|
||||
// Sanity check: Get the focused pane
|
||||
const focusedPane = chatPanes[chatPaneFocusIndex];
|
||||
if (!focusedPane) {
|
||||
console.warn('openConversationInFocusedPane: focusedPane is null', chatPaneFocusIndex, chatPanes);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Check if the conversation is already open in the focused pane.
|
||||
if (focusedPane.conversationId === conversationId) {
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-focuses: ${conversationId} is open in focused pane`, chatPaneFocusIndex, chatPanes);
|
||||
@@ -80,7 +108,7 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
const truncatedHistory = focusedPane.history.slice(0, focusedPane.historyIndex + 1);
|
||||
const newHistory = [...truncatedHistory, conversationId].slice(-MAX_HISTORY_LENGTH);
|
||||
|
||||
// Update the focused pane with the new conversation.
|
||||
// Update the focused pane with the new conversation and history.
|
||||
const newPanes = [...chatPanes];
|
||||
newPanes[chatPaneFocusIndex] = {
|
||||
...focusedPane,
|
||||
@@ -103,21 +131,30 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
// Open a conversation in a new pane, reusing an existing pane if possible.
|
||||
const { chatPanes, chatPaneFocusIndex, openConversationInFocusedPane } = _get();
|
||||
|
||||
// one pane open: split it
|
||||
if (chatPanes.length === 1) {
|
||||
_set({
|
||||
chatPanes: Array.from({ length: 2 }, () => ({ ...chatPanes[0] })),
|
||||
chatPaneFocusIndex: 1,
|
||||
});
|
||||
// Copy from the focused pane, if there's one
|
||||
const focusedPane = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex] ?? null : null;
|
||||
|
||||
// if fewer than the maximum panes, create a new pane and focus it
|
||||
if (chatPanes.length < MAX_CONCURRENT_PANES) {
|
||||
const insertIndex = chatPaneFocusIndex !== null ? chatPaneFocusIndex + 1 : chatPanes.length;
|
||||
_set((state) => ({
|
||||
chatPanes: [
|
||||
...state.chatPanes.slice(0, insertIndex),
|
||||
focusedPane ? duplicatePane(focusedPane) : createPane(null),
|
||||
...state.chatPanes.slice(insertIndex),
|
||||
],
|
||||
chatPaneFocusIndex: insertIndex,
|
||||
}));
|
||||
}
|
||||
// more than 2 panes, reuse the alt pane
|
||||
else if (chatPanes.length >= 2 && chatPaneFocusIndex !== null) {
|
||||
// max reached, replace the next pane (with wraparound) - note the outside logic won't get us here
|
||||
else {
|
||||
const replaceIndex = (chatPaneFocusIndex !== null ? chatPaneFocusIndex + 1 : 0) % MAX_CONCURRENT_PANES;
|
||||
_set({
|
||||
chatPaneFocusIndex: chatPaneFocusIndex === 0 ? 1 : 0,
|
||||
chatPaneFocusIndex: replaceIndex,
|
||||
});
|
||||
}
|
||||
|
||||
// will create a pane if none exists, or load the conversation in the focused pane
|
||||
// Open the conversation in the newly created or updated pane
|
||||
openConversationInFocusedPane(conversationId);
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
@@ -171,21 +208,18 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
|
||||
// Clone the pane at the specified index, including a deep copy of the history array
|
||||
const paneToDuplicate = chatPanes[_srcIndex];
|
||||
const duplicatedPane = {
|
||||
...paneToDuplicate,
|
||||
history: [...paneToDuplicate.history], // Deep copy of the history array
|
||||
};
|
||||
const dstIndex = _srcIndex + 1;
|
||||
|
||||
// Insert the duplicated pane into the array, right after the original pane
|
||||
const newPanes = [
|
||||
...chatPanes.slice(0, _srcIndex + 1),
|
||||
duplicatedPane,
|
||||
...chatPanes.slice(_srcIndex + 1),
|
||||
...chatPanes.slice(0, dstIndex),
|
||||
duplicatePane(paneToDuplicate),
|
||||
...chatPanes.slice(dstIndex),
|
||||
];
|
||||
|
||||
return {
|
||||
chatPanes: newPanes,
|
||||
chatPaneFocusIndex: _srcIndex + 1,
|
||||
chatPaneFocusIndex: dstIndex,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -217,7 +251,7 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
};
|
||||
}),
|
||||
|
||||
setFocusedPane: (paneIndex: number) =>
|
||||
setFocusedPaneIndex: (paneIndex: number) =>
|
||||
_set(state => {
|
||||
if (state.chatPaneFocusIndex === paneIndex)
|
||||
return state;
|
||||
@@ -232,7 +266,7 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
* It takes care of `creating the first pane` as well as `removing invalid history items, reassiging
|
||||
* conversationIds, and re-focusing the pane`.
|
||||
*/
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) =>
|
||||
_onConversationsChanged: (conversationIds: DConversationId[]) =>
|
||||
_set(state => {
|
||||
const { chatPanes, chatPaneFocusIndex } = state;
|
||||
|
||||
@@ -284,47 +318,40 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
}),
|
||||
|
||||
}), {
|
||||
name: 'app-app-chat-panes',
|
||||
// note: added the '-2' suffix on 20240308 to invalidate the persisted state, as we are adding a paneId
|
||||
name: 'app-app-chat-panes-2',
|
||||
},
|
||||
));
|
||||
|
||||
export function getInstantAppChatPanesCount() {
|
||||
return useAppChatPanesStore.getState().chatPanes.length;
|
||||
}
|
||||
|
||||
export function usePanesManager() {
|
||||
// use Panes
|
||||
const { onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(state => {
|
||||
const {
|
||||
chatPaneFocusIndex,
|
||||
chatPanes,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
removePane,
|
||||
setFocusedPane,
|
||||
} = state;
|
||||
const focusedConversationId = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex]?.conversationId ?? null : null;
|
||||
return {
|
||||
chatPanes: chatPanes as Readonly<ChatPane[]>,
|
||||
focusedConversationId,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
focusedPaneIndex: chatPaneFocusIndex,
|
||||
removePane,
|
||||
setFocusedPane,
|
||||
};
|
||||
}, shallow);
|
||||
const { _onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(useShallow(state => ({
|
||||
// state
|
||||
chatPanes: state.chatPanes as Readonly<ChatPane[]>,
|
||||
focusedPaneIndex: state.chatPaneFocusIndex,
|
||||
focusedPaneConversationId: state.chatPaneFocusIndex !== null ? state.chatPanes[state.chatPaneFocusIndex]?.conversationId ?? null : null,
|
||||
// methods
|
||||
openConversationInFocusedPane: state.openConversationInFocusedPane,
|
||||
openConversationInSplitPane: state.openConversationInSplitPane,
|
||||
navigateHistoryInFocusedPane: state.navigateHistoryInFocusedPane,
|
||||
removePane: state.removePane,
|
||||
setFocusedPaneIndex: state.setFocusedPaneIndex,
|
||||
_onConversationsChanged: state._onConversationsChanged,
|
||||
})));
|
||||
|
||||
// use Conversation IDs[]
|
||||
const conversationIDs: DConversationId[] = useChatStore(state => {
|
||||
return state.conversations.map(_c => _c.id);
|
||||
}, shallow);
|
||||
const conversationIDs: DConversationId[] = useChatStore(useShallow(state =>
|
||||
state.conversations.map(_c => _c.id),
|
||||
));
|
||||
|
||||
// [Effect] Ensure all Panes have a valid Conversation ID
|
||||
React.useEffect(() => {
|
||||
onConversationsChanged(conversationIDs);
|
||||
}, [conversationIDs, onConversationsChanged]);
|
||||
_onConversationsChanged(conversationIDs);
|
||||
}, [conversationIDs, _onConversationsChanged]);
|
||||
|
||||
return {
|
||||
...panesFunctions,
|
||||
@@ -332,10 +359,12 @@ export function usePanesManager() {
|
||||
}
|
||||
|
||||
export function usePaneDuplicateOrClose() {
|
||||
return useAppChatPanesStore(state => ({
|
||||
canAddPane: state.chatPanes.length < 4,
|
||||
return useAppChatPanesStore(useShallow(state => ({
|
||||
// state
|
||||
canAddPane: state.chatPanes.length < MAX_CONCURRENT_PANES,
|
||||
isMultiPane: state.chatPanes.length > 1,
|
||||
// actions
|
||||
duplicateFocusedPane: state.duplicateFocusedPane,
|
||||
removeOtherPanes: state.removeOtherPanes,
|
||||
}), shallow);
|
||||
})));
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { Alert, Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
@@ -14,7 +15,7 @@ import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { navigateToPersonas } from '~/common/app.routes';
|
||||
import { useChipBoolean } from '~/common/components/useChipBoolean';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
@@ -25,9 +26,10 @@ import { usePurposeStore } from './store-purposes';
|
||||
|
||||
// 'special' purpose IDs, for tile hiding purposes
|
||||
const PURPOSE_ID_PERSONA_CREATOR = '__persona-creator__';
|
||||
const TILE_ACTIVE_COLOR = 'primary' as const;
|
||||
|
||||
// defined looks
|
||||
const tileSize = 7.5; // rem
|
||||
const tileSize = 7; // rem
|
||||
const tileGap = 0.5; // rem
|
||||
|
||||
|
||||
@@ -45,29 +47,36 @@ function Tile(props: {
|
||||
return (
|
||||
<Button
|
||||
variant={(!props.isEditMode && props.isActive) ? 'solid' : props.isHighlighted ? 'soft' : 'soft'}
|
||||
color={(!props.isEditMode && props.isActive) ? 'primary' : props.isHighlighted ? 'primary' : 'neutral'}
|
||||
color={(!props.isEditMode && props.isActive) ? 'primary' : props.isHighlighted ? 'primary' : TILE_ACTIVE_COLOR}
|
||||
onClick={props.onClick}
|
||||
sx={{
|
||||
aspectRatio: 1,
|
||||
height: `${tileSize}rem`,
|
||||
fontWeight: 500,
|
||||
fontWeight: 'md',
|
||||
lineHeight: 'xs',
|
||||
...((props.isEditMode || !props.isActive) ? {
|
||||
boxShadow: props.isHighlighted ? '0 2px 8px -2px rgb(var(--joy-palette-primary-mainChannel) / 50%)' : 'sm',
|
||||
backgroundColor: props.isHighlighted ? undefined : 'background.surface',
|
||||
...(props.imageUrl && {
|
||||
backgroundImage: `linear-gradient(rgba(255 255 255 /0.85), rgba(255 255 255 /1)), url(${props.imageUrl})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
}),
|
||||
boxShadow: `0 2px 8px -3px rgb(var(--joy-palette-${TILE_ACTIVE_COLOR}-darkChannel) / 30%)`,
|
||||
// boxShadow: props.isHighlighted
|
||||
// ? '0 2px 8px -2px rgb(var(--joy-palette-primary-darkChannel) / 30%)'
|
||||
// : 'sm',
|
||||
backgroundColor: props.isHighlighted ? undefined : 'background.popup',
|
||||
// ...(props.imageUrl && {
|
||||
// backgroundImage: `linear-gradient(rgba(255 255 255 /0.85), rgba(255 255 255 /1)), url(${props.imageUrl})`,
|
||||
// backgroundPosition: 'center',
|
||||
// backgroundSize: 'cover',
|
||||
// '&:hover': {
|
||||
// backgroundImage: 'none',
|
||||
// },
|
||||
// }),
|
||||
} : {}),
|
||||
flexDirection: 'column', gap: 1,
|
||||
flexDirection: 'column', gap: props.symbol === '🎭' ? 0.5 : 1.25, pt: 1.25,
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{/* [Edit mode checkbox] */}
|
||||
{props.isEditMode && (
|
||||
<Checkbox
|
||||
variant='soft' color='neutral'
|
||||
variant='soft' color={TILE_ACTIVE_COLOR}
|
||||
checked={!props.isHidden}
|
||||
// label={<Typography level='body-xs'>show</Typography>}
|
||||
sx={{ position: 'absolute', left: `${tileGap}rem`, top: `${tileGap}rem` }}
|
||||
@@ -125,6 +134,8 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
|
||||
// derived state
|
||||
|
||||
const isCustomPurpose = systemPurposeId === 'Custom';
|
||||
|
||||
const { selectedPurpose, fourExamples } = React.useMemo(() => {
|
||||
const selectedPurpose: SystemPurposeData | null = systemPurposeId ? (SystemPurposes[systemPurposeId] ?? null) : null;
|
||||
// const selectedExample = selectedPurpose?.examples?.length
|
||||
@@ -153,6 +164,13 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
SystemPurposes['Custom'].systemMessage = v.target.value;
|
||||
}, []);
|
||||
|
||||
const handleSwitchToCustom = React.useCallback((customText: string) => {
|
||||
if (setSystemPurposeId) {
|
||||
SystemPurposes['Custom'].systemMessage = customText;
|
||||
setSystemPurposeId(props.conversationId, 'Custom');
|
||||
}
|
||||
}, [props.conversationId, setSystemPurposeId]);
|
||||
|
||||
const toggleEditMode = React.useCallback(() => setEditMode(on => !on), []);
|
||||
|
||||
|
||||
@@ -246,7 +264,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
</Typography>
|
||||
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
|
||||
<IconButton size='sm' onClick={toggleEditMode} sx={{ my: '-0.25rem' /* absorb the button padding */ }}>
|
||||
{editMode ? <DoneIcon /> : <EditIcon />}
|
||||
{editMode ? <DoneIcon /> : <EditRoundedIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@@ -280,6 +298,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
isHidden={hidePersonaCreator}
|
||||
onClick={() => editMode ? toggleHiddenPurposeId(PURPOSE_ID_PERSONA_CREATOR) : void navigateToPersonas()}
|
||||
sx={{
|
||||
fontSize: 'xs',
|
||||
boxShadow: 'xs',
|
||||
backgroundColor: 'neutral.softDisabledBg',
|
||||
}}
|
||||
@@ -298,37 +317,39 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
: selectedPurpose?.description || 'No description available'}
|
||||
</Typography>
|
||||
{/* Examples Toggle */}
|
||||
{/*<Box sx={{ display: 'flex', flexFlow: 'row wrap', flexShrink: 1 }}>*/}
|
||||
{fourExamples && showExamplescomponent}
|
||||
{showPromptComponent}
|
||||
{!isCustomPurpose && showPromptComponent}
|
||||
{/*</Box>*/}
|
||||
</Box>
|
||||
|
||||
{/* [row -3] Example incipits */}
|
||||
{systemPurposeId !== 'Custom' && (
|
||||
<ExpanderControlledBox expanded={showExamples || showPrompt} sx={{ gridColumn: '1 / -1', pt: 1 }}>
|
||||
<ExpanderControlledBox expanded={showExamples || (!isCustomPurpose && showPrompt)} sx={{ gridColumn: '1 / -1', pt: 1 }}>
|
||||
{showExamples && (
|
||||
<List
|
||||
aria-label='Persona Conversation Starters'
|
||||
sx={{
|
||||
// example items 2-col layout
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize * 2 + 1}rem, 1fr))`,
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize * 3 + 1}rem, 1fr))`,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{fourExamples?.map((example, idx) => (
|
||||
<ListItem
|
||||
key={idx}
|
||||
variant='soft'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
// padding: '0.25rem 0.5rem',
|
||||
backgroundColor: 'background.popup',
|
||||
borderRadius: 'md',
|
||||
// boxShadow: 'xs',
|
||||
padding: '0.25rem 0.5rem',
|
||||
backgroundColor: 'background.surface',
|
||||
boxShadow: 'xs',
|
||||
'& svg': { opacity: 0.1, transition: 'opacity 0.2s' },
|
||||
'&:hover svg': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<ListItemButton onClick={() => props.runExample(example)} sx={{ justifyContent: 'space-between' }}>
|
||||
<ListItemButton onClick={() => props.runExample(example)} sx={{ justifyContent: 'space-between', borderRadius: 'md' }}>
|
||||
<Typography level='body-sm'>
|
||||
{example}
|
||||
</Typography>
|
||||
@@ -338,15 +359,32 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{showPrompt && (
|
||||
{(!isCustomPurpose && showPrompt) && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography level='title-sm'>
|
||||
System Prompt
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography level='title-sm'>
|
||||
System Prompt
|
||||
</Typography>
|
||||
<Button
|
||||
variant='plain' color='neutral' size='sm'
|
||||
endDecorator={<EditNoteIcon />}
|
||||
onClick={() => handleSwitchToCustom(bareBonesPromptMixer(selectedPurpose?.systemMessage || 'No system message available', chatLLM?.id))}
|
||||
sx={{ ml: 'auto', my: '-0.25rem' /* absorb the button padding */ }}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces' }}>
|
||||
{bareBonesPromptMixer(selectedPurpose?.systemMessage || 'No system message available', chatLLM?.id)}
|
||||
</Typography>
|
||||
{!!selectedPurpose?.systemMessageNotes && (
|
||||
<Alert sx={{ m: -1, mt: 1, p: 1 }}>
|
||||
<Typography level='body-xs'>
|
||||
Prompt notes: {selectedPurpose.systemMessageNotes}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -363,9 +401,11 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
defaultValue={SystemPurposes['Custom']?.systemMessage}
|
||||
onChange={handleCustomSystemMessageChange}
|
||||
endDecorator={
|
||||
<Typography level='body-sm' sx={{ px: 0.75 }}>
|
||||
Just start chatting when done.
|
||||
</Typography>
|
||||
<Alert sx={{ flex: 1, p: 1 }}>
|
||||
<Typography level='body-xs'>
|
||||
Just start chatting when done.
|
||||
</Typography>
|
||||
</Alert>
|
||||
}
|
||||
sx={{
|
||||
gridColumn: '1 / -1',
|
||||
@@ -373,7 +413,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
lineHeight: lineHeightTextarea,
|
||||
lineHeight: lineHeightTextareaMd,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const usePurposeStore = create<PurposeStore>()(
|
||||
(set) => ({
|
||||
|
||||
// default state
|
||||
hiddenPurposeIDs: ['Designer'],
|
||||
hiddenPurposeIDs: ['Developer', 'Designer'],
|
||||
|
||||
toggleHiddenPurposeId: (purposeId: string) => {
|
||||
set(state => {
|
||||
@@ -34,5 +34,18 @@ export const usePurposeStore = create<PurposeStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'app-purpose',
|
||||
|
||||
/* versioning:
|
||||
* 1: hide 'Developer' as 'DeveloperPreview' is best
|
||||
*/
|
||||
version: 1,
|
||||
|
||||
migrate: (state: any, fromVersion: number): PurposeStore => {
|
||||
// 0 -> 1: rename 'enterToSend' to 'enterIsNewline' (flip the meaning)
|
||||
if (state && fromVersion === 0)
|
||||
if (!state.hiddenPurposeIDs.includes('Developer'))
|
||||
state.hiddenPurposeIDs.push('Developer');
|
||||
return state;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconButton } from '@mui/joy';
|
||||
import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown';
|
||||
|
||||
import { useScrollToBottom } from './useScrollToBottom';
|
||||
|
||||
|
||||
export function ScrollToBottomButton() {
|
||||
|
||||
// state
|
||||
const { atBottom, stickToBottom, setStickToBottom } = useScrollToBottom();
|
||||
|
||||
const handleStickToBottom = React.useCallback(() => {
|
||||
setStickToBottom(true);
|
||||
}, [setStickToBottom]);
|
||||
|
||||
// do not render the button at all if we're already snapping
|
||||
if (atBottom || stickToBottom)
|
||||
return null;
|
||||
|
||||
return (
|
||||
// <Tooltip title={
|
||||
// <Typography variant='solid' level='title-sm' sx={{ px: 1 }}>
|
||||
// Scroll to bottom
|
||||
// </Typography>
|
||||
// }>
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
onClick={handleStickToBottom}
|
||||
sx={{
|
||||
// place this on the bottom-right corner (FAB-like)
|
||||
position: 'absolute',
|
||||
bottom: '2rem',
|
||||
right: {
|
||||
xs: '1rem',
|
||||
md: '2rem',
|
||||
},
|
||||
|
||||
// style it
|
||||
backgroundColor: 'background.surface',
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'md',
|
||||
|
||||
// fade it in when hovering
|
||||
// transition: 'all 0.15s',
|
||||
// '&:hover': {
|
||||
// transform: 'scale(1.1)',
|
||||
// },
|
||||
}}
|
||||
>
|
||||
<KeyboardDoubleArrowDownIcon />
|
||||
</IconButton>
|
||||
// </Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { conversationTitle, DConversationId, DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import type { ChatNavigationItemData } from './ChatDrawerItem';
|
||||
|
||||
|
||||
// configuration
|
||||
const SEARCH_MIN_CHARS = 3;
|
||||
|
||||
|
||||
export type ChatNavGrouping = false | 'date' | 'persona';
|
||||
|
||||
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,
|
||||
grouping: ChatNavGrouping,
|
||||
searchSorting: ChatSearchSorting,
|
||||
showRelativeSize: boolean,
|
||||
): {
|
||||
renderNavItems: ChatRenderItemData[],
|
||||
filteredChatIDs: DConversationId[],
|
||||
filteredChatsCount: number,
|
||||
filteredChatsAreEmpty: boolean,
|
||||
filteredChatsBarBasis: number,
|
||||
filteredChatsIncludeActive: boolean,
|
||||
} {
|
||||
return 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);
|
||||
|
||||
// transform (the conversations into ChatNavigationItemData) + filter2 (if searching)
|
||||
const chatNavItems = selectedConversations
|
||||
.filter(_c => !filterHasStars || _c.messages.some(m => messageHasUserFlag(m, 'starred')))
|
||||
.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) => count + (message.text.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('');
|
||||
|
||||
// create the ChatNavigationData
|
||||
return {
|
||||
type: 'nav-item-chat-data',
|
||||
conversationId: _c.id,
|
||||
isActive: _c.id === activeConversationId,
|
||||
isAlsoOpen,
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title,
|
||||
userFlagsSummary,
|
||||
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,
|
||||
assistantTyping: !!_c.abortController,
|
||||
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) {
|
||||
|
||||
// [grouping/date]: sort by update time
|
||||
const midnightTime = getNextMidnightTime();
|
||||
if (grouping === 'date')
|
||||
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
// Array.groupBy(...)
|
||||
const grouped = chatNavItems.reduce((acc, item) => {
|
||||
|
||||
const groupName = grouping === 'date'
|
||||
? getTimeBucketEn(item.updatedAt || midnightTime, midnightTime)
|
||||
: item.systemPurposeId;
|
||||
|
||||
if (!acc[groupName])
|
||||
acc[groupName] = [];
|
||||
acc[groupName].push(item);
|
||||
return acc;
|
||||
}, {} as { [groupName: string]: ChatNavigationItemData[] });
|
||||
|
||||
// prepend groups
|
||||
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 ? '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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { usePurposeStore } from './persona-selector/store-purposes';
|
||||
|
||||
|
||||
function PersonaDropdown(props: {
|
||||
systemPurposeId: SystemPurposeId | null,
|
||||
@@ -14,11 +16,23 @@ function PersonaDropdown(props: {
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
|
||||
const { zenMode } = useUIPreferencesStore(state => ({
|
||||
zenMode: state.zenMode,
|
||||
}), shallow);
|
||||
|
||||
|
||||
// filter by key in the object - must be missing the system purpose ids hidden by the user, or be the currently active one
|
||||
const visibleSystemPurposes = React.useMemo(() => {
|
||||
return Object.keys(SystemPurposes)
|
||||
.filter(key => !hiddenPurposeIDs.includes(key as SystemPurposeId) || key === props.systemPurposeId)
|
||||
.reduce((obj, key) => {
|
||||
obj[key as SystemPurposeId] = SystemPurposes[key as SystemPurposeId];
|
||||
return obj;
|
||||
}, {} as typeof SystemPurposes);
|
||||
}, [hiddenPurposeIDs, props.systemPurposeId]);
|
||||
|
||||
|
||||
const { setSystemPurposeId } = props;
|
||||
|
||||
const handleSystemPurposeChange = React.useCallback((value: string | null) => {
|
||||
@@ -28,7 +42,7 @@ function PersonaDropdown(props: {
|
||||
|
||||
return (
|
||||
<PageBarDropdownMemo
|
||||
items={SystemPurposes}
|
||||
items={visibleSystemPurposes}
|
||||
value={props.systemPurposeId}
|
||||
onChange={handleSystemPurposeChange}
|
||||
showSymbols={zenMode !== 'cleaner'}
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import { createAssistantTypingMessage } from './editors';
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
|
||||
|
||||
export const runBrowseUpdatingState = async (conversationId: string, url: string) => {
|
||||
export const runBrowseGetPageUpdatingState = async (cHandler: ConversationHandler, url?: string) => {
|
||||
if (!url) {
|
||||
cHandler.messageAppendAssistant('Issue: no URL provided.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { editMessage } = useChatStore.getState();
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
// const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation
|
||||
// noinspection HttpUrlsUsage
|
||||
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`);
|
||||
const updateAssistantMessage = (update: Partial<DMessage>) => editMessage(conversationId, assistantMessageId, update, false);
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(`Loading page at ${shortUrl}...`, undefined, 'web', true);
|
||||
|
||||
try {
|
||||
|
||||
const page = await callBrowseFetchPage(url);
|
||||
if (!page.content) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('No text found.');
|
||||
}
|
||||
updateAssistantMessage({
|
||||
text: page.content,
|
||||
typing: false,
|
||||
});
|
||||
|
||||
cHandler.messageEdit(assistantMessageId, { text: page.content || 'Issue: page load did not produce an answer: no text found', typing: false }, true);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
updateAssistantMessage({
|
||||
text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').',
|
||||
typing: false,
|
||||
});
|
||||
cHandler.messageEdit(assistantMessageId, { text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').', typing: false }, true);
|
||||
}
|
||||
};
|
||||
@@ -1,102 +1,138 @@
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { SystemPurposeId } from '../../../data';
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
import type { StreamingClientUpdate } from '~/modules/llms/vendors/unifiedStreamingClient';
|
||||
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { llmStreamingChatGenerate } from '~/modules/llms/llm.client';
|
||||
import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
|
||||
import { DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import type { DMessage } from '~/common/state/store-chats';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
|
||||
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
|
||||
import { createAssistantTypingMessage, updatePurposeInHistory } from './editors';
|
||||
|
||||
|
||||
export const STREAM_TEXT_INDICATOR = '...';
|
||||
|
||||
|
||||
/**
|
||||
* The main "chat" function. TODO: this is here so we can soon move it to the data model.
|
||||
*/
|
||||
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId) {
|
||||
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, parallelViewCount: number) {
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// update the system message from the active Purpose, if not manually edited
|
||||
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
|
||||
|
||||
// create a blank and 'typing' message for the assistant
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, history[0].purposeId, assistantLlmId, true);
|
||||
|
||||
// when an abort controller is set, the UI switches to the "stop" mode
|
||||
const controller = new AbortController();
|
||||
const { startTyping, editMessage } = useChatStore.getState();
|
||||
startTyping(conversationId, controller);
|
||||
const abortController = new AbortController();
|
||||
cHandler.setAbortController(abortController);
|
||||
|
||||
// stream the assistant's messages
|
||||
await streamAssistantMessage(
|
||||
assistantLlmId, history,
|
||||
assistantLlmId,
|
||||
history.map((m): VChatMessageIn => ({ role: m.role, content: m.text })),
|
||||
parallelViewCount,
|
||||
autoSpeak,
|
||||
(updatedMessage) => editMessage(conversationId, assistantMessageId, updatedMessage, false),
|
||||
controller.signal,
|
||||
(update) => cHandler.messageEdit(assistantMessageId, update, false),
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// clear to send, again
|
||||
startTyping(conversationId, null);
|
||||
cHandler.setAbortController(null);
|
||||
|
||||
if (autoTitleChat)
|
||||
conversationAutoTitle(conversationId, false);
|
||||
if (autoTitleChat) {
|
||||
// fire/forget, this will only set the title if it's not already set
|
||||
void conversationAutoTitle(conversationId, false);
|
||||
}
|
||||
|
||||
if (autoSuggestDiagrams || autoSuggestQuestions)
|
||||
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
|
||||
}
|
||||
|
||||
type StreamMessageOutcome = 'success' | 'aborted' | 'errored';
|
||||
type StreamMessageStatus = { outcome: StreamMessageOutcome, errorMessage?: string };
|
||||
|
||||
async function streamAssistantMessage(
|
||||
llmId: DLLMId, history: DMessage[],
|
||||
export async function streamAssistantMessage(
|
||||
llmId: DLLMId,
|
||||
messagesHistory: VChatMessageIn[],
|
||||
throttleUnits: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce the message frequency with the square root
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
editMessage: (updatedMessage: Partial<DMessage>) => void,
|
||||
editMessage: (update: Partial<DMessage>) => void,
|
||||
abortSignal: AbortSignal,
|
||||
) {
|
||||
): Promise<StreamMessageStatus> {
|
||||
|
||||
const returnStatus: StreamMessageStatus = {
|
||||
outcome: 'success',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
// speak once
|
||||
let spokenText = '';
|
||||
let spokenLine = false;
|
||||
|
||||
const messages = history.map(({ role, text }) => ({ role, content: text }));
|
||||
// Throttling setup
|
||||
let lastCallTime = 0;
|
||||
let throttleDelay = 1000 / 12; // 12 messages per second works well for 60Hz displays (single chat, and 24 in 4 chats, see the square root below)
|
||||
if (throttleUnits > 1)
|
||||
throttleDelay = Math.round(throttleDelay * Math.sqrt(throttleUnits));
|
||||
|
||||
try {
|
||||
await llmStreamingChatGenerate(llmId, messages, null, null, abortSignal,
|
||||
(updatedMessage: Partial<DMessage>) => {
|
||||
// update the message in the store (and thus schedule a re-render)
|
||||
editMessage(updatedMessage);
|
||||
|
||||
// 📢 TTS: first-line
|
||||
if (updatedMessage?.text) {
|
||||
spokenText = updatedMessage.text;
|
||||
if (autoSpeak === 'firstLine' && !spokenLine) {
|
||||
let cutPoint = spokenText.lastIndexOf('\n');
|
||||
if (cutPoint < 0)
|
||||
cutPoint = spokenText.lastIndexOf('. ');
|
||||
if (cutPoint > 100 && cutPoint < 400) {
|
||||
spokenLine = true;
|
||||
const firstParagraph = spokenText.substring(0, cutPoint);
|
||||
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void speakText(firstParagraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Fetch request error:', error);
|
||||
// TODO: show an error to the UI?
|
||||
function throttledEditMessage(updatedMessage: Partial<DMessage>) {
|
||||
const now = Date.now();
|
||||
if (throttleUnits === 0 || now - lastCallTime >= throttleDelay) {
|
||||
editMessage(updatedMessage);
|
||||
lastCallTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
// 📢 TTS: all
|
||||
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && spokenText && !spokenLine && !abortSignal.aborted)
|
||||
void speakText(spokenText);
|
||||
const incrementalAnswer: Partial<DMessage> = { text: '' };
|
||||
|
||||
// finally, stop the typing animation
|
||||
editMessage({ typing: false });
|
||||
try {
|
||||
await llmStreamingChatGenerate(llmId, messagesHistory, null, null, abortSignal, (update: StreamingClientUpdate) => {
|
||||
const textSoFar = update.textSoFar;
|
||||
|
||||
// grow the incremental message
|
||||
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
|
||||
if (textSoFar) incrementalAnswer.text = textSoFar;
|
||||
if (update.typing !== undefined) incrementalAnswer.typing = update.typing;
|
||||
|
||||
// Update the data store, with optional max-frequency throttling (e.g. OpenAI is downsamped 50 -> 12Hz)
|
||||
// This can be toggled from the settings
|
||||
throttledEditMessage(incrementalAnswer);
|
||||
|
||||
// 📢 TTS: first-line
|
||||
if (textSoFar && autoSpeak === 'firstLine' && !spokenLine) {
|
||||
let cutPoint = textSoFar.lastIndexOf('\n');
|
||||
if (cutPoint < 0)
|
||||
cutPoint = textSoFar.lastIndexOf('. ');
|
||||
if (cutPoint > 100 && cutPoint < 400) {
|
||||
spokenLine = true;
|
||||
const firstParagraph = textSoFar.substring(0, cutPoint);
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void speakText(firstParagraph);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Fetch request error:', error);
|
||||
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
|
||||
incrementalAnswer.text = (incrementalAnswer.text || '') + errorText;
|
||||
returnStatus.outcome = 'errored';
|
||||
returnStatus.errorMessage = error.message;
|
||||
} else
|
||||
returnStatus.outcome = 'aborted';
|
||||
}
|
||||
|
||||
// Optimized:
|
||||
// 1 - stop the typing animation
|
||||
// 2 - ensure the last content is flushed out
|
||||
editMessage({ ...incrementalAnswer, typing: false });
|
||||
|
||||
// 📢 TTS: all
|
||||
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && incrementalAnswer.text && !spokenLine && !abortSignal.aborted)
|
||||
void speakText(incrementalAnswer.text);
|
||||
|
||||
return returnStatus;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { DLLMId, getKnowledgeMapCutoff } from '~/modules/llms/store-llms';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
|
||||
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
|
||||
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | string /* 'DALL·E' | 'Prodia' | 'react-...' | 'web' */, assistantPurposeId: SystemPurposeId | undefined, text: string): string {
|
||||
const assistantMessage: DMessage = createDMessage('assistant', text);
|
||||
assistantMessage.typing = true;
|
||||
assistantMessage.purposeId = assistantPurposeId;
|
||||
assistantMessage.originLLM = assistantLlmLabel;
|
||||
useChatStore.getState().appendMessage(conversationId, assistantMessage);
|
||||
return assistantMessage.id;
|
||||
}
|
||||
|
||||
|
||||
export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] {
|
||||
const systemMessageIndex = history.findIndex(m => m.role === 'system');
|
||||
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
|
||||
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
|
||||
systemMessage.purposeId = purposeId;
|
||||
systemMessage.text = bareBonesPromptMixer(SystemPurposes[purposeId].systemMessage, assistantLlmId);
|
||||
|
||||
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
|
||||
if (purposeId === 'Custom')
|
||||
systemMessage.updated = Date.now();
|
||||
}
|
||||
history.unshift(systemMessage);
|
||||
useChatStore.getState().setMessages(conversationId, history);
|
||||
return history;
|
||||
}
|
||||
@@ -1,39 +1,43 @@
|
||||
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import { createAssistantTypingMessage } from './editors';
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import type { TextToImageProvider } from '~/common/components/useCapabilities';
|
||||
|
||||
|
||||
/**
|
||||
* Text to image, appended as an 'assistant' message
|
||||
*/
|
||||
export async function runImageGenerationUpdatingState(conversationId: string, imageText: string) {
|
||||
export async function runImageGenerationUpdatingState(cHandler: ConversationHandler, imageText?: string) {
|
||||
if (!imageText) {
|
||||
cHandler.messageAppendAssistant('Issue: no image description provided.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire the active TextToImageProvider
|
||||
let t2iProvider: TextToImageProvider | undefined = undefined;
|
||||
try {
|
||||
t2iProvider = getActiveTextToImageProviderOrThrow();
|
||||
} catch (error: any) {
|
||||
cHandler.messageAppendAssistant(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the imageText ends with " xN" or " [N]" (where N is a number), then we'll generate N images
|
||||
const match = imageText.match(/\sx(\d+)$|\s\[(\d+)]$/);
|
||||
const count = match ? parseInt(match[1] || match[2], 10) : 1;
|
||||
if (count > 1)
|
||||
const repeat = match ? parseInt(match[1] || match[2], 10) : 1;
|
||||
if (repeat > 1)
|
||||
imageText = imageText.replace(/x(\d+)$|\[(\d+)]$/, '').trim(); // Remove the "xN" or "[N]" part from the imageText
|
||||
|
||||
// create a blank and 'typing' message for the assistant
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, '', undefined,
|
||||
`Give me a few seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`);
|
||||
|
||||
// reference the state editing functions
|
||||
const { editMessage } = useChatStore.getState();
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(
|
||||
`Give me ${t2iProvider.vendor === 'openai' ? 'a dozen' : 'a few'} seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`,
|
||||
undefined, t2iProvider.painter, true,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
const t2iProvider = getActiveTextToImageProviderOrThrow();
|
||||
editMessage(conversationId, assistantMessageId, { originLLM: t2iProvider.painter }, false);
|
||||
|
||||
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, count);
|
||||
editMessage(conversationId, assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
|
||||
|
||||
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, repeat);
|
||||
cHandler.messageEdit(assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || error?.toString() || 'Unknown error';
|
||||
if (assistantMessageId)
|
||||
editMessage(conversationId, assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
|
||||
cHandler.messageEdit(assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
|
||||
}
|
||||
}
|
||||
@@ -2,37 +2,36 @@ import { Agent } from '~/modules/aifn/react/react';
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
|
||||
import { createAssistantTypingMessage } from './editors';
|
||||
import { STREAM_TEXT_INDICATOR } from './chat-stream';
|
||||
|
||||
const EPHEMERAL_DELETION_DELAY = 5 * 1000;
|
||||
|
||||
|
||||
/**
|
||||
* Synchronous ReAct chat function - TODO: event loop, auto-ui, cleanups, etc.
|
||||
*/
|
||||
export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) {
|
||||
|
||||
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
|
||||
const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState();
|
||||
export async function runReActUpdatingState(cHandler: ConversationHandler, question: string | undefined, assistantLlmId: DLLMId) {
|
||||
if (!question) {
|
||||
cHandler.messageAppendAssistant('Issue: no question provided.', undefined, 'issue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
const assistantModelLabel = 'react-' + assistantLlmId.slice(4, 7); // HACK: this is used to change the Avatar animation
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantModelLabel, undefined, '...');
|
||||
const updateAssistantMessage = (update: Partial<DMessage>) =>
|
||||
editMessage(conversationId, assistantMessageId, update, false);
|
||||
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, undefined, assistantModelLabel, true);
|
||||
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
|
||||
|
||||
// create an ephemeral space
|
||||
const ephemeral = createDEphemeral(`Reason+Act`, 'Initializing ReAct..');
|
||||
appendEphemeral(conversationId, ephemeral);
|
||||
|
||||
const eHandler = cHandler.createEphemeral(`Reason+Act`, 'Initializing ReAct..');
|
||||
let ephemeralText = '';
|
||||
const logToEphemeral = (text: string) => {
|
||||
console.log(text);
|
||||
ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n';
|
||||
updateEphemeralText(conversationId, ephemeral.id, ephemeralText);
|
||||
eHandler.updateText(ephemeralText);
|
||||
};
|
||||
const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state);
|
||||
const showStateInEphemeral = (state: object) => eHandler.updateState(state);
|
||||
|
||||
try {
|
||||
|
||||
@@ -40,12 +39,12 @@ export async function runReActUpdatingState(conversationId: string, question: st
|
||||
const agent = new Agent();
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
|
||||
|
||||
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 4 * 1000);
|
||||
updateAssistantMessage({ text: reactResult, typing: false });
|
||||
cHandler.messageEdit(assistantMessageId, { text: reactResult, typing: false }, false);
|
||||
setTimeout(() => eHandler.delete(), EPHEMERAL_DELETION_DELAY);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
logToEphemeral(ephemeralText + `\nIssue: ${error || 'unknown'}`);
|
||||
updateAssistantMessage({ text: 'Issue: ReAct did not produce an answer.', typing: false });
|
||||
cHandler.messageEdit(assistantMessageId, { text: 'Issue: ReAct did not produce an answer.', typing: false }, false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
||||
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
@@ -10,6 +11,8 @@ export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
|
||||
interface AppChatStore {
|
||||
|
||||
// chat AI
|
||||
|
||||
autoSpeak: ChatAutoSpeakType;
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
|
||||
|
||||
@@ -22,9 +25,20 @@ interface AppChatStore {
|
||||
autoTitleChat: boolean;
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => void;
|
||||
|
||||
// chat UI
|
||||
|
||||
filterHasStars: boolean;
|
||||
setFilterHasStars: (filterHasStars: boolean) => void;
|
||||
|
||||
micTimeoutMs: number;
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => void;
|
||||
|
||||
showPersonaIcons: boolean;
|
||||
setShowPersonaIcons: (showPersonaIcons: boolean) => void;
|
||||
|
||||
showRelativeSize: boolean;
|
||||
setShowRelativeSize: (showRelativeSize: boolean) => void;
|
||||
|
||||
showTextDiff: boolean;
|
||||
setShowTextDiff: (showTextDiff: boolean) => void;
|
||||
|
||||
@@ -49,9 +63,18 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
autoTitleChat: true,
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => _set({ autoTitleChat }),
|
||||
|
||||
filterHasStars: false,
|
||||
setFilterHasStars: (filterHasStars: boolean) => _set({ filterHasStars }),
|
||||
|
||||
micTimeoutMs: 2000,
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
|
||||
|
||||
showPersonaIcons: true,
|
||||
setShowPersonaIcons: (showPersonaIcons: boolean) => _set({ showPersonaIcons }),
|
||||
|
||||
showRelativeSize: false,
|
||||
setShowRelativeSize: (showRelativeSize: boolean) => _set({ showRelativeSize }),
|
||||
|
||||
showTextDiff: false,
|
||||
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
|
||||
|
||||
@@ -103,6 +126,20 @@ export const useChatMicTimeoutMsValue = (): number =>
|
||||
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
|
||||
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
|
||||
|
||||
export const useChatDrawerFilters = () => {
|
||||
const values = useAppChatStore(useShallow(state => ({
|
||||
filterHasStars: state.filterHasStars,
|
||||
showPersonaIcons: state.showPersonaIcons,
|
||||
showRelativeSize: state.showRelativeSize,
|
||||
})));
|
||||
return {
|
||||
...values,
|
||||
toggleFilterHasStars: () => useAppChatStore.getState().setFilterHasStars(!values.filterHasStars),
|
||||
toggleShowPersonaIcons: () => useAppChatStore.getState().setShowPersonaIcons(!values.showPersonaIcons),
|
||||
toggleShowRelativeSize: () => useAppChatStore.getState().setShowRelativeSize(!values.showRelativeSize),
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRouterQuery } from '~/common/app.routes';
|
||||
|
||||
import { DrawHeading } from './components/DrawHeading';
|
||||
import { DrawUnconfigured } from './components/DrawUnconfigured';
|
||||
import { Gallery } from './Gallery';
|
||||
import { TextToImage } from './TextToImage';
|
||||
|
||||
|
||||
@@ -18,6 +17,7 @@ export interface AppDrawIntent {
|
||||
export function AppDraw() {
|
||||
|
||||
// state
|
||||
const [showHeading, setShowHeading] = React.useState<boolean>(true);
|
||||
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
|
||||
const [section, setSection] = React.useState<number>(0);
|
||||
|
||||
@@ -45,19 +45,20 @@ export function AppDraw() {
|
||||
|
||||
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
|
||||
|
||||
<DrawHeading
|
||||
{showHeading && <DrawHeading
|
||||
section={section}
|
||||
setSection={setSection}
|
||||
showSections
|
||||
onRemoveHeading={() => setShowHeading(false)}
|
||||
sx={{
|
||||
px: { xs: 1, md: 2 },
|
||||
py: { xs: 1, md: 6 },
|
||||
}}
|
||||
/>
|
||||
/>}
|
||||
|
||||
{!mayWork && <DrawUnconfigured />}
|
||||
|
||||
{mayWork && <Gallery />}
|
||||
{/*{mayWork && <Gallery />}*/}
|
||||
|
||||
{mayWork && (
|
||||
<TextToImage
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user