Compare commits
802 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4664dfa5 | |||
| 4122735bba | |||
| 1cab66b898 | |||
| d5c994708c | |||
| 4c76a5f1d2 | |||
| e8c8ab0d78 | |||
| 9705fb964e | |||
| 29fee0012a | |||
| 33dcf67252 | |||
| ab7e9e9a89 | |||
| d77ac0322d | |||
| 3162ac5b45 | |||
| 2c67f3498b | |||
| 7ee27b4962 | |||
| cad49d5d1f | |||
| 35ff6d44fe | |||
| 9609592be3 | |||
| 08624ec245 | |||
| 8bc2483ab5 | |||
| 68245e8b68 | |||
| f73831036d | |||
| f8af56032a | |||
| 60fe4c4c84 | |||
| 4ce6d9e5c7 | |||
| 9a75ead6ad | |||
| 10276c6e67 | |||
| 5aeb67f6d6 | |||
| 556bc7448a | |||
| 04b84b6e8a | |||
| 2163a55cc7 | |||
| a47e12c0e0 | |||
| d1c3dc811e | |||
| 8ad719744a | |||
| 83fee37fb4 | |||
| b41535ef5e | |||
| 8ad1174f20 | |||
| 026171715c | |||
| 8b902e1e10 | |||
| 1f45e74378 | |||
| 585e7a4900 | |||
| 04a397c19e | |||
| ae4276c624 | |||
| 7579def667 | |||
| 187d5deccb | |||
| fb221b4535 | |||
| 5058c96a78 | |||
| 2b679d85cb | |||
| 57f64347ac | |||
| fb4b8fd067 | |||
| 2bed20cd85 | |||
| c48c7c524e | |||
| 32f27e73e3 | |||
| 71738f1966 | |||
| e44455ba80 | |||
| d2ee260d82 | |||
| 1f738bc41c | |||
| 2802ab3dea | |||
| e6a2e07640 | |||
| 235fa1bc26 | |||
| c04c02723d | |||
| 77468e4ab4 | |||
| 7f9cb61fc2 | |||
| 76fea5b285 | |||
| a796453c6e | |||
| a1b0dd90eb | |||
| fb8c42daa4 | |||
| 8eaa784a8a | |||
| 7dffaa4718 | |||
| 0f3f639497 | |||
| 67b1874836 | |||
| 56c09a9ae9 | |||
| e65c7c9601 | |||
| a646da80f5 | |||
| d3797109bf | |||
| 4e881f7eb0 | |||
| 768b07e28c | |||
| 4828f9f726 | |||
| edb769b826 | |||
| e270ba15b5 | |||
| c19891926b | |||
| 0438bf6265 | |||
| 255e8fc861 | |||
| 4690aefec5 | |||
| 8f9a637bdb | |||
| c2d6a864f8 | |||
| 1deaa2147e | |||
| 052a302a08 | |||
| 9ed0a09194 | |||
| 73810b3ed8 | |||
| 01c9cc9ea7 | |||
| a7beabbbe2 | |||
| b3ff0a1a25 | |||
| daae28db34 | |||
| 797c238862 | |||
| 270791bb54 | |||
| 8ec460f431 | |||
| 8c3a5f8188 | |||
| 4ee773bef9 | |||
| 3a2f0a41ec | |||
| 64bb1f1a56 | |||
| 2afcf13fbb | |||
| f617728fc3 | |||
| 9376abfe2a | |||
| 2f343bcc56 | |||
| 4108f67079 | |||
| 0ba3bbd67f | |||
| 552987eb56 | |||
| 99ef08ac79 | |||
| 4445bec08a | |||
| 6530f0ef40 | |||
| f8676a5cc2 | |||
| 7b623c5b22 | |||
| 029f64e4bc | |||
| 78b6e2ad3c | |||
| 112f9fff39 | |||
| 1c82030a21 | |||
| f378972ef8 | |||
| 68ae73d4e6 | |||
| 874b302860 | |||
| 90ff96ca15 | |||
| 3c0d55fa33 | |||
| 9891e02215 | |||
| 823195ad94 | |||
| fd12fb9f1b | |||
| fb8de08c39 | |||
| 738a62e96c | |||
| e7b93efda0 | |||
| 0b6f0a6068 | |||
| cc16d39e00 | |||
| 7eceff26ef | |||
| 61a791f759 | |||
| 6ecd6e61f6 | |||
| 1e13385f1f | |||
| dec9fe8b3d | |||
| ebd8ec5ae7 | |||
| e525e4c6dd | |||
| 903c2a736c | |||
| 482190777c | |||
| ebc785d1fe | |||
| ba932283f2 | |||
| f05bf68f61 | |||
| 35ab12a20a | |||
| fd7c54a695 | |||
| eafd834daf | |||
| f575ae0fa4 | |||
| 797c9f8575 | |||
| fb3ac4d454 | |||
| c629d706ec | |||
| 7896786311 | |||
| 820a2654ff | |||
| 8d45ab8436 | |||
| 855020636b | |||
| ff82603839 | |||
| 90270fb76c | |||
| c9edeaea44 | |||
| 757fe07b4d | |||
| f04fa706f7 | |||
| 9daf914a89 | |||
| c8e5ff3010 | |||
| f5591dd3ea | |||
| 62a0d988ce | |||
| dc41969503 | |||
| 9d9a4b9698 | |||
| b171bf210f | |||
| c39678dece | |||
| 6c1f552606 | |||
| dcad67d4b4 | |||
| 15f3af406c | |||
| b50abcc4ec | |||
| 20f1b1f51e | |||
| cd035cd30f | |||
| a7cc935453 | |||
| 349609614b | |||
| ad680cee99 | |||
| e95b2d6a28 | |||
| f55c1b984b | |||
| 5ab34a2f50 | |||
| de24d97a22 | |||
| c6f09375f8 | |||
| cc33297cc4 | |||
| 8556745645 | |||
| 317b2a1f10 | |||
| 7bc2fa4d59 | |||
| 9d5c5a8969 | |||
| 052d302f16 | |||
| bb4da7fd86 | |||
| 359e6eadc8 | |||
| e41a2609b3 | |||
| b23c909953 | |||
| 79bf9e2aee | |||
| d9d8d558a9 | |||
| 6afd3dcbdf | |||
| 5809fe3249 | |||
| e626e0b8ce | |||
| 284098ff0d | |||
| 90e1c297f4 | |||
| 23adbea744 | |||
| 9af3a3f73e | |||
| cc27ff9305 | |||
| 8a75c15557 | |||
| e9eaf44bdd | |||
| 2ad2d06d01 | |||
| 8bf7abfe76 | |||
| 099a1251cd | |||
| 354397a1b4 | |||
| a1d9e11c05 | |||
| 33538cca05 | |||
| 50f4866142 | |||
| 28a2ebb817 | |||
| 7a08022c72 | |||
| 78c829d955 | |||
| 5008345ce8 | |||
| a1b7684bf2 | |||
| da67f3b07e | |||
| c72ff95670 | |||
| d7a38abdbd | |||
| 6fed95b365 | |||
| 0099c1ca8b | |||
| c5c7e8af55 | |||
| 83476da97f | |||
| 4b6b591849 | |||
| b5ec9c4406 | |||
| ca197d14e9 | |||
| acfb336a9a | |||
| 4d27d49752 | |||
| b46693f525 | |||
| c3720ce6b3 | |||
| f68c73c5e9 | |||
| 58d992d875 | |||
| e9784a16d0 | |||
| ebe3a3f7c3 | |||
| 9ebb629aca | |||
| 4f80ec1459 | |||
| 9104064e8c | |||
| a43e653a74 | |||
| 3bffd76626 | |||
| 015101d6ae | |||
| 8904e602dd | |||
| d384967e41 | |||
| 531b59f538 | |||
| bef26c3feb | |||
| c48dd538a0 | |||
| 950767c1bf | |||
| 12b6001d53 | |||
| 2c1972ed89 | |||
| 362bf822f7 | |||
| 4734e2df26 | |||
| c2648c1749 | |||
| 7071a6c26a | |||
| 303ece84fd | |||
| 8a0266ce8d | |||
| cb55daf47d | |||
| 43415d3139 | |||
| 9cc9edf17e | |||
| e290e2f67a | |||
| 4272939217 | |||
| 148bdb6839 | |||
| 68cee4cc64 | |||
| 7e9027167d | |||
| 6630774dc9 | |||
| 0ca1c8de0b | |||
| 85a33ac1a4 | |||
| b8fd1aaebc | |||
| 13d952dc9d | |||
| 1f2f5a44d3 | |||
| 8159aa58a6 | |||
| 2bdb3be81c | |||
| bde630f55d | |||
| 3efef0ec42 | |||
| 1141287733 | |||
| ec08b05da1 | |||
| 5ea2edd13c | |||
| 1999bbeda9 | |||
| b3ec571341 | |||
| d0de8ad838 | |||
| 0402a6be03 | |||
| 6823e04ca9 | |||
| 9ae1527201 | |||
| 2b20b48dff | |||
| 296459924a | |||
| 8eee3b27e2 | |||
| 32d6f0c74b | |||
| 6e4094565a | |||
| da2b7b5a8f | |||
| 3207c3ffc1 | |||
| 784627a7c1 | |||
| 46d6c02c95 | |||
| 6d46719fe8 | |||
| f2b6c9dfd2 | |||
| c465fd8aa4 | |||
| 1eef785a61 | |||
| 0b4eb6b49a | |||
| cba54967b8 | |||
| 23dc3b7f7e | |||
| 49b137981c | |||
| cad033d63b | |||
| 312952ec41 | |||
| 52edd78cd4 | |||
| a7c9999d83 | |||
| 7f8ddde748 | |||
| e1fede1194 | |||
| 2322d6c46b | |||
| ac72f55731 | |||
| 5485e852bd | |||
| 48fb65d00b | |||
| 75bc69c0c0 | |||
| 0d9b9e4b9a | |||
| 78a31ef5d1 | |||
| 98005e323a | |||
| f3c253c044 | |||
| 02f63ec04d | |||
| 583f757f0a | |||
| 53c9149d06 | |||
| 64a90e3b3a | |||
| abfa903c9c | |||
| 6dd0ceab73 | |||
| eede819ea8 | |||
| ce3047428a | |||
| 3456f45590 | |||
| 4f71f065df | |||
| 005bc8ab44 | |||
| cd2d33a9f6 | |||
| 80e96d0b9e | |||
| 2c3b27e337 | |||
| b6691b4732 | |||
| a557a85d28 | |||
| eeb5b6a5af | |||
| ad31e2b750 | |||
| e98d80b6a0 | |||
| 93b1e52376 | |||
| 66a85c0dd3 | |||
| 50ce56a4f6 | |||
| 5aeb40abac | |||
| 8c06cdc898 | |||
| dfde89aa70 | |||
| 49b3316a5f | |||
| c35f367b39 | |||
| 3459836a14 | |||
| b7b5b6db30 | |||
| 05d16313c7 | |||
| a3a96d85d5 | |||
| c89042f142 | |||
| 2e18bd862d | |||
| 0bc47a997d | |||
| bcc7dfea2d | |||
| ba242d3a1b | |||
| fe82b11285 | |||
| bdf79b7c10 | |||
| 24eefdef23 | |||
| 252f550c04 | |||
| 628c249d45 | |||
| 61d598142d | |||
| c0510b2235 | |||
| ca0005c825 | |||
| 17e92b6200 | |||
| 2e0cbef034 | |||
| 95b6785f05 | |||
| 2917941b13 | |||
| 31c496cc65 | |||
| cef39d5090 | |||
| 3063b2a06d | |||
| d66a390516 | |||
| aae6c93f11 | |||
| 2f5476b76c | |||
| 88ea2c6708 | |||
| 37760a97df | |||
| de76d97303 | |||
| 92196ac793 | |||
| 80dda37f8c | |||
| 5e7fe18463 | |||
| 3d014f15e7 | |||
| ce8f0fdbd8 | |||
| 48fede38fd | |||
| f303ed5fc5 | |||
| 6dbc33efd9 | |||
| 53628532df | |||
| 2aae34d74a | |||
| e27d1fb57f | |||
| 8ad4d19232 | |||
| 0fa7dc0531 | |||
| 89546887bd | |||
| b0bcba2922 | |||
| 618ae8bf14 | |||
| 5ed5d574fd | |||
| bde9d9023a | |||
| c8a8858d24 | |||
| 6491118cde | |||
| b4acd02007 | |||
| 5695b3d6aa | |||
| 0a160697d1 | |||
| 11d3ca3d6e | |||
| ee88b23891 | |||
| ca68be7a56 | |||
| 4ad2abe997 | |||
| 8f08322721 | |||
| fb043e5a81 | |||
| 1628d48c97 | |||
| 2f92a9cb32 | |||
| 025cb3391e | |||
| 4aab7f991f | |||
| 67846db28f | |||
| cac55fa2bf | |||
| a74b7542ba | |||
| a9f462ed37 | |||
| c33b3d8278 | |||
| de3a6e4660 | |||
| e2c00b1bfc | |||
| 692ac4acca | |||
| c4bdd8fc4b | |||
| aa88221845 | |||
| b425e48afe | |||
| ff54b363aa | |||
| 4d931b4430 | |||
| 6054ca1217 | |||
| 7f9f0d1589 | |||
| ddcfa5b499 | |||
| 441b41c713 | |||
| ed71dd08b9 | |||
| 1c354ab5d3 | |||
| 151760713a | |||
| 4450d6302b | |||
| 2d055d3909 | |||
| 16da7d936d | |||
| efe08f445f | |||
| 4e7f755ae9 | |||
| 28bb2204e7 | |||
| 0c3b4592b2 | |||
| 222bbef670 | |||
| fa8ab451eb | |||
| b4ebb2b98f | |||
| c757270101 | |||
| 5873e68d63 | |||
| 0c124c3b44 | |||
| d46a57f8ef | |||
| 153fa23a71 | |||
| 5852054e3a | |||
| 5988d4ba24 | |||
| 0b6a698f6b | |||
| 01db520881 | |||
| 30b9956c4f | |||
| 4b37a3e538 | |||
| 928d8d2fb3 | |||
| 8ece20f54b | |||
| 26b4afca6a | |||
| 5f43b76b57 | |||
| 15d609aebc | |||
| d66bf8f3cf | |||
| d3b42036af | |||
| e448a43769 | |||
| 349de7bf48 | |||
| eec601e6b2 | |||
| 5b280a4a67 | |||
| 46deaf373f | |||
| e098d5701f | |||
| 21d4be0020 | |||
| 3f6a1856ac | |||
| 816e24a102 | |||
| 80e2a93a8c | |||
| 2f8096883f | |||
| 088f002583 | |||
| 27e9e78636 | |||
| a0c979602e | |||
| 3dc9edb8b5 | |||
| 32b68ab7c8 | |||
| 75a61c05c1 | |||
| bdfa98358b | |||
| dc8b38ccb9 | |||
| e1c288aaeb | |||
| 7a97de2a37 | |||
| 19b03b83ea | |||
| 598b144d06 | |||
| 1cd8a8de60 | |||
| 9beffca985 | |||
| 53db2069ce | |||
| 121104e13d | |||
| 0dacbda9da | |||
| 8bc27414a4 | |||
| 76509d93f0 | |||
| ff31b0c03e | |||
| 6b2ab73d65 | |||
| 2754023a80 | |||
| fa5c65db53 | |||
| 53c2c19d69 | |||
| e5437b445a | |||
| 09bdf9ddaf | |||
| b58d2a09ef | |||
| ce442742ba | |||
| 632f22962e | |||
| 72c3b12fb4 | |||
| 4807d550a3 | |||
| f9722f0619 | |||
| e1557a5d2e | |||
| 06280c2ba5 | |||
| 645a7ceb52 | |||
| a47c72ce84 | |||
| dcc6cd1cf3 | |||
| b4b9f6ce2d | |||
| 14e31e6ec4 | |||
| 69f2478fd7 | |||
| 5fec6846b0 | |||
| fe792f1ceb | |||
| d1d27a76de | |||
| 3e7e99420a | |||
| 0ffd221a61 | |||
| ee208b9779 | |||
| 6ad20c8f45 | |||
| ff4caaec22 | |||
| 91ae86f56e | |||
| c2daf13ead | |||
| ce2258fe16 | |||
| 68d68182e2 | |||
| 5f74f98a97 | |||
| 07b4dc32f7 | |||
| 9d5c8b2302 | |||
| 0c61ead38f | |||
| d92ad8f959 | |||
| a51772e191 | |||
| 89c83f454d | |||
| 7f67463a24 | |||
| 2311c1918a | |||
| b45a2af9bf | |||
| b4377ed152 | |||
| b7be29d792 | |||
| cbe223dac5 | |||
| 22fe448d95 | |||
| 49bdc70c78 | |||
| d6bd6a7170 | |||
| 2b99620de3 | |||
| d913dd7bd0 | |||
| 8cb57befb3 | |||
| b1be05309b | |||
| e5cf60df1b | |||
| 58a3d5fbc6 | |||
| 6abe75f199 | |||
| e27ebda960 | |||
| 9b603d90ba | |||
| b89228693d | |||
| 66cc9e0a3c | |||
| 53144a8021 | |||
| 43b7c1b136 | |||
| 3e7851849f | |||
| dffcb53db9 | |||
| 4dc332a32c | |||
| f19b3cc2cc | |||
| 11d66f42c4 | |||
| 20e947a1c1 | |||
| 7c87c90258 | |||
| fcb9ec4e57 | |||
| 1fcf931685 | |||
| c71ffcec1c | |||
| 430be466fc | |||
| 3f284cf2e6 | |||
| 6f0655478a | |||
| 7d5449967b | |||
| fd47888d1b | |||
| 10f470ce7d | |||
| cb29db1cc4 | |||
| 8c08f639dd | |||
| 488f080e68 | |||
| 6e07af422b | |||
| e9d721f6b9 | |||
| 840162df8c | |||
| 1517bc4868 | |||
| 67f6a56a87 | |||
| 6f0ff1fd33 | |||
| 6fdb8d03f2 | |||
| d00a8dcc21 | |||
| 851e9ef158 | |||
| 8ea8ce9acd | |||
| 9bd4a47d35 | |||
| 71ca554108 | |||
| 648a0c1bda | |||
| 1a0422f99d | |||
| 2f72c677a0 | |||
| 18019d390a | |||
| bb3605b3df | |||
| 2b3bb8ab9e | |||
| 7a09bc559d | |||
| a834d37f28 | |||
| 0c3be817c9 | |||
| b8999fdb7c | |||
| 9ba95e96b6 | |||
| 4459026297 | |||
| 98d3016395 | |||
| 15ea488719 | |||
| 5b2a11c0b2 | |||
| c5c6340eb6 | |||
| 74aea50875 | |||
| f86bb03745 | |||
| f6b4447ef7 | |||
| d8ec4cf1ab | |||
| da440f63c3 | |||
| 622fbff790 | |||
| 68c944c3a4 | |||
| 6cd3e3726c | |||
| d11a2593b3 | |||
| 5e936c35d7 | |||
| 97cdb1e1cd | |||
| 7470c1987b | |||
| b767f10a85 | |||
| 8a03391c95 | |||
| 5cfaa292d3 | |||
| 4e87940127 | |||
| 5d77990187 | |||
| b0e07d5180 | |||
| cf4eeb9481 | |||
| d67ca95925 | |||
| f67ec5a2c2 | |||
| 0f3ae38cb3 | |||
| d6323f1488 | |||
| ef2d115742 | |||
| ff75aebc20 | |||
| 04d51c566d | |||
| 142231a16c | |||
| d712ea7041 | |||
| 9d37a452e4 | |||
| 94ae80ff6a | |||
| d7b17facb6 | |||
| 501411b254 | |||
| f6394a04b1 | |||
| c80de93d3b | |||
| 0cad8a1159 | |||
| 77a771f0b6 | |||
| dd593f77e9 | |||
| 55ffbb8f08 | |||
| e425a3ed9f | |||
| 87c3fe36d2 | |||
| d64e144ab4 | |||
| 5c3e3d1066 | |||
| a243f158d7 | |||
| 96a51d3674 | |||
| 06f2f8be6c | |||
| bb68d218cc | |||
| 6ff2a032d6 | |||
| fdc3cf4e6d | |||
| 8efa8e18c1 | |||
| cdecf3cc3d | |||
| 89d6835a99 | |||
| 3bf616dde1 | |||
| f18b756390 | |||
| 969dd31db5 | |||
| c4bcad04bd | |||
| 4f170b674e | |||
| 5826ff47cd | |||
| 0bac51b67b | |||
| 8a7b9b39ce | |||
| 3cfc830d14 | |||
| 6141c0f669 | |||
| fa5ab9d567 | |||
| 2aa9c3b978 | |||
| f4eec86e10 | |||
| 60b401d86c | |||
| dad837e173 | |||
| efe1f33144 | |||
| 7a560341aa | |||
| 2024a417c6 | |||
| 9738545b2c | |||
| 299a5b2075 | |||
| 1f3e51eeb4 | |||
| e96dac28fb | |||
| 785a2d194e | |||
| 391e70dca4 | |||
| 8c43fe1409 | |||
| d861f7b7f2 | |||
| 3ffcd6482d | |||
| 53d3aaa67f | |||
| 5c4777fe7a | |||
| 4c7de54365 | |||
| 0cd04db40f | |||
| 2d1b2bbb85 | |||
| 23f1818503 | |||
| c1ae196bdc | |||
| c06742b8ed | |||
| 0df512add7 | |||
| 5fdb49fecb | |||
| af133470e1 | |||
| 4153db866f | |||
| fe1893a382 | |||
| b2260e9484 | |||
| 8c1c1ed9fe | |||
| fb2ffa1de3 | |||
| 6cbb4e99c4 | |||
| 34ce56b446 | |||
| 3fea74faa9 | |||
| 570c79623b | |||
| f0cb69ec7f | |||
| 23ec4602c2 | |||
| 7dcfad42f5 | |||
| 189e9f3d39 | |||
| 908ffae77c | |||
| 536e5076da | |||
| 81807e17ca | |||
| 9531fcff5a | |||
| 52454305fb | |||
| a18ebfd6c1 | |||
| 5402cdecc2 | |||
| 969c00b2d1 | |||
| 266eec1474 | |||
| 6944bedf85 | |||
| 5210a6edff | |||
| 897b919044 | |||
| 3a3bb0e58c | |||
| bc7ccf47ff | |||
| c781eba986 | |||
| efe131660a | |||
| 4200c78327 | |||
| f500445965 | |||
| a47dfef659 | |||
| 22516dec74 | |||
| 32a9973e49 | |||
| 1f8953c450 | |||
| d6a57f3e20 | |||
| ba7e79251f | |||
| b4d17aa907 | |||
| 1a0c533cb4 | |||
| 37332cbb9d | |||
| be85d63ef8 | |||
| db3b24a5ea | |||
| e0027f99c4 | |||
| b234868bc1 | |||
| 10ae34a677 | |||
| 2fb01b7315 | |||
| 5c80f2b002 | |||
| 28c128645e | |||
| 250cb47e44 | |||
| f83a4174cc | |||
| 334512210d | |||
| 3df71788ff | |||
| 60450ea7be | |||
| 80c93bd668 | |||
| 6d53f4cb44 | |||
| b35489d6a5 | |||
| d78cbb6b6e | |||
| e9c5cece4f | |||
| 4b69a7ee00 | |||
| 25e4d07fc5 | |||
| 6a381dede1 | |||
| b3bfbb96bb | |||
| 3d10953324 | |||
| 8dd4c5d78e | |||
| 8bde218f28 | |||
| 0643cc4ff9 | |||
| 4c5dd23fcd | |||
| 3ce4d8e3fe | |||
| b217e6a2ca | |||
| c9f049a733 | |||
| 09c3084629 | |||
| 38c8020e4a | |||
| 5057f7472b | |||
| 6820eec676 | |||
| ef2777b521 | |||
| 8bba3a63f5 | |||
| 722eb10286 | |||
| 15ffc27059 | |||
| 5f162b1a1c | |||
| 083c002ce0 | |||
| 1ede1d9911 | |||
| 8abe3afed9 | |||
| af36f43104 | |||
| b5f260207c | |||
| 9075204c29 | |||
| ce3a80bc53 | |||
| 64c36f67bb | |||
| 78d4919289 | |||
| 1af2607cca | |||
| ab713f3d3a | |||
| 05cc4f36c8 | |||
| 54e9bdad7d | |||
| 0fc88c0080 | |||
| 02f97ef2fa | |||
| 0853ffbbc6 | |||
| 60ac6da09f | |||
| 11a9fb1181 | |||
| 3762d0f1e0 | |||
| ebbe92f7ca | |||
| 9f239b2de3 | |||
| 2410c27b55 | |||
| 6d6c82648c | |||
| f41119c1ad | |||
| 9e6e1b996c | |||
| 1ab23e181f | |||
| a1b485691f | |||
| 694e05b800 | |||
| b761795c1a | |||
| 8cc54983aa | |||
| 59a599285e | |||
| 2f8c2b395c | |||
| 28f836674c | |||
| 6f01632231 | |||
| 41123ac561 | |||
| c8bf0dd20f | |||
| ec2b744bab | |||
| eab9cf64e8 | |||
| 42130d6f1d | |||
| c0ce6e7d14 | |||
| 8f5f98f889 | |||
| ee4719cdcf | |||
| b4c82b7833 | |||
| aefc3cfb89 | |||
| 7f2b8c2766 | |||
| 62e24a3050 | |||
| 4f6249a252 |
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: git-cleanup
|
||||||
|
description: Clean up local git branches and remotes accumulated from PR reviews. Use when the user asks to clean branches, remove stale remotes, or tidy up the local git state.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Cleanup
|
||||||
|
|
||||||
|
Clean local branches (except `main` and current branch) and non-origin remotes.
|
||||||
|
Useful after reviewing multiple PRs that leave behind tracking branches and contributor remotes.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Survey
|
||||||
|
|
||||||
|
Run these in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch
|
||||||
|
git remote
|
||||||
|
git status --short
|
||||||
|
git stash list
|
||||||
|
```
|
||||||
|
|
||||||
|
Present a summary of how many branches and remotes will be removed.
|
||||||
|
|
||||||
|
### 2. Safety Checks — Ask Before Proceeding
|
||||||
|
|
||||||
|
**Stop and ask for confirmation if any of these are true:**
|
||||||
|
|
||||||
|
- Current branch is NOT `main`
|
||||||
|
- Working tree has uncommitted changes or stashes
|
||||||
|
- Any branch is ahead of its upstream
|
||||||
|
|
||||||
|
List unmerged branches separately and let the user decide.
|
||||||
|
|
||||||
|
### 3. Delete Branches
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Safe delete first (fails on unmerged branches)
|
||||||
|
git branch | grep -v '^\*' | grep -v '^\s*main$' | xargs git branch -d 2>&1
|
||||||
|
|
||||||
|
# Force-delete only with explicit user approval
|
||||||
|
git branch -D <branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Remove Remotes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote | grep -v '^origin$' | xargs -I{} git remote remove {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Confirm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch && echo "---" && git remote
|
||||||
|
```
|
||||||
|
|
||||||
|
Report what was cleaned up.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- **NEVER** delete `main` or the current checked-out branch
|
||||||
|
- **NEVER** force-delete unmerged branches without user confirmation
|
||||||
|
- If in doubt, ask
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: pre-impl-discussion
|
||||||
|
description: "Conduct a thorough pre-implementation discussion before making significant changes. Use when the user wants to discuss, plan, or evaluate a change before implementing it — especially when they say words like 'discuss', 'evaluate', 'plan', or 'let's talk about'."
|
||||||
|
argument-hint: 'Describe the change to evaluate'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-Implementation Discussion
|
||||||
|
|
||||||
|
Facilitate a structured, collaborative discussion to evaluate a proposed change **before any implementation begins**.
|
||||||
|
|
||||||
|
## Golden Rule
|
||||||
|
|
||||||
|
**Do NOT implement anything until the user explicitly confirms the final plan and says to proceed.** Not even "let me try on a branch" — that's implementation. The user will tell you when discussion is over.
|
||||||
|
|
||||||
|
## Interaction Style
|
||||||
|
|
||||||
|
- **Concise during discussion.** Each intermediate response should be short and focused on the current question. Do NOT repeat the full plan in every response.
|
||||||
|
- **Complete when finalizing.** Once the plan is mature and the user asks for it, present a single comprehensive (but terse) summary for final review.
|
||||||
|
- **Ask, don't assume.** When uncertain about project context, constraints, or preferences — ask. The user prefers collaborative discussion over receiving a pre-baked answer.
|
||||||
|
- **Challenge the premise.** Question whether the proposed change is the right one. Suggest simpler alternatives if they exist.
|
||||||
|
- **Match the user's language.** Reply in the same language the user writes in.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Understand the Current State
|
||||||
|
|
||||||
|
Before forming any opinion:
|
||||||
|
|
||||||
|
- **Read the relevant source files** — configs, code, docs that relate to the change
|
||||||
|
- **Understand the project structure** — what's published vs private, what environments things target, existing constraints
|
||||||
|
- **Map the impact surface** — which files, packages, or systems would be affected
|
||||||
|
- **Estimate implementation cost** — how many files to touch, how much config to rewrite, what could break
|
||||||
|
|
||||||
|
Do NOT skip this step. Do NOT rely on assumptions about what "most projects" do.
|
||||||
|
|
||||||
|
### 2. Research Thoroughly
|
||||||
|
|
||||||
|
- **Consult official sources** — docs, changelogs, migration guides, GitHub issues
|
||||||
|
- **Verify every claim with evidence.** Don't say "X supports Y" without a source. If unsure, say so.
|
||||||
|
- **Search in parallel** to save time — batch independent queries
|
||||||
|
|
||||||
|
Common pitfalls:
|
||||||
|
|
||||||
|
- Assuming compatibility without checking actual version constraints
|
||||||
|
- Confusing roadmap/aspirations with actual released state
|
||||||
|
- Missing transitive constraints (a dependency of a dependency)
|
||||||
|
|
||||||
|
### 3. Present Findings (Concise)
|
||||||
|
|
||||||
|
Share a **brief** assessment. Tables work well for comparisons. Highlight **blockers** and **unknowns** prominently — don't bury them in paragraphs.
|
||||||
|
|
||||||
|
### 4. Identify Key Decisions
|
||||||
|
|
||||||
|
Surface the decisions the user needs to make. Present them as clear choices with trade-offs, not as a recommendation monologue.
|
||||||
|
|
||||||
|
For each decision point:
|
||||||
|
|
||||||
|
- What are the options? (2-3 max)
|
||||||
|
- What does each option cost or give up?
|
||||||
|
- What's your lean and why? (one sentence)
|
||||||
|
|
||||||
|
### 5. Iterate
|
||||||
|
|
||||||
|
The user will ask follow-up questions, raise concerns, or challenge assumptions. For each round:
|
||||||
|
|
||||||
|
- **Research if needed** — don't answer from instinct when facts are available
|
||||||
|
- **Answer only what was asked** — don't re-present the entire plan
|
||||||
|
- **Update your mental model** based on user feedback
|
||||||
|
|
||||||
|
Common mistakes in this phase:
|
||||||
|
|
||||||
|
- Repeating the full plan after every small clarification
|
||||||
|
- Answering a narrow question with a broad redesign
|
||||||
|
- Treating user questions as confirmation to proceed
|
||||||
|
|
||||||
|
### 6. Finalize the Plan
|
||||||
|
|
||||||
|
When the user signals the discussion is converging, present the **complete plan once**:
|
||||||
|
|
||||||
|
- **What changes** — concrete list of actions (table or bullet points)
|
||||||
|
- **Impact surface** — what files/systems are affected, estimated effort
|
||||||
|
- **What does NOT change** — explicitly state scope boundaries
|
||||||
|
- **Risks or open items** — anything that needs testing or further verification
|
||||||
|
|
||||||
|
Keep it terse. Tables over paragraphs. No explanations the user already heard during discussion.
|
||||||
|
|
||||||
|
### 7. Wait for Confirmation
|
||||||
|
|
||||||
|
After presenting the final plan, **stop and wait**. The user will either:
|
||||||
|
|
||||||
|
- Confirm → then (and only then) proceed to implementation
|
||||||
|
- Ask more questions → go back to step 5
|
||||||
|
- Modify scope → update the plan and re-present
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Don't | Do Instead |
|
||||||
|
| --------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| Start implementation "to test" without confirmation | Present findings and wait |
|
||||||
|
| Repeat the full plan in every response | Answer the specific question asked |
|
||||||
|
| Say "X should work" without checking | Say "I need to verify X" and research it |
|
||||||
|
| Assume project structure or constraints | Read the actual files |
|
||||||
|
| Present one recommendation as the only option | Present 2-3 options with trade-offs |
|
||||||
|
| Write long paragraphs explaining trade-offs | Use tables and bullet points |
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: submit-pr-from-current-changes
|
||||||
|
description: 'Create a branch, commit existing local changes, push them, and open a pull request. Use when submitting current work as a PR.'
|
||||||
|
argument-hint: 'Short summary of the current changes to submit'
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Submit PR From Current Changes
|
||||||
|
|
||||||
|
Turn a working tree diff into a clean branch, commit, and pull request.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
1. **Follow the PR template exactly.** The template is at `.github/PULL_REQUEST_TEMPLATE.md`. Read it and copy its full structure into the PR body. Do NOT remove, reorder, or skip any section or checkbox.
|
||||||
|
2. **Never check "Requirements / 要求" checkboxes.** These are human-only declarations — the Code of Conduct acknowledgment and the AI authorship declaration can only be truthfully made by the human submitter. They MUST remain unchecked (`- [ ]`) in the PR body you generate. No exceptions, no workarounds, even if the user explicitly asks you to check them. The user goes to GitHub and checks them in person after verifying each statement is true.
|
||||||
|
3. **Only check Testing checkboxes you actually verified.** The "`npm run ci` passes" checkbox MAY be checked only if you ran `npm run ci` in this session and it passed. All other Testing checkboxes (browser tested, no console errors, types/doc added) require manual verification that only a human can perform — they MUST remain unchecked. You MAY add a supplementary note below the Testing section listing what automated validation you actually ran and the results.
|
||||||
|
4. **Never fabricate information.** Do not claim tests passed, commands ran, or checks succeeded unless you actually executed them and observed the output in this session. If you did not run it, do not mention it.
|
||||||
|
5. **PR output must be concise.** PR title: one line. "What" section: 1–2 sentences max. No walls of text, no redundant explanations. Let the diff speak.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before attempting to push or open a PR, verify that the necessary tools are available:
|
||||||
|
|
||||||
|
- Check that `gh` CLI is installed and authenticated (`gh auth status`). If not available, stop and ask the user to install and authenticate GitHub CLI first.
|
||||||
|
- If the workflow uses MCP tools for GitHub operations, verify the MCP server is accessible.
|
||||||
|
|
||||||
|
## Shell Permissions
|
||||||
|
|
||||||
|
`gh` reads credentials from the OS keyring. A sandboxed shell cannot reach the keyring and will report a false auth failure.
|
||||||
|
|
||||||
|
- Run every `gh` command (`gh auth status`, `gh pr create`, `gh pr view`, …) and every `git push` / `git fetch` that talks to GitHub with full permissions on the **first** attempt (`required_permissions: ["all"]` for the Shell tool). Do not probe these in the sandbox first.
|
||||||
|
- Local read-only git inspection (`git status`, `git diff`, `git log`, `git branch`) may stay sandboxed.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Read contribution rules.** Read `CONTRIBUTING.md` and any package-level instructions (e.g. `packages/*/AGENTS.md`) relevant to the changed files.
|
||||||
|
2. **Inspect the diff.** Review changed files, scope, and impact. Understand what the change does before writing anything.
|
||||||
|
3. **Inspect repo conventions.** Check recent branch names (`git branch -r --sort=-committerdate | head -20`), recent commit messages (`git log --oneline -20`), and the PR template.
|
||||||
|
4. **Pre-submission health checks** (run all before creating the PR):
|
||||||
|
- **Unified theme**: All changes should serve one purpose. If unrelated changes are mixed in, ask the user to split or confirm.
|
||||||
|
- **Commit hygiene**: Every new commit must follow the repo's conventional commit style. Squash or reword if needed.
|
||||||
|
- **Author identity**: Verify `git config user.name` and `git config user.email` are set and the email looks real (not empty, not `noreply` unless intentional). Warn the user if not.
|
||||||
|
- **No leftover artifacts**: Check for debug logs, `.only` in tests, conflict markers, or temp files in the diff.
|
||||||
|
- **CI**: Run `npm run ci` and make sure it passes. Record results honestly.
|
||||||
|
5. **Warn if the PR looks too hasty.** If any of these are true, pause and warn the user before proceeding:
|
||||||
|
- Large diff with no description of intent provided
|
||||||
|
- Changes touch core lib or extension (where vibe coding is prohibited per `CONTRIBUTING.md`)
|
||||||
|
- Multiple unrelated concerns in one diff
|
||||||
|
- No validation has been run at all
|
||||||
|
- Remind the user: _"This project does not accept low-quality or AI-generated PRs without meaningful human review. Please review your changes carefully."_
|
||||||
|
6. **Branch.** Check the current branch first:
|
||||||
|
- If already on a non-main feature branch with a valid name (matching `type/topic` convention), reuse it.
|
||||||
|
- If the branch name does not follow repo conventions (e.g. missing prefix, unclear topic), ask the user whether to rename or create a new one.
|
||||||
|
- If on `main` or a default branch, create a new branch from it with a short kebab-case name: prefix (`fix/`, `feat/`, `docs/`, `refactor/`, `chore/`) + concrete topic words.
|
||||||
|
7. **Stage and commit.** Stage only the intended files. Use `type(scope): subject` format. Keep the subject specific and compact.
|
||||||
|
8. **Push.** Push with `-u origin HEAD`.
|
||||||
|
9. **Open PR.** Use `gh pr create` with the full PR template structure. Fill in "What" and "Type" sections based on the actual diff. Check "`npm run ci` passes" only if it actually passed in this session; leave all other "Testing" and all "Requirements" checkboxes unchecked.
|
||||||
|
10. **Report results.** Tell the user: branch name, commit hash, PR link, and what validation actually ran (with pass/fail).
|
||||||
|
|
||||||
|
## Post-Submission Reminder
|
||||||
|
|
||||||
|
After successfully opening the PR, ALWAYS give a brief reminder in the user's language. Keep it concise and natural, but make sure it clearly tells the user:
|
||||||
|
|
||||||
|
1. They need to test the changes themselves in the browser.
|
||||||
|
2. They need to go to the PR page and check the Testing and Requirements checkboxes only after verifying each item.
|
||||||
|
3. The PR will not enter review until those checkboxes are checked.
|
||||||
|
4. The project does not accept autonomously AI-generated PRs, so they should only check the AI declaration if it is truthful.
|
||||||
|
|
||||||
|
## Branch Naming
|
||||||
|
|
||||||
|
- Prefer short kebab-case names with a repo-consistent prefix such as `fix/`, `feat/`, `docs/`, `refactor/`, or `chore/`.
|
||||||
|
- Match the change type first, then the smallest useful topic.
|
||||||
|
- Prefer concrete topic words over issue text dumps.
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
- Use the repository's prevailing commit style (conventional commits).
|
||||||
|
- Use `type(scope): subject` when scope is clear from the changed area.
|
||||||
|
- Keep the subject specific and compact.
|
||||||
|
- If multiple commits exist on the branch, each one must independently follow conventions.
|
||||||
|
|
||||||
|
## Validation Strategy
|
||||||
|
|
||||||
|
- Default to enough validation to defend the PR, not the absolute minimum.
|
||||||
|
- Always run `npm run ci` (build, lint, typecheck, tests) before opening the PR. It must pass.
|
||||||
|
- Escalate to broader validation when the diff crosses packages, changes shared code, or affects release behavior.
|
||||||
|
- Never claim checks that did not actually run.
|
||||||
|
- You MAY note what you ran and the results below the Testing section in the PR body, but do NOT check the template checkboxes.
|
||||||
|
|
||||||
|
## Decision Points
|
||||||
|
|
||||||
|
- No local changes → stop, say so.
|
||||||
|
- `gh` CLI not available → stop, ask user to install it.
|
||||||
|
- Unrelated files mixed → stage only the intended subset or ask whether the work should be split.
|
||||||
|
- If the repo has area-specific instructions, read them before naming the branch or writing the PR.
|
||||||
|
- Change is small but high-risk → prefer broader validation.
|
||||||
|
- Change is narrowly scoped and low-risk → run the most relevant checks, not arbitrary unrelated ones.
|
||||||
|
- Template has declarations you cannot truthfully check → leave unchecked, tell the user why.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
- The branch exists remotely and tracks upstream.
|
||||||
|
- The commit message(s) match repo style.
|
||||||
|
- All commit authors have proper name and email configured.
|
||||||
|
- The PR title matches the commit intent.
|
||||||
|
- The PR body follows the template structure completely.
|
||||||
|
- All "Requirements" checkboxes and all human-only "Testing" checkboxes are unchecked (`- [ ]`) in the PR body. The only checkbox you may check is "`npm run ci` passes", and only if it truly passed.
|
||||||
|
- The reported validation is accurate — nothing fabricated.
|
||||||
|
- The post-submission reminder was delivered in the user's language, concisely and accurately.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: update-changelog
|
||||||
|
description: 'Update docs/CHANGELOG.md from git history, GitHub releases, and code diffs. Use when: writing release notes, syncing the latest changelog entry, summarizing a new tag, or keeping changelog wording concise and consistent.'
|
||||||
|
argument-hint: 'Describe the target, for example: latest version only'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Changelog
|
||||||
|
|
||||||
|
Update `docs/CHANGELOG.md` from repository evidence instead of guesswork.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Add the newest release entry to `docs/CHANGELOG.md`
|
||||||
|
- Sync changelog text with GitHub Releases
|
||||||
|
- Summarize the latest tag from git history or code diffs
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Keep the wording brief
|
||||||
|
- Only add the latest missing version unless the user explicitly asks to backfill older releases
|
||||||
|
- Prefer GitHub sources first, especially Releases and tag metadata
|
||||||
|
- Skip `Features` / `Improvements` / `Bug Fixes` headings when the release only has a few clear items
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Read the local style
|
||||||
|
|
||||||
|
- Open `docs/CHANGELOG.md`
|
||||||
|
- Match the existing tone, bullet style, section ordering, and date format
|
||||||
|
|
||||||
|
### 2. Determine the target release
|
||||||
|
|
||||||
|
- Read the root `package.json` version and compare it with the top changelog entry
|
||||||
|
- Find the previous tag for the target version
|
||||||
|
- If the latest version is already documented, stop and report that no changelog update is needed
|
||||||
|
|
||||||
|
### 3. Gather evidence
|
||||||
|
|
||||||
|
Prefer these sources in order:
|
||||||
|
|
||||||
|
1. GitHub release notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GH_PAGER=cat gh release view v<version> --repo <owner>/<repo> --json tagName,name,publishedAt,body
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Tag date
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log -1 --format=%cs v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Commit history between tags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git --no-pager log --format='%h %s' --no-merges v<previous>..v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Diff scope when commit subjects are vague
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git --no-pager diff --name-only v<previous>..v<version>
|
||||||
|
git --no-pager diff --stat v<previous>..v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Read touched files directly only when the user-visible change is still unclear
|
||||||
|
|
||||||
|
### 4. Distill what belongs in the changelog
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- User-visible features
|
||||||
|
- Important behavior changes
|
||||||
|
- Bug fixes that improve reliability, compatibility, or developer experience
|
||||||
|
- Small docs updates only when they materially change supported setups or onboarding
|
||||||
|
|
||||||
|
Exclude unless explicitly requested:
|
||||||
|
|
||||||
|
- Pure version bumps
|
||||||
|
- Routine dependency updates
|
||||||
|
- Internal refactors with no visible impact
|
||||||
|
- Mechanical formatting noise
|
||||||
|
|
||||||
|
### 5. Choose the structure
|
||||||
|
|
||||||
|
- If the release has 1 to 3 clear points, write flat bullets directly under the version heading
|
||||||
|
- If the release has several distinct items, use short headings such as `### Features`, `### Improvements`, and `### Bug Fixes`
|
||||||
|
- Do not force categories when they make the entry longer or noisier
|
||||||
|
|
||||||
|
### 6. Write the entry
|
||||||
|
|
||||||
|
- Add or update only the requested release entry
|
||||||
|
- Keep bullets short and concrete
|
||||||
|
- Reuse project terminology from nearby entries
|
||||||
|
- Avoid marketing language
|
||||||
|
- Do not copy noisy GitHub release text verbatim
|
||||||
|
|
||||||
|
### 7. Verify
|
||||||
|
|
||||||
|
- Check dates, version numbers, and tag boundaries
|
||||||
|
- Ensure Markdown structure matches nearby entries
|
||||||
|
- Confirm no intermediate versions were added unless requested
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
- The newest requested version is documented
|
||||||
|
- The wording is concise and consistent with the surrounding changelog
|
||||||
|
- The entry is backed by GitHub releases, git history, or code diff evidence
|
||||||
|
- Low-signal internal changes were left out
|
||||||
@@ -1,61 +1,59 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: '[Bug] '
|
title: '[Bug] '
|
||||||
labels: ['bug']
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
Thanks for your interest in improving the project! Before submitting, please search for existing issues.
|
||||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
感谢参与我们的社区!请先搜索现有 Issue,避免重复提交。
|
||||||
|
|
||||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md)
|
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
||||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: What happened? / 问题描述
|
||||||
placeholder: Describe the bug and expected behavior
|
description: Describe what you expected vs what actually happened.
|
||||||
|
placeholder: 'A clear description of the bug.'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: code
|
id: reproduction
|
||||||
attributes:
|
attributes:
|
||||||
label: Code
|
label: How to reproduce / 如何复现
|
||||||
render: typescript
|
description: Provide steps, URL, config or code snippet to reproduce the bug.
|
||||||
placeholder: Minimal reproduction code
|
placeholder: '1. Open URL: https://...'
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
placeholder: '0.0.0'
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser
|
||||||
placeholder: 'Chrome 120, Firefox 119, etc.'
|
placeholder: 'e.g. Chrome 130'
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: version
|
|
||||||
placeholder: '0.0.0'
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: community
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Community Communication / 社区沟通
|
label: Before submitting
|
||||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
|
||||||
options:
|
options:
|
||||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||||
required: true
|
required: true
|
||||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
|
||||||
required: true
|
|
||||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have searched existing issues and this is not a duplicate.
|
||||||
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions & Ideas / 问题与想法(Discussions)
|
|
||||||
url: https://github.com/alibaba/page-agent/discussions
|
|
||||||
about: Use Discussions for Q&A and ideation. 使用 Discussions 进行问答与想法交流。
|
|
||||||
- name: Security Report / 安全问题报告
|
|
||||||
url: https://github.com/alibaba/page-agent/security/policy
|
|
||||||
about: Report security vulnerabilities responsibly. 通过安全页面报告漏洞。
|
|
||||||
- name: Contributing Guide / 贡献指南
|
- name: Contributing Guide / 贡献指南
|
||||||
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
|
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
|
||||||
about: How to contribute code and ideas. 如何进行贡献与提交代码。
|
about: How to contribute code and ideas. 如何进行贡献与提交代码。
|
||||||
- name: Code of Conduct / 行为准则
|
- name: Code of Conduct / 行为准则
|
||||||
url: https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md
|
url: https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md
|
||||||
about: Community expectations and standards. 社区行为期望与标准。
|
about: Community expectations and standards. 社区行为期望与标准。
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a feature
|
description: Suggest a feature
|
||||||
title: '[Feature] '
|
title: '[Feature] '
|
||||||
labels: ['enhancement']
|
type: Feature
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
Thanks for your interest in improving the project! Before submitting, please search for existing issues.
|
||||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
感谢参与我们的社区!请先搜索现有 Issue,避免重复提交。
|
||||||
|
|
||||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md)
|
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
||||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Feature Description / 功能描述
|
label: Feature Description / 功能描述
|
||||||
description: Describe the problem, solution, and any API changes. / 描述问题、解决方案以及 API 变更。
|
description: Describe the problem, solution, and any API changes.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
**Problem**:
|
**Problem**:
|
||||||
What problem does this solve?
|
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
How should this work?
|
|
||||||
|
|
||||||
**Proposed API**:
|
**Proposed API**:
|
||||||
```typescript
|
|
||||||
// code here
|
|
||||||
```
|
```
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -34,14 +30,13 @@ body:
|
|||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: community
|
id: community
|
||||||
attributes:
|
attributes:
|
||||||
label: Community Communication / 社区沟通
|
label: Before submitting
|
||||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
|
||||||
options:
|
options:
|
||||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||||
required: true
|
required: true
|
||||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
|
||||||
required: true
|
|
||||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have searched existing issues and this is not a duplicate.
|
||||||
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -2,24 +2,23 @@
|
|||||||
|
|
||||||
Brief description of changes.
|
Brief description of changes.
|
||||||
|
|
||||||
|
Closes #(issue)
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
|
|
||||||
|
- [ ] Breaking change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
- [ ] Feature / Improvement
|
- [ ] Feature / Improvement
|
||||||
- [ ] Refactor
|
- [ ] Refactor / Chores
|
||||||
- [ ] Documentation
|
- [ ] Documentation / Website / Demo / Testing
|
||||||
- [ ] Website
|
|
||||||
- [ ] Demo / Testing
|
|
||||||
- [ ] Breaking change
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
- [ ] `npm run ci` passes
|
||||||
- [ ] Tested in modern browsers
|
- [ ] Tested in modern browsers
|
||||||
- [ ] No console errors
|
|
||||||
- [ ] Types/doc added
|
- [ ] Types/doc added
|
||||||
|
|
||||||
Closes #(issue)
|
|
||||||
|
|
||||||
## Requirements / 要求
|
## Requirements / 要求
|
||||||
|
|
||||||
- [ ] I have read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
- [ ] I have read and follow the [Code of Conduct](../docs/CODE_OF_CONDUCT.md) and [Contributing Guide](../CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
||||||
|
- [ ] This PR is NOT generated by a bot or AI agent acting autonomously. I have authored or meaningfully reviewed every change. / 此 PR 不是由 bot 或 AI 自主生成的,我已亲自编写或充分审查了每一处变更。
|
||||||
|
|||||||
+11
-4
@@ -4,28 +4,35 @@ updates:
|
|||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: 'weekly'
|
||||||
|
labels:
|
||||||
|
- 'dependencies'
|
||||||
groups:
|
groups:
|
||||||
# 生产依赖 - 小版本更新
|
|
||||||
production-dependencies:
|
production-dependencies:
|
||||||
dependency-type: 'production'
|
dependency-type: 'production'
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- 'minor'
|
||||||
- 'patch'
|
- 'patch'
|
||||||
|
|
||||||
# 开发依赖 - 小版本更新
|
|
||||||
development-dependencies:
|
development-dependencies:
|
||||||
dependency-type: 'development'
|
dependency-type: 'development'
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- 'minor'
|
||||||
- 'patch'
|
- 'patch'
|
||||||
|
|
||||||
# Major 更新单独处理(不分组,需要人工审查)
|
development-major:
|
||||||
# 安全更新也不分组,Dependabot 会自动优先创建独立 PR
|
dependency-type: 'development'
|
||||||
|
update-types:
|
||||||
|
- 'major'
|
||||||
|
|
||||||
|
# Production major updates are intentionally ungrouped for individual review.
|
||||||
|
# Security updates are also ungrouped — Dependabot prioritizes them automatically.
|
||||||
|
|
||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: 'weekly'
|
||||||
|
labels:
|
||||||
|
- 'dependencies'
|
||||||
groups:
|
groups:
|
||||||
github-actions:
|
github-actions:
|
||||||
patterns:
|
patterns:
|
||||||
|
|||||||
@@ -1,41 +1,27 @@
|
|||||||
|
name: CI
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20, 24, 25]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js ${{ matrix.node-version }}
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
# test on default version of npm
|
|
||||||
# - 9.6~10.8 on node@20
|
|
||||||
# - 11.3~11.6 on node@24
|
|
||||||
|
|
||||||
- name: Node and NPM version
|
|
||||||
run: node --version && npm --version
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint
|
- name: CI checks
|
||||||
run: npx eslint . && npx prettier --check **/*.ts
|
run: node scripts/ci.js
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy Demo
|
name: Deploy Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,14 +6,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: page-agent-trusted
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -24,17 +24,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build demo
|
- name: Build website
|
||||||
run: npm run build:website
|
run: npm run build:website
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v6
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: './packages/website/dist'
|
path: './packages/website/dist'
|
||||||
|
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v5
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Runs only for code already on main, on the self-hosted runner.
|
||||||
|
# PR-triggered CI must stay on GitHub-hosted runners (see ci.yml).
|
||||||
|
name: Main CI
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: page-agent-trusted
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: CI checks
|
||||||
|
run: node scripts/ci.js
|
||||||
@@ -6,14 +6,16 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write # Required for OIDC
|
id-token: write # Required for OIDC
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
# Only the repo owner may trigger a release, even if tag protection is bypassed
|
||||||
|
if: github.actor == 'gaomeng1900'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -21,10 +23,10 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
# Ensure npm 11.5.1 or later is installed
|
# Stay on the latest npm 11 release
|
||||||
- name: Update npm
|
- name: Update npm
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -32,4 +34,12 @@ jobs:
|
|||||||
run: npm run build:libs
|
run: npm run build:libs
|
||||||
|
|
||||||
- name: Publish all public packages
|
- name: Publish all public packages
|
||||||
run: npm publish --workspaces --access public
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
if [[ "$VERSION" == *"-"* ]]; then
|
||||||
|
# Prerelease version (e.g., 0.3.0-beta.1) -> extract tag name before the dot
|
||||||
|
TAG=$(echo "$VERSION" | sed 's/.*-\([a-zA-Z]*\).*/\1/')
|
||||||
|
npm publish --workspaces --access public --tag "$TAG"
|
||||||
|
else
|
||||||
|
npm publish --workspaces --access public
|
||||||
|
fi
|
||||||
|
|||||||
+13
-5
@@ -25,11 +25,19 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
.qoder
|
.qoder
|
||||||
|
|
||||||
|
# publish backup
|
||||||
|
package.json.bak
|
||||||
|
|
||||||
#
|
# env files
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# Copied files for npm publish (generated by prepublishOnly)
|
# extension
|
||||||
packages/page-agent/README.md
|
.output
|
||||||
packages/page-agent/README-zh.md
|
.wxt
|
||||||
packages/page-agent/LICENSE
|
|
||||||
|
# AI
|
||||||
|
.claude
|
||||||
|
.cursor
|
||||||
|
.gemini
|
||||||
|
.codex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Prompt templates (formatted manually for LLM readability)
|
||||||
|
*prompt.md
|
||||||
|
|
||||||
|
# Generated
|
||||||
|
packages/extension/.wxt
|
||||||
|
|
||||||
|
# Vendored
|
||||||
|
**/components/ui
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2",
|
||||||
|
"language": "en",
|
||||||
|
"globRoot": "..",
|
||||||
|
"ignorePaths": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/dist-ssr/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/.wxt/**",
|
||||||
|
"**/*.svg"
|
||||||
|
],
|
||||||
|
"words": [
|
||||||
|
"activedescendant",
|
||||||
|
"adex",
|
||||||
|
"agentactivity",
|
||||||
|
"agentic",
|
||||||
|
"akldabonmimlicnjlflnapfeklbfemhj",
|
||||||
|
"aliyun",
|
||||||
|
"Bailian",
|
||||||
|
"Bento",
|
||||||
|
"BYOK",
|
||||||
|
"Chromewebstore",
|
||||||
|
"contenteditable",
|
||||||
|
"deepseek",
|
||||||
|
"executionresult",
|
||||||
|
"fieldsets",
|
||||||
|
"gaomeng",
|
||||||
|
"Googlechrome",
|
||||||
|
"Grabbable",
|
||||||
|
"Gregor",
|
||||||
|
"haspopup",
|
||||||
|
"highspeed",
|
||||||
|
"historicalevent",
|
||||||
|
"historychange",
|
||||||
|
"HITL",
|
||||||
|
"hrefs",
|
||||||
|
"IDBP",
|
||||||
|
"innerhtml",
|
||||||
|
"instructionsconfig",
|
||||||
|
"kimi",
|
||||||
|
"languagedetector",
|
||||||
|
"Lian",
|
||||||
|
"llms",
|
||||||
|
"magicui",
|
||||||
|
"maxlength",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
"modelcontextprotocol",
|
||||||
|
"multipageagent",
|
||||||
|
"nesw",
|
||||||
|
"npmmirror",
|
||||||
|
"Ollama",
|
||||||
|
"onwarn",
|
||||||
|
"opensource",
|
||||||
|
"openrouter",
|
||||||
|
"pageagent",
|
||||||
|
"pageagentcore",
|
||||||
|
"pageagentext",
|
||||||
|
"postpublish",
|
||||||
|
"qwen",
|
||||||
|
"reactid",
|
||||||
|
"reactroot",
|
||||||
|
"retryable",
|
||||||
|
"shadcn",
|
||||||
|
"sidepanel",
|
||||||
|
"spinbutton",
|
||||||
|
"staticity",
|
||||||
|
"statuschange",
|
||||||
|
"Supabase",
|
||||||
|
"toggleable",
|
||||||
|
"trivago",
|
||||||
|
"Uncapitalize",
|
||||||
|
"unparseable",
|
||||||
|
"valuenow",
|
||||||
|
"valuetext",
|
||||||
|
"WCAG",
|
||||||
|
"webgl",
|
||||||
|
"wouter",
|
||||||
|
"Zunic"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["dbaeumer.vscode-eslint", "prettier.prettier-vscode"]
|
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
-26
@@ -1,28 +1,9 @@
|
|||||||
{
|
{
|
||||||
"editor.fontLigatures": true,
|
"files.exclude": {
|
||||||
"cSpell.words": [
|
"packages/*/node_modules": true
|
||||||
"deepseek",
|
},
|
||||||
"HITL",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"innerhtml",
|
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||||
"llms",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"opensource",
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
"qwen",
|
|
||||||
"retryable",
|
|
||||||
"shadcn",
|
|
||||||
"wouter"
|
|
||||||
],
|
|
||||||
"markdownlint.config": {
|
|
||||||
// "comment": "Relaxed rules",
|
|
||||||
"default": true,
|
|
||||||
"whitespace": false,
|
|
||||||
"line_length": false,
|
|
||||||
"ul-indent": false,
|
|
||||||
"no-inline-html": false,
|
|
||||||
"no-bare-urls": false,
|
|
||||||
"fenced-code-language": false,
|
|
||||||
"first-line-h1": false,
|
|
||||||
"block-spacing": false,
|
|
||||||
"blanks-around-lists": false,
|
|
||||||
"ol-prefix": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
This is a **monorepo** with npm workspaces:
|
This is a **monorepo** with npm workspaces:
|
||||||
|
|
||||||
- **Core Library** (`packages/page-agent/`) - AI agent for browser DOM automation, published as `page-agent` on npm
|
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
||||||
|
- **Extension** (`packages/extension/`) - Browser extension (WXT + React)
|
||||||
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||||
|
|
||||||
Internal packages:
|
Internal packages:
|
||||||
|
|
||||||
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
- **Core** (`packages/core/`) - PageAgentCore without UI (npm: `@page-agent/core`)
|
||||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||||
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||||
@@ -17,24 +18,28 @@ Internal packages:
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # Start website dev server
|
npm start # Start website dev server
|
||||||
npm run build # Build all packages
|
npm run build # Build all packages
|
||||||
npm run build:libs # Build all libraries
|
npm run build:libs # Build all libraries
|
||||||
npm run lint # ESLint with TypeScript strict rules
|
npm run build:ext # Build and zip the extension package
|
||||||
|
npm run typecheck # Typecheck all packages
|
||||||
|
npm run test # Run unit tests across all workspaces
|
||||||
|
npm run lint # ESLint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig and vite config when adding/removing packages.
|
Source-first monorepo: library `package.json` exports point to `src/*.ts` during development. At publish time, `scripts/pre-publish.js` promotes `publishConfig` fields to top-level (swapping to `dist/`), and `scripts/post-publish.js` restores the originals.
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
├── core/ # npm: "@page-agent/core" ⭐ Core agent logic (headless)
|
||||||
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
||||||
├── website/ # @page-agent/website (private)
|
├── website/ # @page-agent/website (private)
|
||||||
├── llms/ # @page-agent/llms
|
├── llms/ # @page-agent/llms
|
||||||
|
├── extension/ # Browser extension
|
||||||
├── page-controller/ # @page-agent/page-controller
|
├── page-controller/ # @page-agent/page-controller
|
||||||
└── ui/ # @page-agent/ui
|
└── ui/ # @page-agent/ui
|
||||||
```
|
```
|
||||||
@@ -43,9 +48,10 @@ packages/
|
|||||||
|
|
||||||
### Module Boundaries
|
### Module Boundaries
|
||||||
|
|
||||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
- **Page Agent**: Main entry with UI. Extends PageAgentCore and adds Panel. Imports from `@page-agent/core`, `@page-agent/ui`
|
||||||
|
- **Core**: PageAgentCore without UI. Imports from `@page-agent/llms`, `@page-agent/page-controller`
|
||||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||||
- **UI**: Panel and i18n. No dependency on page-agent
|
- **UI**: Panel and i18n. Decoupled from PageAgent via PanelAgentAdapter interface
|
||||||
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||||
|
|
||||||
### PageController ↔ PageAgent Communication
|
### PageController ↔ PageAgent Communication
|
||||||
@@ -71,34 +77,31 @@ const pageInfo = await this.pageController.getPageInfo()
|
|||||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||||
|
|
||||||
### CDN Builds (`packages/cdn/`)
|
|
||||||
|
|
||||||
Two IIFE builds for script tag usage:
|
|
||||||
|
|
||||||
| Build | File | Description |
|
|
||||||
| ----- | -------------------- | -------------------------------- |
|
|
||||||
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
|
||||||
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
|
||||||
|
|
||||||
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
|
||||||
|
|
||||||
## Key Files Reference
|
## Key Files Reference
|
||||||
|
|
||||||
### Page Agent (`packages/page-agent/`)
|
### Page Agent (`packages/page-agent/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
| ------------------ | --------------------------------------- |
|
| ------------------ | -------------------------------------------- |
|
||||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
| `src/PageAgent.ts` | ⭐ Main class with UI, extends PageAgentCore |
|
||||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
| `src/demo.ts` | IIFE demo entry (auto-init with demo API) |
|
||||||
| `src/tools/` | Tool definitions calling PageController |
|
|
||||||
|
### Core (`packages/core/`)
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------- | --------------------------------------- |
|
||||||
|
| `src/PageAgentCore.ts` | ⭐ Core agent class without UI |
|
||||||
|
| `src/tools/` | Tool definitions calling PageController |
|
||||||
|
| `src/config/` | Configuration types and constants |
|
||||||
|
| `src/prompts/` | System prompt templates |
|
||||||
|
|
||||||
### LLMs (`packages/llms/`)
|
### LLMs (`packages/llms/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
| ---------------------------- | ------------------------------------- |
|
| --------------------- | ------------------------------------- |
|
||||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
| `src/OpenAIClient.ts` | OpenAI-compatible client |
|
||||||
|
|
||||||
### Page Controller (`packages/page-controller/`)
|
### Page Controller (`packages/page-controller/`)
|
||||||
|
|
||||||
@@ -113,7 +116,7 @@ Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
|||||||
|
|
||||||
### New Agent Tool
|
### New Agent Tool
|
||||||
|
|
||||||
1. Implement in `packages/page-agent/src/tools/index.ts`
|
1. Implement in `packages/core/src/tools/index.ts`
|
||||||
2. If tool needs DOM ops, add method to PageController first
|
2. If tool needs DOM ops, add method to PageController first
|
||||||
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
||||||
|
|
||||||
@@ -123,9 +126,26 @@ Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
|||||||
2. Expose via async method in `PageController.ts`
|
2. Expose via async method in `PageController.ts`
|
||||||
3. Export from `packages/page-controller/src/index.ts`
|
3. Export from `packages/page-controller/src/index.ts`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Framework**: Vitest (unit tests only for now; future E2E goes to `packages/e2e/` with Playwright)
|
||||||
|
- **Location**: co-located, `src/foo.test.ts` next to `src/foo.ts`
|
||||||
|
- **Coverage today**: `packages/llms` only — other packages will follow incrementally
|
||||||
|
- **Adding tests to a new package**: create `vitest.config.ts` in the package and add a `"test": "vitest run"` script. Root `npm test` and `node scripts/ci.js` pick it up through npm workspaces.
|
||||||
|
- **Live tests** (hit real external APIs, slow/costly): name them `*.live.test.ts`, exclude them from the package's `test` script, and expose them via a `test:live` script. Root `npm run test:live` runs all of them; they never run in `npm test` or CI. Template: `packages/llms`.
|
||||||
|
- **Template**: See @page-agent/llms
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # all packages with a test script
|
||||||
|
npm test -w @page-agent/llms # single package
|
||||||
|
cd packages/llms && npx vitest # watch mode in one package
|
||||||
|
```
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
|
|
||||||
- Explicit typing for exported/public APIs
|
- Explicit typing for exported/public APIs
|
||||||
- ESLint relaxes some unsafe rules for rapid iteration
|
- ESLint relaxes some unsafe rules for rapid iteration
|
||||||
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
||||||
- All code and comments must be in English.
|
- All code and comments must be in English.
|
||||||
|
- Do not try to hide errors or risks. They are valuable feedbacks for developers and users. Make them visible and actionable.
|
||||||
|
- Traceability and predictability is more important than success rate.
|
||||||
|
|||||||
+22
-146
@@ -1,39 +1,14 @@
|
|||||||
# Contributing to Page-Agent
|
# Contributing to PageAgent
|
||||||
|
|
||||||
Thank you for your interest in contributing to Page-Agent! We welcome contributions from everyone.
|
♥️ We welcome contributions from everyone.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
For local development workflows, setup, local LLM config, extension development, testing on other websites, and more details, see [docs/developer-guide.md](docs/developer-guide.md).
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
1. **Prerequisites**
|
|
||||||
- `node.js >= 20` with `npm >= 10`
|
|
||||||
- An editor that supports `ts/eslint/prettier`
|
|
||||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
|
||||||
|
|
||||||
2. **Setup**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm start # Start demo and documentation site
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
This is a **monorepo** with npm workspaces containing **two main packages**:
|
|
||||||
|
|
||||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
|
||||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
|
||||||
|
|
||||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
|
||||||
|
|
||||||
- When developing. Use alias so that we don't have to pre-build.
|
|
||||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
|
||||||
- When bundling `UMD` and `Website`. Bundle everything including local packages.
|
|
||||||
|
|
||||||
## 🤝 How to Contribute
|
## 🤝 How to Contribute
|
||||||
|
|
||||||
### Reporting Issues
|
> **[Maintainer's Note](https://github.com/alibaba/page-agent/issues/349)**
|
||||||
|
|
||||||
|
### Opening Issues
|
||||||
|
|
||||||
- Use the GitHub issue tracker to report bugs or request features
|
- Use the GitHub issue tracker to report bugs or request features
|
||||||
- Search existing issues before creating new ones
|
- Search existing issues before creating new ones
|
||||||
@@ -42,130 +17,31 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
|||||||
|
|
||||||
### Code Contributions
|
### Code Contributions
|
||||||
|
|
||||||
1. **Fork and Clone**
|
1. Follow existing code style and patterns
|
||||||
|
2. Update documentation as needed
|
||||||
|
3. Add JSDoc for public APIs
|
||||||
|
4. Build and lint everything
|
||||||
|
5. Test in our demo website, and on other websites if applicable
|
||||||
|
6. Include screenshots for UI changes
|
||||||
|
|
||||||
```bash
|
### Vibe Coding with AI
|
||||||
git clone https://github.com/your-username/page-agent.git
|
|
||||||
cd page-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create Feature Branch**
|
- Vibe coding is **NOT** allowed for the core lib or the extension!!!
|
||||||
|
- Vibe coding is **RECOMMENDED** when maintaining **the demo, the website, the UI and tests**.
|
||||||
```bash
|
- Make sure your AI references `AGENTS.md` and `website/AGENTS.md` for better quality.
|
||||||
git checkout -b feat/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make Changes**
|
|
||||||
- Follow existing code style and patterns
|
|
||||||
- Add tests for new functionality
|
|
||||||
- Update documentation as needed
|
|
||||||
|
|
||||||
4. **Test Your Changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# TODO
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Commit and Push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: add awesome feature"
|
|
||||||
git push origin feat/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create Pull Request**
|
|
||||||
- Provide clear description of changes
|
|
||||||
- Link related issues
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
|
|
||||||
## 📝 Code Style
|
|
||||||
|
|
||||||
### General Guidelines
|
|
||||||
|
|
||||||
- Use TypeScript for type safety
|
|
||||||
- Follow existing naming conventions
|
|
||||||
- Write meaningful commit messages
|
|
||||||
- Keep functions small and focused
|
|
||||||
- Add JSDoc for public APIs
|
|
||||||
|
|
||||||
### Vibe coding with AI
|
|
||||||
|
|
||||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
|
||||||
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!!
|
|
||||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||||
- Update the AI instructions when structure changed.
|
|
||||||
- Cursor and Cline: `./.cursor/rules`
|
|
||||||
- Github Copilot: `./.github/copilot-instructions.md`
|
|
||||||
- Claude Code: `./CLAUDE.md`
|
|
||||||
|
|
||||||
## 🔧 Development Workflows
|
|
||||||
|
|
||||||
### Website Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start # React development server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Lib Development and Testing
|
|
||||||
|
|
||||||
> @TODO this part is outdated. Update this.
|
|
||||||
|
|
||||||
- Config your LLM API
|
|
||||||
- Start and serve a local umd script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:umd # Serving UMD with auto rebuild at http://localhost:5173/page-agent.umd.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add a new bookmark enable it on other website
|
|
||||||
|
|
||||||
```
|
|
||||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Documentation
|
|
||||||
|
|
||||||
1. Create `website/src/docs/section/page-name/page.tsx`
|
|
||||||
2. Add route to `website/src/router.tsx`
|
|
||||||
3. Add navigation link to `website/src/components/DocsLayout.tsx`
|
|
||||||
|
|
||||||
## 🎯 Contribution Areas
|
|
||||||
|
|
||||||
We especially welcome contributions in:
|
|
||||||
|
|
||||||
- **Browser compatibility** improvements
|
|
||||||
- **Performance optimizations** for DOM processing
|
|
||||||
- **Documentation** and examples
|
|
||||||
- **Testing** and quality assurance
|
|
||||||
- **Accessibility** features
|
|
||||||
- **Internationalization** support
|
|
||||||
|
|
||||||
## 🚫 What We Don't Accept
|
## 🚫 What We Don't Accept
|
||||||
|
|
||||||
- Changes that break existing API compatibility
|
- Breaking changes and large PRs without prior discussion
|
||||||
- Heavy dependencies to core library
|
- Heavy dependencies to core libs
|
||||||
- Contributions without proper testing
|
- Dependencies or code with licenses incompatible with MIT
|
||||||
- Code that doesn't follow project conventions
|
- Bot or AI-generated pull requests without meaningful human involvement
|
||||||
|
|
||||||
## 📄 Legal
|
## 📄 Legal
|
||||||
|
|
||||||
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
|
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|
||||||
> You may need to sign a github CLA before you create a PR.
|
---
|
||||||
|
|
||||||
### Browser-Use Attribution
|
Thank you for helping make PageAgent better! 🎉
|
||||||
|
|
||||||
Parts of this project are derived from the [browser-use](https://github.com/browser-use/browser-use) project (MIT License). When contributing to DOM-related functionality:
|
|
||||||
|
|
||||||
- Maintain existing attribution comments
|
|
||||||
- Follow similar patterns for consistency
|
|
||||||
- Credit browser-use for derived concepts
|
|
||||||
|
|
||||||
## 💬 Questions?
|
|
||||||
|
|
||||||
- Open a GitHub issue for technical questions
|
|
||||||
- Check existing documentation and issues first
|
|
||||||
- Be respectful and constructive in discussions
|
|
||||||
|
|
||||||
Thank you for helping make Page-Agent better! 🎉
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Alibaba
|
Copyright (c) 2026 SimonLuvRamen
|
||||||
|
Copyright (c) 2026 Alibaba Group Holding Limited
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
# PageAgent 🤖🪄
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
|
||||||
|
|
||||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
|
||||||
|
|
||||||
🌐 [English](./README.md) | **中文**
|
|
||||||
|
|
||||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
|
||||||
|
|
||||||
<video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
- **🎯 轻松集成**
|
|
||||||
- **🔐 端侧运行**
|
|
||||||
- **🧠 HTML 脱水**
|
|
||||||
- **💬 自然语言接口**
|
|
||||||
- **🎨 HITL 交互界面**
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 快速体验 (Demo CDN)
|
|
||||||
|
|
||||||
最快的体验方式:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js" crossorigin="true"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
|
|
||||||
|
|
||||||
| 位置 | URL |
|
|
||||||
| ------ | --------------------------------------------------------------------------------------- |
|
|
||||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
|
||||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
|
||||||
|
|
||||||
### NPM 安装(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install page-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { PageAgent } from 'page-agent'
|
|
||||||
|
|
||||||
const agent = new PageAgent({
|
|
||||||
model: 'deepseek-chat',
|
|
||||||
baseURL: 'https://api.deepseek.com',
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
language: 'zh-CN',
|
|
||||||
})
|
|
||||||
|
|
||||||
await agent.execute('点击登录按钮')
|
|
||||||
```
|
|
||||||
|
|
||||||
### CDN 引入
|
|
||||||
|
|
||||||
适用于无法使用 NPM 的环境,用法与 NPM 一致:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js" crossorigin="true"></script>
|
|
||||||
<script>
|
|
||||||
const agent = new PageAgent({
|
|
||||||
model: 'deepseek-chat',
|
|
||||||
baseURL: 'https://api.deepseek.com',
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
})
|
|
||||||
await agent.execute('点击登录按钮')
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
| 位置 | URL |
|
|
||||||
| ------ | ----------------------------------------------------------------------------------- |
|
|
||||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
|
||||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
|
||||||
|
|
||||||
## 🏗️ 架构设计
|
|
||||||
|
|
||||||
PageAgent adopts a simplified monorepo structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/
|
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
|
||||||
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
|
||||||
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
|
||||||
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
|
||||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
|
||||||
└── website/ # 文档站点
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🤝 贡献
|
|
||||||
|
|
||||||
欢迎社区贡献!以下是参与方式:
|
|
||||||
|
|
||||||
### 开发环境
|
|
||||||
|
|
||||||
1. Fork 项目仓库
|
|
||||||
2. Clone or fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
|
||||||
3. 安装依赖: `npm install`
|
|
||||||
4. 启动开发: `npm start`
|
|
||||||
|
|
||||||
### 贡献指南
|
|
||||||
|
|
||||||
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)和[贡献指南](CONTRIBUTING.md)。
|
|
||||||
|
|
||||||
## 👏 致谢
|
|
||||||
|
|
||||||
本项目基于以下优秀项目构建:
|
|
||||||
|
|
||||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
|
||||||
|
|
||||||
PageAgent 专为**客户端网页增强**设计,不是服务端自动化工具。
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
|
||||||
|
|
||||||
```
|
|
||||||
DOM processing components and prompt are derived from browser-use:
|
|
||||||
|
|
||||||
Browser Use
|
|
||||||
Copyright (c) 2024 Gregor Zunic
|
|
||||||
Licensed under the MIT License
|
|
||||||
|
|
||||||
Original browser-use project: <https://github.com/browser-use/browser-use>
|
|
||||||
|
|
||||||
We gratefully acknowledge the browser-use project and its contributors for their
|
|
||||||
excellent work on web automation and DOM interaction patterns that helped make
|
|
||||||
this project possible.
|
|
||||||
|
|
||||||
Third-party dependencies and their licenses can be found in the package.json
|
|
||||||
file and in the node_modules directory after installation.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
|
||||||
@@ -1,49 +1,75 @@
|
|||||||
# PageAgent 🤖🪄
|
# Page Agent
|
||||||
|
|
||||||

|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||||
|
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
[](https://github.com/alibaba/page-agent/actions/workflows/main-ci.yml)
|
||||||
|
[](https://www.npmjs.com/package/page-agent)
|
||||||
|
[](https://www.npmjs.com/package/page-agent)
|
||||||
|
[](https://bundlephobia.com/package/page-agent)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](http://www.typescriptlang.org/)
|
||||||
|
[](https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj)
|
||||||
|
[](https://github.com/alibaba/page-agent)
|
||||||
|
|
||||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
The GUI Agent Living in Your Webpage. One script gives any web page its own AI agent.
|
||||||
|
|
||||||
🌐 **English** | [中文](./README-zh.md)
|
<a href="https://trendshift.io/repositories/22551?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-22551" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/22551" alt="alibaba%2Fpage-agent | Trendshift" width="180"/></a>
|
||||||
|
|
||||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
🌐 **English** | [中文](./docs/README-zh.md)
|
||||||
|
|
||||||
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
|
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||||
|
|
||||||
|
<!-- demo video -->
|
||||||
|
|
||||||
|
[](https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🎯 Easy Integration** - Transform your webpage into an agent with a single script tag.
|
- **🎯 Easy integration**
|
||||||
- **🔐 Client-Side Processing**
|
- No need for `browser extension` / `python` / `headless browser`.
|
||||||
- **🧠 DOM Extraction**
|
- Just in-page javascript. Everything happens in your web page.
|
||||||
- **💬 Natural Language Interface**
|
- **📖 Text-based DOM manipulation**
|
||||||
- **🎨 UI with Human in the loop**
|
- No screenshots. No multi-modal LLMs or special permissions needed.
|
||||||
|
- **🧠 Bring your own LLMs**
|
||||||
|
- Works with most mainstream models, including locally deployed ones. See [supported models](https://alibaba.github.io/page-agent/docs/features/models).
|
||||||
|
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
|
||||||
|
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
## 💡 Use Cases
|
||||||
|
|
||||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
- **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite.
|
||||||
|
- **Smart Form Filling** — Turn 20-click workflows into one sentence. Perfect for ERP, CRM, and admin systems.
|
||||||
|
- **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
|
||||||
|
- **Multi-page Agent** — Extend your own web agent's reach across browser tabs via the [Chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
|
||||||
|
- **MCP** - Allow your agent clients to control your browser.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Quick Try (Demo CDN)
|
### One-line integration
|
||||||
|
|
||||||
Fastest way to try PageAgent:
|
Fastest way to try PageAgent with our free Demo LLM:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js" crossorigin="true"></script>
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
|
> **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
|
||||||
|
> Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
|
||||||
|
|
||||||
| Location | URL |
|
| Mirrors | URL |
|
||||||
| -------- | --------------------------------------------------------------------------------------- |
|
| ------- | ----------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js |
|
||||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
| China | https://registry.npmmirror.com/page-agent/1.12.1/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM Installation (Recommended)
|
### NPM Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install page-agent
|
npm install page-agent
|
||||||
@@ -53,94 +79,47 @@ npm install page-agent
|
|||||||
import { PageAgent } from 'page-agent'
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
const agent = new PageAgent({
|
const agent = new PageAgent({
|
||||||
model: 'deepseek-chat',
|
model: 'qwen3.5-plus',
|
||||||
baseURL: 'https://api.deepseek.com',
|
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
apiKey: 'YOUR_API_KEY',
|
apiKey: 'YOUR_API_KEY',
|
||||||
language: 'en-US',
|
language: 'en-US',
|
||||||
})
|
})
|
||||||
|
|
||||||
await agent.execute('Click the login button')
|
await agent.execute('Click the login button')
|
||||||
```
|
```
|
||||||
|
|
||||||
### CDN Build
|
For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/docs/introduction/overview).
|
||||||
|
|
||||||
For environments where NPM is not available. Usage is identical to NPM:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js" crossorigin="true"></script>
|
|
||||||
<script>
|
|
||||||
const agent = new PageAgent({
|
|
||||||
model: 'deepseek-chat',
|
|
||||||
baseURL: 'https://api.deepseek.com',
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
})
|
|
||||||
await agent.execute('Click the login button')
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
| Location | URL |
|
|
||||||
| -------- | ----------------------------------------------------------------------------------- |
|
|
||||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
|
||||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
|
||||||
|
|
||||||
## 🏗️ Structure
|
|
||||||
|
|
||||||
PageAgent adopts a simplified monorepo structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/
|
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
|
||||||
├── llms/ # LLM client (npm: @page-agent/llms)
|
|
||||||
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
|
||||||
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
|
||||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
|
||||||
└── website/ # Demo & Documentation site
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We welcome contributions from the community! Here's how to get started:
|
We welcome contributions from the community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [docs/developer-guide.md](docs/developer-guide.md) for local development workflows.
|
||||||
|
|
||||||
### Setup
|
Built something cool with PageAgent? Share it in [Show and Tell](https://github.com/alibaba/page-agent/discussions/categories/show-and-tell). 🙌
|
||||||
|
|
||||||
1. Fork the repository
|
Please read the [maintainer's note](https://github.com/alibaba/page-agent/issues/349) on principles and current state.
|
||||||
2. Clone your fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
|
||||||
3. Install dependencies: `npm install`
|
|
||||||
4. Start development: `npm start`
|
|
||||||
|
|
||||||
### Contributing Guidelines
|
Contributions generated entirely by **bots or AI** without substantial human involvement will **not be accepted**.
|
||||||
|
|
||||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
|
## ⚖️ License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
## 👏 Acknowledgments
|
## 👏 Acknowledgments
|
||||||
|
|
||||||
This project builds upon the excellent work of:
|
This project builds upon the excellent work of **[`browser-use`](https://github.com/browser-use/browser-use)**.
|
||||||
|
|
||||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
`PageAgent` is designed for **client-side web enhancement**, not server-side automation.
|
||||||
|
|
||||||
PageAgent is designed for **client-side web enhancement**, not server-side automation.
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
DOM processing components and prompt are derived from browser-use:
|
DOM processing components and prompt are derived from browser-use:
|
||||||
|
|
||||||
Browser Use
|
Browser Use <https://github.com/browser-use/browser-use>
|
||||||
Copyright (c) 2024 Gregor Zunic
|
Copyright (c) 2024 Gregor Zunic
|
||||||
Licensed under the MIT License
|
Licensed under the MIT License
|
||||||
|
|
||||||
Original browser-use project: <https://github.com/browser-use/browser-use>
|
|
||||||
|
|
||||||
We gratefully acknowledge the browser-use project and its contributors for their
|
We gratefully acknowledge the browser-use project and its contributors for their
|
||||||
excellent work on web automation and DOM interaction patterns that helped make
|
excellent work on web automation and DOM interaction patterns that helped make
|
||||||
this project possible.
|
this project possible.
|
||||||
|
|
||||||
Third-party dependencies and their licenses can be found in the package.json
|
|
||||||
file and in the node_modules directory after installation.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ Star this repo if you find PageAgent helpful!**
|
**⭐ Star this repo if you find PageAgent helpful!**
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-07-09
|
||||||
|
|
||||||
|
- **Stateless extension tab sync** - TabsController pulls tab state on demand instead of long-lived ports, so the MV3 service worker stays stateless and survives idle kills without stalling the side panel.
|
||||||
|
- **Tab status in agent context** - Tab summaries now include each tab's loading status for the LLM.
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **LLM model patching rework** - Rewrote per-model request patching for GPT, Claude, Qwen, Gemini, and DeepSeek, and refreshed the recommended model list.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Deprecate `LLMConfig.temperature`** - Warn when set; use `transformRequestBody` for models that still accept it.
|
||||||
|
- **Extension limitations docs** - Added a limitations section to extension documentation.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Multi-window extension tabs** - Resolved the active tab/window from the caller's window context instead of the global active tab.
|
||||||
|
- **chat-latest model compatibility** - Skip `reasoning_effort` and `temperature` patches for `*-chat-latest` models.
|
||||||
|
- **OpenRouter defaults** - Do not enable reasoning by default on OpenRouter.
|
||||||
|
- **Asset URLs** - Migrated `img.alicdn.com` links to `page-agent.github.io`.
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-06-15
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Agent run lifecycle rework** - `stop()` is now async and resolves only after the run fully settles. Run status is decoupled from task outcome: a new `stopped` state was added, and LLM self-reported failures now end as `completed`. Lifecycle hooks re-throw instead of folding errors into the result, agent errors are recorded in history, and `agent.lastResult` was added.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Abortable JavaScript execution** - `execute_javascript` now honors the `AbortSignal`.
|
||||||
|
- **Leaner agent prompts** - Simplified the waiting-response flow and removed navigation-back instructions to reduce LLM cognitive load.
|
||||||
|
- **MultiPageAgent safety** - Disabled `ScriptExecutionTool` for `MultiPageAgent`.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Dark mode detection** - Refined detection heuristics and made `isMainContentDark` less aggressive by checking `html` and `body` data attributes independently.
|
||||||
|
- **Extension lifecycle robustness** - Drove heartbeat and running state from status changes, cleared stale activity on any non-running status, handled the stopped lifecycle state, and cleared `currentTabId` on `TabsController.init`.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Accurate wait reporting** - Wait steps now report the actual wait duration.
|
||||||
|
- **Scroll predicates** - Scroll predicates now return booleans.
|
||||||
|
- **Docs** - Fixed the broken demo video on GitHub.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-06-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
|
||||||
|
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
|
||||||
|
- **Model recommendations refresh** - Updated default and tested model list recommendations.
|
||||||
|
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
|
||||||
|
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
|
||||||
|
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
|
||||||
|
|
||||||
|
## [1.8.2] - 2026-05-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
|
||||||
|
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
|
||||||
|
|
||||||
|
## [1.8.1] - 2026-04-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
|
||||||
|
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
|
||||||
|
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
|
||||||
|
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
|
||||||
|
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
|
||||||
|
- **MCP version advertising** - MCP server now correctly advertises its package version.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-04-15
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
|
||||||
|
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
|
||||||
|
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
|
||||||
|
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-04-04
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Optional `keepSemanticTags`** - Added an experimental `keepSemanticTags` config to preserve semantic structure in PageController output
|
||||||
|
- **Per-task extension system instructions** - Extension `ExecuteConfig` now supports `systemInstruction`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Smarter scroll handling** - Scroll container detection and scroll direction handling are more reliable
|
||||||
|
- **Better accessibility-aware element detection** - Interactive candidates with supported ARIA attributes and `role="listitem"` are recognized more accurately
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed iframe-origin filtering for extension `postMessage` listeners
|
||||||
|
- Avoided a `currentScript` null pointer during deferred initialization
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-03-31
|
||||||
|
|
||||||
|
- **More reliable click actions** - Click handling now reuses pointer coordinates, verifies targets with `elementFromPoint`, and behaves better on layered layouts
|
||||||
|
- **Better mask event handling** - `SimulatorMask` now supports passthrough events when automation should not fully swallow input
|
||||||
|
- Fixed a `SimulatorMask` memory leak
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-03-30
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Experimental all-tabs control** - Extension can include and control all browser tabs via `experimentalIncludeAllTabs`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Calmer empty state motion** - Disabled the EmptyState auto-start animation in the extension UI
|
||||||
|
- **Cleaner extension docs** - Simplified setup and tab-control documentation across the README and developer guide
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed new-tab detection from content scripts
|
||||||
|
- Fixed tab deduplication and multi-window handling in the extension
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-03-25
|
||||||
|
|
||||||
|
- **Longer task input** - The UI task input now accepts up to 1000 characters
|
||||||
|
- **Contributor docs refresh** - Added a maintainer note and refreshed contributor-facing documentation
|
||||||
|
- Fixed lint issues in the release pipeline
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-03-22
|
||||||
|
|
||||||
|
- **Internal PageController action exports** - PageController actions are now exposed as internal methods for easier reuse across packages
|
||||||
|
- **Expanded docs** - Added MCP docs and clarified project limitations and homepage details
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-03-21
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Beta MCP support** - New `@page-agent/mcp` package lets MCP clients such as Claude Desktop and Copilot control the browser through the Page Agent extension
|
||||||
|
- **Better iframe handling** - Same-origin iframe elements are handled more reliably during DOM extraction and actions
|
||||||
|
- **Extension history workflows** - Users can rerun past tasks, export history sessions as JSON, and approve MCP-triggered tasks before execution
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Unified versioning across packages** - The extension now follows the root workspace version. Changelog entries are no longer split into a separate extension version section
|
||||||
|
- **Configurable `stepDelay`** - Agent pacing between steps is now configurable via `stepDelay`
|
||||||
|
- **Optional API key** - `apiKey` can now be omitted for compatible deployments that do not require one
|
||||||
|
- **Optional named tool choice** - Tool invocation can disable named tool choice for providers that behave better without it
|
||||||
|
- **Better rich-text input support** - Improved `contenteditable` handling with better event dispatching and `execCommand` fallback for more editors
|
||||||
|
- **More flexible DOM extraction** - `includeAttributes` now supports wildcards, `contenteditable` is included by default, and heuristically interactive elements expose more useful attributes
|
||||||
|
- **MiniMax model support** - Added MiniMax compatibility, with the default recommendation updated to `MiniMax-M2.7`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed Safari issues when `requestIdleCallback` is unavailable
|
||||||
|
- Avoid throwing when `webgl2` initialization fails
|
||||||
|
- Improved OpenAI-compatible request patches for GPT-5.4 chat tools and MiniMax temperature/tool-call compatibility
|
||||||
|
- Fixed several UI polish issues in the extension and website, including cursor and layout regressions
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-03-05
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **`data-browser-use-ignore` → `data-page-agent-ignore`** - DOM ignore attribute renamed to match the project identity
|
||||||
|
- **Config types restructured** - `PageAgentConfig` split into `AgentConfig` + `PageAgentCoreConfig`; config definitions moved from `config/index.ts` to `types.ts`
|
||||||
|
- **Zod v3/v4 dual support** - Libraries now accept both `zod@^3.25` and `zod@^4.0` as peer dependencies
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Experimental `llms.txt` support** - Agent can fetch and include a site's `llms.txt` in context. Enable via `experimentalLlmsTxt: true`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Default `maxSteps` changed from 20 to 40 for better for complex tasks out of the box
|
||||||
|
- Added 400ms wait between agent steps for page reactions
|
||||||
|
- Increased click wait time (100ms → 200ms) for more reliable interactions
|
||||||
|
- Removed debug `console.log` statements from scroll actions
|
||||||
|
- Reset observations on new task start
|
||||||
|
- Improved logging across packages
|
||||||
|
|
||||||
|
### Extension v0.1.9
|
||||||
|
|
||||||
|
> PageAgent 1.5.1
|
||||||
|
|
||||||
|
- **Advanced config panel** - New collapsible section exposing Max Steps, System Instruction, and experimental `llms.txt` toggle
|
||||||
|
- Streamlined User Auth Token description
|
||||||
|
- Moved testing API notice below auth token section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-02-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Update Terms of Use and Privacy Policy
|
||||||
|
- **Robust tool-call validation** - Action inputs are now validated against tool schemas individually, producing clear error messages (e.g. `Invalid input for action "click_element_by_index"`) instead of unreadable union parse errors
|
||||||
|
- **Primitive action input coercion** - Small models that output `{"click_element_by_index": 2}` instead of `{"click_element_by_index": {"index": 2}}` are now auto-corrected using tool schemas
|
||||||
|
- **Qwen model updates** - Added `qwen3.5-plus` as the default free testing model; disabled `enable_thinking` for Qwen models to avoid incompatible responses
|
||||||
|
- **Updated default LLM endpoint** - Migrated demo and extension to a new testing endpoint with legacy endpoint auto-migration
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Unified zod imports (`* as z`) across all packages for consistency
|
||||||
|
- Better Zod error formatting with `z.prettifyError()` in LLM client
|
||||||
|
- Exported `InvokeError` and `InvokeErrorType` as values (not just types) from `@page-agent/llms`
|
||||||
|
- Exported `SupportedLanguage` type from `@page-agent/core`
|
||||||
|
|
||||||
|
### Extension v0.1.8
|
||||||
|
|
||||||
|
- **Language setting** - Added language selector (System / English / 中文) in config panel
|
||||||
|
- **UI makeover** - New empty state with breathing glow and typing animation; ai-motion glow overlay while running; refined focus styles
|
||||||
|
- **Testing endpoint notice** - Shows terms of use notice when using the free testing API
|
||||||
|
- **Legacy endpoint migration** - Auto-migrates old Supabase testing endpoint to new endpoint on startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-02-13
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Lifecycle: `stop()` vs `dispose()`** - New `stop()` method to cancel the current task while keeping the agent reusable. `dispose()` is now terminal — a disposed agent cannot be reused. This affects both `PageAgentCore` and `PanelAgentAdapter`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Panel action button** - The panel button now morphs between Stop (■) and Close (X) based on agent status
|
||||||
|
- **Error history** - Errors and max-step failures are now recorded in `history` as `AgentErrorEvent`, making post-task analysis more complete
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **AbortError handling** - `AbortError` is no longer retried by the LLM client, and shows a clean "Task stopped" message instead of a raw error stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-02-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Observe Phase** - Agent now observes the page before each action, improving decision accuracy on dynamic pages
|
||||||
|
- **Better Abort Handling** - Improved `abortSignal` support for cleaner task cancellation
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Pruned system prompts for lower token usage and faster responses
|
||||||
|
- Improved error handling during agent steps with better error messages
|
||||||
|
- Zod tree-shaking for smaller bundle size
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed indentation lost in DOM extraction caused by `trimLines`
|
||||||
|
- Fixed `gpt-5-mini` temperature configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-02-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Custom System Prompt** - New `systemPrompt` config option to customize or extend the default system prompt
|
||||||
|
- **Chrome Extension** - Extension with multi-tab control, main-world API with token auth, and tab lifecycle management
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Renamed `include_attributes` to `includeAttributes` in PageController config (camelCase consistency)
|
||||||
|
- Lazy-loaded mask module for faster initialization
|
||||||
|
- Better date formatting and error messages from LLM client
|
||||||
|
- Added `rawRequest` to step history for easier debugging
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed CSP errors by using local SVGs for cursor mask instead of inline styles
|
||||||
|
- Fixed `AbortError` being incorrectly retried and shown to users
|
||||||
|
- Fixed mask not working correctly when starting a new task after stopping a previous one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-19
|
||||||
|
|
||||||
|
### 🎉 First Stable Release
|
||||||
|
|
||||||
|
PageAgent is now ready for production use. The API is stable and breaking changes will follow semantic versioning.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
#### Core
|
||||||
|
|
||||||
|
- **PageAgent** - Main entry class with built-in UI Panel
|
||||||
|
- **PageAgentCore** - Headless agent class for custom UI or programmatic use
|
||||||
|
- **DOM Analysis** - Text-based DOM extraction with high-intensity dehydration
|
||||||
|
- **LLM Support** - Works with OpenAI, Claude, DeepSeek, Qwen, and other OpenAI-compatible APIs
|
||||||
|
- **Tool System** - Built-in tools for click, input, scroll, select, and more
|
||||||
|
- **Custom Tools** - Extend agent capabilities with your own tools (experimental)
|
||||||
|
- **Lifecycle Hooks** - Hook into agent execution (experimental)
|
||||||
|
- **Instructions System** - System-level and page-level instructions to guide agent behavior
|
||||||
|
- **Data Masking** - Transform page content before sending to LLM
|
||||||
|
|
||||||
|
#### Page Controller
|
||||||
|
|
||||||
|
- **Element Interactions** - Click, input text, select options, scroll
|
||||||
|
- **Visual Mask** - Blocks user interaction during automation
|
||||||
|
- **DOM Tree Extraction** - Efficient page structure extraction for LLM consumption
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
- **Interactive Panel** - Real-time task progress and agent thinking display
|
||||||
|
- **Ask User Tool** - Agent can ask users for clarification
|
||||||
|
- **i18n Support** - English and Chinese localization
|
||||||
|
|
||||||
|
### Packages
|
||||||
|
|
||||||
|
| Package | Description |
|
||||||
|
| ----------------------------- | ---------------------------------- |
|
||||||
|
| `page-agent` | Main entry with UI Panel |
|
||||||
|
| `@page-agent/core` | Core agent logic without UI |
|
||||||
|
| `@page-agent/llms` | LLM client with retry logic |
|
||||||
|
| `@page-agent/page-controller` | DOM operations and visual feedback |
|
||||||
|
| `@page-agent/ui` | Panel and i18n |
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- Single-page application only (cannot navigate across pages)
|
||||||
|
- No visual recognition (relies on DOM structure)
|
||||||
|
- Limited interaction support (no hover, drag-drop, canvas operations)
|
||||||
|
- See [Limitations](https://alibaba.github.io/page-agent/docs/introduction/limitations) for details
|
||||||
|
|
||||||
|
### Acknowledgments
|
||||||
|
|
||||||
|
This project builds upon the excellent work of [browser-use](https://github.com/browser-use/browser-use). DOM processing components and prompts are adapted from browser-use (MIT License).
|
||||||
@@ -16,22 +16,22 @@ appearance, race, religion, or sexual identity and orientation.
|
|||||||
Examples of behavior that contributes to creating a positive environment
|
Examples of behavior that contributes to creating a positive environment
|
||||||
include:
|
include:
|
||||||
|
|
||||||
* Using welcoming and inclusive language
|
- Using welcoming and inclusive language
|
||||||
* Being respectful of differing viewpoints and experiences
|
- Being respectful of differing viewpoints and experiences
|
||||||
* Gracefully accepting constructive criticism
|
- Gracefully accepting constructive criticism
|
||||||
* Focusing on what is best for the community
|
- Focusing on what is best for the community
|
||||||
* Showing empathy towards other community members
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include:
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||||
advances
|
advances
|
||||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
- Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or electronic
|
- Publishing others' private information, such as a physical or electronic
|
||||||
address, without explicit permission
|
address, without explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
## Our Responsibilities
|
## Our Responsibilities
|
||||||
|
|
||||||
@@ -69,10 +69,10 @@ members of the project's leadership.
|
|||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][https://www.contributor-covenant.org], version 1.4,
|
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4,
|
||||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||||
|
|
||||||
----
|
---
|
||||||
|
|
||||||
> Chinese Version
|
> Chinese Version
|
||||||
> 《阿里巴巴开源行为准则》
|
> 《阿里巴巴开源行为准则》
|
||||||
@@ -85,19 +85,19 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
|
|||||||
|
|
||||||
有助于创造正面环境的行为包括但不限于:
|
有助于创造正面环境的行为包括但不限于:
|
||||||
|
|
||||||
* 使用友好和包容性语言
|
- 使用友好和包容性语言
|
||||||
* 尊重不同的观点和经历
|
- 尊重不同的观点和经历
|
||||||
* 耐心地接受建设性批评
|
- 耐心地接受建设性批评
|
||||||
* 关注对社区最有利的事情
|
- 关注对社区最有利的事情
|
||||||
* 友善对待其他社区成员
|
- 友善对待其他社区成员
|
||||||
|
|
||||||
身为参与者不能接受的行为包括但不限于:
|
身为参与者不能接受的行为包括但不限于:
|
||||||
|
|
||||||
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
- 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||||
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
- 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||||
* 公开或私下的骚扰
|
- 公开或私下的骚扰
|
||||||
* 未经许可地发布他人的个人资料,例如住址或是电子地址
|
- 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||||
* 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
- 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||||
|
|
||||||
## 我们的责任
|
## 我们的责任
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Page Agent
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||||
|
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
[](https://github.com/alibaba/page-agent/actions/workflows/main-ci.yml)
|
||||||
|
[](https://www.npmjs.com/package/page-agent)
|
||||||
|
[](https://www.npmjs.com/package/page-agent)
|
||||||
|
[](https://bundlephobia.com/package/page-agent)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](http://www.typescriptlang.org/)
|
||||||
|
[](https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj)
|
||||||
|
[](https://github.com/alibaba/page-agent)
|
||||||
|
|
||||||
|
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/22551?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-22551" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/22551" alt="alibaba%2Fpage-agent | Trendshift" width="180"/></a>
|
||||||
|
|
||||||
|
🌐 [English](../README.md) | **中文**
|
||||||
|
|
||||||
|
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||||
|
|
||||||
|
<!-- demo video -->
|
||||||
|
|
||||||
|
[](https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **🎯 轻松集成**
|
||||||
|
- 无需 `浏览器插件` / `Python` / `无头浏览器`,纯页面内 JavaScript
|
||||||
|
- **📖 基于文本的 DOM 操作**
|
||||||
|
- 无需截图,无需多模态模型或特殊权限
|
||||||
|
- **🧠 自备 LLM**
|
||||||
|
- 支持多数主流模型,包括本地部署模型。参见[支持的模型](https://alibaba.github.io/page-agent/docs/features/models)。
|
||||||
|
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
||||||
|
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
||||||
|
|
||||||
|
## 💡 应用场景
|
||||||
|
|
||||||
|
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
||||||
|
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
|
||||||
|
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
|
||||||
|
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Web Agent 跨标签页工作。
|
||||||
|
- 通过 MCP 为现有 Agent 加入浏览器控制能力。
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 一行代码集成
|
||||||
|
|
||||||
|
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
|
||||||
|
> 在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
|
||||||
|
|
||||||
|
| Mirrors | URL |
|
||||||
|
| ------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js |
|
||||||
|
| China | https://registry.npmmirror.com/page-agent/1.12.1/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
|
### NPM 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install page-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
|
const agent = new PageAgent({
|
||||||
|
model: 'qwen3.5-plus',
|
||||||
|
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
apiKey: 'YOUR_API_KEY',
|
||||||
|
language: 'zh-CN',
|
||||||
|
})
|
||||||
|
|
||||||
|
await agent.execute('点击登录按钮')
|
||||||
|
```
|
||||||
|
|
||||||
|
更多编程用法,请参阅 [📖 文档](https://alibaba.github.io/page-agent/docs/introduction/overview)。
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
||||||
|
|
||||||
|
提交 issue 或 PR 之前,请先阅读[作者声明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)。
|
||||||
|
|
||||||
|
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码。
|
||||||
|
|
||||||
|
用 PageAgent 做了有趣的东西?欢迎到 [Show and Tell](https://github.com/alibaba/page-agent/discussions/categories/show-and-tell) 分享。🙌
|
||||||
|
|
||||||
|
## 👏 声明与致谢
|
||||||
|
|
||||||
|
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||||
|
|
||||||
|
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||||
|
|
||||||
|
```
|
||||||
|
DOM processing components and prompt are derived from browser-use:
|
||||||
|
|
||||||
|
Browser Use <https://github.com/browser-use/browser-use>
|
||||||
|
Copyright (c) 2024 Gregor Zunic
|
||||||
|
Licensed under the MIT License
|
||||||
|
|
||||||
|
We gratefully acknowledge the browser-use project and its contributors for their
|
||||||
|
excellent work on web automation and DOM interaction patterns that helped make
|
||||||
|
this project possible.
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚖️ 许可证
|
||||||
|
|
||||||
|
[MIT License](../LICENSE)
|
||||||
|
|
||||||
|
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We provide security fixes on a best-effort basis for:
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| --------------------------------------------------------- | --------- |
|
||||||
|
| `main` | Yes |
|
||||||
|
| Latest npm release of `page-agent` and workspace packages | Yes |
|
||||||
|
| Older releases | No |
|
||||||
|
|
||||||
|
Please upgrade to the latest release before reporting an issue against an older build.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
||||||
|
|
||||||
|
Use GitHub's private vulnerability reporting flow:
|
||||||
|
|
||||||
|
- Open https://github.com/alibaba/page-agent/security/policy
|
||||||
|
- Click `Report a vulnerability`
|
||||||
|
|
||||||
|
If private reporting is unavailable, open a minimal public issue only to request a private contact channel. Do not include exploit details.
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
- Affected package or feature
|
||||||
|
- Exact version, commit, or build
|
||||||
|
- Browser, OS, and runtime environment
|
||||||
|
- Reproduction steps or a proof of concept
|
||||||
|
- Expected impact
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
We prioritize reports that show a real security boundary failure, such as:
|
||||||
|
|
||||||
|
- Unauthorized access to data, tokens, or extension capabilities
|
||||||
|
- Bypassing explicit safety constraints
|
||||||
|
- Sensitive data exposure caused by default behavior
|
||||||
|
|
||||||
|
The following usually do not qualify by themselves:
|
||||||
|
|
||||||
|
- Unsafe custom integrations that ignore documented safeguards
|
||||||
|
- Intentionally embedding secrets into client-side builds
|
||||||
|
- Reports against unsupported older versions
|
||||||
|
|
||||||
|
## Disclosure
|
||||||
|
|
||||||
|
Please avoid public disclosure until maintainers have had a reasonable chance to investigate and ship a fix.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Developer Guide
|
||||||
|
|
||||||
|
This file is for local development workflows.
|
||||||
|
|
||||||
|
For contribution rules and expectations, see [../CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. **Prerequisites**
|
||||||
|
- `macOS` / `Linux` / `WSL`
|
||||||
|
- `node.js ^22.13 || >=24` with `npm >= 11`
|
||||||
|
- An editor that supports `ts/eslint/prettier`
|
||||||
|
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
|
||||||
|
|
||||||
|
2. **Setup**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i # Or `npm ci` if you don't want to change the lockfile
|
||||||
|
npm start # Start website dev server
|
||||||
|
npm run build # Build everything
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Project Structure
|
||||||
|
|
||||||
|
This is a **monorepo** with npm workspaces.
|
||||||
|
|
||||||
|
Published packages:
|
||||||
|
|
||||||
|
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel (npm: `page-agent`)
|
||||||
|
- **MCP** (`packages/mcp/`) - MCP server for browser control via Page Agent extension (npm: `@page-agent/mcp`)
|
||||||
|
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
|
||||||
|
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||||
|
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback, independent of LLM
|
||||||
|
- **UI** (`packages/ui/`) - Panel and i18n, decoupled from PageAgent
|
||||||
|
|
||||||
|
Applications:
|
||||||
|
|
||||||
|
- **Extension** (`packages/extension/`) - Browser extension (WXT + React)
|
||||||
|
- **Website** (`packages/website/`) - React docs, landing page, and dev playground (private)
|
||||||
|
|
||||||
|
> Source-first monorepo with `npm workspaces + ts references + vite alias`. Library `package.json` exports point to `src/*.ts` during development, and point to `dist/*.js` when published. `workspaces` in root `package.json` must be in topological order.
|
||||||
|
|
||||||
|
## 🤖 AGENTS.md Alias
|
||||||
|
|
||||||
|
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it.
|
||||||
|
|
||||||
|
## 🔧 Development Workflows
|
||||||
|
|
||||||
|
### Test With Your Own LLM API
|
||||||
|
|
||||||
|
- Create a `.env` file in the repo root with your LLM API config
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_MODEL_NAME=gpt-5.2
|
||||||
|
LLM_API_KEY=your-api-key
|
||||||
|
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_BASE_URL="http://localhost:11434/v1"
|
||||||
|
LLM_API_KEY="NA"
|
||||||
|
LLM_MODEL_NAME="qwen3:14b"
|
||||||
|
```
|
||||||
|
|
||||||
|
> @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
|
||||||
|
|
||||||
|
- **Restart the dev server** to load new env vars
|
||||||
|
- If not provided, the demo will use the free testing proxy by default. By using it, you agree to its [terms](./terms-and-privacy.md).
|
||||||
|
|
||||||
|
### Extension Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:ext
|
||||||
|
npm run build:ext
|
||||||
|
```
|
||||||
|
|
||||||
|
- Update `packages/extension/docs/extension_api.md` for API integration details
|
||||||
|
|
||||||
|
### Testing on Other Websites
|
||||||
|
|
||||||
|
- Start and serve a local `iife` script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add a new bookmark
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?lang=en-US&t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||||
|
```
|
||||||
|
|
||||||
|
- Click the bookmark on any page to load Page-Agent
|
||||||
|
|
||||||
|
> Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
|
||||||
|
|
||||||
|
### Adding Documentation
|
||||||
|
|
||||||
|
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||||
|
|
||||||
|
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Terms of Use & Privacy
|
||||||
|
|
||||||
|
**Last updated:** March 2026
|
||||||
|
|
||||||
|
"We" in this document refers to the maintainers of the open-source Page Agent project (https://github.com/alibaba/page-agent). "The software" refers to Page Agent (the JavaScript library) and Page Agent Ext (the browser extension). This document covers the software itself and the testing API we provide — **not** any third-party product or service built with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Open Source Software Privacy
|
||||||
|
|
||||||
|
The software is a **client-side only** tool with a "Bring Your Own Key" (BYOK) architecture. The software itself does **not** include any backend service. The software does **not** collect or transmit any user data on its own, and we do **not** have access to your browsing activity, page content, or task instructions through the software.
|
||||||
|
|
||||||
|
All data transmission occurs **only** between your browser and the LLM provider you configure. You are in full control of which provider receives your data.
|
||||||
|
|
||||||
|
The project is open source under the [MIT License](https://github.com/alibaba/page-agent/blob/main/LICENSE) and can be audited at: https://github.com/alibaba/page-agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Testing API and Demo Disclaimer & Terms of Use
|
||||||
|
|
||||||
|
To facilitate easy testing and technical evaluation, we provide a free testing LLM API. This API is used in the project homepage's live demo, the pre-built demo CDN bundles, and the browser extension's default configuration. Users may also use it independently for their own technical evaluation of the software.
|
||||||
|
|
||||||
|
This free testing API is provided **strictly for technical evaluation and R&D purposes only**. It must not be used in any production environment. By using this API, you agree to the following terms:
|
||||||
|
|
||||||
|
- **Permitted Use Only**: This API must be used solely for technical evaluation of the software. Any other use — including integration into other products or services, unlawful activities, violation of the underlying LLM provider's usage policies, or automated scraping at scale — is strictly prohibited.
|
||||||
|
|
||||||
|
- **No Sensitive Data**: You are strictly prohibited from inputting any Personal Identifiable Information (PII), confidential business data, financial/medical records, or using this agent on web pages containing such sensitive information.
|
||||||
|
|
||||||
|
- **Data Processing**: We do not store or log your prompts, webpage data (HTML), or any submitted content, nor do we use such data for model training. All data is processed in-transit and immediately discarded. We perform in-memory request validation to prevent abuse of the testing API, and temporarily process IP addresses for rate-limiting purposes. No data from these processes is retained. Data is processed through Alibaba Cloud infrastructure, which is subject to its own privacy policy.
|
||||||
|
|
||||||
|
- **Independent Infrastructure**: The software is completely frontend-based with a "Bring Your Own Key" (BYOK) architecture and **no built-in backend**. To facilitate easy testing, the maintainers have purchased public cloud services from Alibaba Cloud China ([aliyun.com](https://www.aliyun.com) Function Compute and BaiLian Qwen models). This project is not a product of, nor endorsed by, Alibaba Cloud.
|
||||||
|
|
||||||
|
- **No Guaranteed Availability**: This testing API may be rate-limited, degraded, or discontinued at any time without prior notice.
|
||||||
|
|
||||||
|
- **"AS IS" & Limitation of Liability**: This service is provided strictly on an "AS IS" and "AS AVAILABLE" basis, without any warranties. The maintainers bear no liability for any data loss, service interruption, or legal consequences arising from your use of this service.
|
||||||
|
|
||||||
|
- **Recommendation for Real Usage**: For secure and continuous usage, we strongly advise using the BYOK mode with your own legally compliant commercial LLM API keys, or connecting to local, offline models (e.g., Ollama).
|
||||||
|
|
||||||
|
**Note**: This free testing LLM API processes data via servers located in Mainland China. If you are located in a region with strict data localization laws (such as the EU/EEA), please do not use this API.
|
||||||
|
|
||||||
|
**Age Requirement**: The software and testing API are not intended for use by individuals under the age of 13 (or the minimum age of digital consent in your jurisdiction).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Browser Extension (Page Agent Ext)
|
||||||
|
|
||||||
|
### Data Processing
|
||||||
|
|
||||||
|
The extension performs DOM analysis and automation actions **locally in your browser**. Your browsing history, passwords, and form data are not accessed or collected by the extension developer.
|
||||||
|
|
||||||
|
Data is transmitted to external servers **only when you initiate an automation task**. When this occurs:
|
||||||
|
|
||||||
|
- Your task instructions (natural language commands)
|
||||||
|
- Simplified page structure (cleaned HTML) of all pages under the extension's control
|
||||||
|
|
||||||
|
are sent to the LLM API endpoint configured in **your settings**.
|
||||||
|
|
||||||
|
> **Note:** The HTML cleaning process simplifies page structure for AI readability but **does not guarantee removal of sensitive information** (e.g., visible text, form values, or personal data on the page). Please be mindful of the page content when initiating tasks.
|
||||||
|
|
||||||
|
**If you configure a third-party LLM provider** (e.g., OpenAI, Anthropic, or others), data is sent directly to that provider. Their privacy policies apply.
|
||||||
|
|
||||||
|
**If you use the testing API**, the terms in [Section 2](#2-testing-api-and-demo-disclaimer--terms-of-use) apply. By using the extension with the default testing API, you agree to those terms.
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local` (or equivalent browser storage APIs)
|
||||||
|
- **No cloud sync**: Configuration is not synced to any external server
|
||||||
|
- **No analytics**: The extension does not include any analytics or tracking code
|
||||||
|
|
||||||
|
### Your Control
|
||||||
|
|
||||||
|
- The extension is open source and can be audited by anyone
|
||||||
|
- You choose which LLM provider to use
|
||||||
|
- You may configure your own API endpoint at any time
|
||||||
|
- You can clear all stored data by removing the extension
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
We may update these terms at our discretion.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
https://github.com/alibaba/page-agent/issues
|
||||||
+16
-30
@@ -1,52 +1,35 @@
|
|||||||
|
import eslintReact from '@eslint-react/eslint-plugin'
|
||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
globalIgnores([
|
||||||
{
|
'**/dist',
|
||||||
plugins: {
|
'**/node_modules',
|
||||||
'react-hooks': reactHooks,
|
'packages/*/src/components/ui',
|
||||||
},
|
'**/.wxt',
|
||||||
rules: reactHooks.configs.recommended.rules,
|
'**/.output',
|
||||||
},
|
]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
// reactHooks.configs['recommended-latest'],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
eslintReact.configs['recommended-typescript'],
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
// project: ['./tsconfig.json'],
|
|
||||||
// project: ['./packages/*/tsconfig.json'],
|
|
||||||
// tsconfigRootDir: import.meta.dirname,
|
|
||||||
projectService: true,
|
projectService: true,
|
||||||
},
|
},
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Add any additional rules here
|
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
@@ -66,12 +49,15 @@ export default defineConfig([
|
|||||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
'@typescript-eslint/no-unsafe-return': 'off',
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
'@typescript-eslint/restrict-plus-operands': 'off',
|
'@typescript-eslint/restrict-plus-operands': 'off',
|
||||||
'react-dom/no-missing-button-type': 'off',
|
|
||||||
'react-x/no-nested-component-definitions': 'off',
|
|
||||||
'@typescript-eslint/prefer-optional-chain': 'off',
|
'@typescript-eslint/prefer-optional-chain': 'off',
|
||||||
|
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
||||||
// 'require-await': 'off',
|
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||||
'@typescript-eslint/require-await': 'off',
|
'@typescript-eslint/require-await': 'off',
|
||||||
|
'@typescript-eslint/no-deprecated': 'off',
|
||||||
|
'@eslint-react/dom-no-missing-button-type': 'off',
|
||||||
|
'@eslint-react/no-nested-component-definitions': 'off',
|
||||||
|
'@eslint-react/no-array-index-key': 'off',
|
||||||
|
'@eslint-react/dom-no-dangerously-set-innerhtml': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+11522
-8222
File diff suppressed because it is too large
Load Diff
+134
-102
@@ -1,104 +1,136 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.24",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
"packages/ui",
|
"packages/ui",
|
||||||
"packages/llms",
|
"packages/llms",
|
||||||
"packages/page-agent",
|
"packages/core",
|
||||||
"packages/cdn",
|
"packages/page-agent",
|
||||||
"packages/website"
|
"packages/mcp",
|
||||||
],
|
"packages/extension",
|
||||||
"description": "AI-powered UI agent for web applications",
|
"packages/website"
|
||||||
"author": "Simon<gaomeng1900>",
|
],
|
||||||
"license": "MIT",
|
"description": "AI-powered UI agent for web applications",
|
||||||
"repository": {
|
"author": "Simon<gaomeng1900>",
|
||||||
"type": "git",
|
"license": "MIT",
|
||||||
"url": "https://github.com/alibaba/page-agent.git"
|
"repository": {
|
||||||
},
|
"type": "git",
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
"engines": {
|
},
|
||||||
"node": ">=20.0.0",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"npm": ">=10.0.0"
|
"engines": {
|
||||||
},
|
"node": "^22.22.1 || >=24",
|
||||||
"scripts": {
|
"npm": "^11.6.3"
|
||||||
"cleanup": "rm -rf packages/*/dist",
|
},
|
||||||
"start": "npm run dev --workspace=@page-agent/website",
|
"scripts": {
|
||||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
"start": "npm run dev --workspace=@page-agent/website",
|
||||||
"build:libs": "npm run build --workspaces --if-present",
|
"dev:ext": "npm run dev -w @page-agent/ext",
|
||||||
"build": "npm run build:libs && npm run build:website",
|
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||||
"dev:umd": "npm run dev:umd --workspace=page-agent",
|
"build": "node scripts/build.js",
|
||||||
"version": "node scripts/sync-version.js",
|
"build:libs": "node scripts/build-libs.js",
|
||||||
"lint": "eslint .",
|
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||||
"prepare": "husky"
|
"build:ext": "npm run zip -w @page-agent/ext",
|
||||||
},
|
"version": "node scripts/sync-version.js",
|
||||||
"devDependencies": {
|
"postpublish": "npm run postpublish --workspaces --if-present",
|
||||||
"@commitlint/cli": "^20.3.1",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
|
||||||
"@commitlint/config-conventional": "^20.3.1",
|
"test": "npm test --workspaces --if-present",
|
||||||
"@eslint/js": "^9.39.2",
|
"test:live": "npm run test:live --workspaces --if-present",
|
||||||
"@microsoft/api-extractor": "^7.55.2",
|
"lint": "eslint .",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"ci": "node scripts/ci.js",
|
||||||
"@types/node": "^25.0.7",
|
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
||||||
"concurrently": "^9.2.1",
|
"prepare": "husky || true"
|
||||||
"dotenv": "^17.2.3",
|
},
|
||||||
"eslint": "^9.39.2",
|
"devDependencies": {
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"@commitlint/cli": "^21.2.1",
|
||||||
"eslint-plugin-react-dom": "^2.5.7",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"@eslint-react/eslint-plugin": "^5.13.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"@eslint/js": "^10.0.1",
|
||||||
"eslint-plugin-react-x": "^2.5.7",
|
"@microsoft/api-extractor": "^7.58.9",
|
||||||
"globals": "^17.0.0",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"husky": "^9.1.7",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"lint-staged": "^16.2.4",
|
"@types/node": "^26.1.1",
|
||||||
"prettier": "^3.7.4",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"typescript": "^5.9.3",
|
"chalk": "^5.6.2",
|
||||||
"typescript-eslint": "^8.53.0",
|
"concurrently": "^10.0.3",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"dotenv": "^17.4.2",
|
||||||
"vite": "^7.3.1",
|
"eslint": "^10.6.0",
|
||||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
"globals": "^17.7.0",
|
||||||
},
|
"happy-dom": "^20.10.6",
|
||||||
"lint-staged": {
|
"husky": "^9.1.7",
|
||||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
"lint-staged": "^17.0.8",
|
||||||
"npx prettier --write --ignore-unknown",
|
"prettier": "^3.9.5",
|
||||||
"npx eslint --quiet"
|
"typescript": "^6.0.3",
|
||||||
],
|
"typescript-eslint": "^8.63.0",
|
||||||
"*.{jsx,tsx}": [
|
"unplugin-dts": "^1.0.3",
|
||||||
"npx prettier --write --ignore-unknown",
|
"vite": "^8.1.4",
|
||||||
"npx eslint --quiet"
|
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||||
],
|
"vitest": "^4.1.10"
|
||||||
"*.css": [
|
},
|
||||||
"npx prettier --write --ignore-unknown"
|
"overrides": {
|
||||||
]
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"commitlint": {
|
"lint-staged": {
|
||||||
"extends": [
|
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||||
"@commitlint/config-conventional"
|
"npx prettier --write --ignore-unknown",
|
||||||
],
|
"npx eslint --quiet"
|
||||||
"rules": {
|
],
|
||||||
"subject-case": [
|
"*.{jsx,tsx}": [
|
||||||
0,
|
"npx prettier --write --ignore-unknown",
|
||||||
"never"
|
"npx eslint --quiet"
|
||||||
]
|
],
|
||||||
}
|
"*.css": [
|
||||||
},
|
"npx prettier --write --ignore-unknown"
|
||||||
"prettier": {
|
],
|
||||||
"singleQuote": true,
|
"*.md": [
|
||||||
"semi": false,
|
"npx prettier --write --ignore-unknown"
|
||||||
"useTabs": true,
|
]
|
||||||
"printWidth": 100,
|
},
|
||||||
"trailingComma": "es5",
|
"commitlint": {
|
||||||
"plugins": [
|
"extends": [
|
||||||
"@trivago/prettier-plugin-sort-imports"
|
"@commitlint/config-conventional"
|
||||||
],
|
],
|
||||||
"importOrder": [
|
"rules": {
|
||||||
"<THIRD_PARTY_MODULES>",
|
"subject-case": [
|
||||||
"^(@/).*(?<!css)$",
|
0,
|
||||||
"^[./].*(?<!css)$",
|
"never"
|
||||||
".css$"
|
]
|
||||||
],
|
}
|
||||||
"importOrderSeparation": true,
|
},
|
||||||
"importOrderSortSpecifiers": true
|
"prettier": {
|
||||||
}
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"plugins": [
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"^(@/).*(?<!css)$",
|
||||||
|
"^[./].*(?<!css)$",
|
||||||
|
".css$"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.md",
|
||||||
|
"options": {
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.json",
|
||||||
|
"options": {
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@page-agent/cdn",
|
|
||||||
"private": false,
|
|
||||||
"version": "0.0.24",
|
|
||||||
"type": "module",
|
|
||||||
"files": [
|
|
||||||
"dist/"
|
|
||||||
],
|
|
||||||
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
|
||||||
"author": "Simon<gaomeng1900>",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/alibaba/page-agent.git"
|
|
||||||
},
|
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build && vite build --mode demo"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"page-agent": "0.0.24"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
/**
|
|
||||||
* Demo CDN build for page-agent
|
|
||||||
* Auto-initializes with built-in demo API for testing
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
|
||||||
*/
|
|
||||||
import { PageAgent } from 'page-agent'
|
|
||||||
|
|
||||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
|
||||||
if (window.pageAgent) {
|
|
||||||
window.pageAgent.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount to global window object
|
|
||||||
window.PageAgent = PageAgent
|
|
||||||
|
|
||||||
// Export for IIFE
|
|
||||||
export { PageAgent }
|
|
||||||
|
|
||||||
console.log('🚀 page-agent.js loaded!')
|
|
||||||
|
|
||||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
|
||||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
|
|
||||||
// Demo warning
|
|
||||||
console.log(
|
|
||||||
'%c⚠️ DEMO MODE %c\n' +
|
|
||||||
'This build uses a shared testing LLM with rate limits.\n' +
|
|
||||||
'For production, use page-agent.js with your own API key:\n' +
|
|
||||||
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
|
||||||
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
|
||||||
'color: #f59e0b; font-size: 12px;'
|
|
||||||
)
|
|
||||||
|
|
||||||
// in case document.x is not ready yet
|
|
||||||
setTimeout(() => {
|
|
||||||
const currentScript = document.currentScript
|
|
||||||
|
|
||||||
if (currentScript) {
|
|
||||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
|
||||||
const url = new URL(currentScript.src)
|
|
||||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
|
||||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
|
||||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
|
||||||
const language = url.searchParams.get('lang') || 'zh-CN'
|
|
||||||
const config = { model, baseURL, apiKey, language }
|
|
||||||
window.pageAgent = new PageAgent(config)
|
|
||||||
} else {
|
|
||||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
|
||||||
window.pageAgent = new PageAgent()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
|
||||||
|
|
||||||
window.pageAgent.panel.show()
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
/**
|
|
||||||
* Full CDN build for page-agent
|
|
||||||
* Exposes PageAgent class for manual instantiation and configuration
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
|
||||||
* <script>
|
|
||||||
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
|
||||||
* agent.panel.show()
|
|
||||||
* </script>
|
|
||||||
*/
|
|
||||||
import { PageAgent } from 'page-agent'
|
|
||||||
|
|
||||||
export { PageAgent }
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { dirname, resolve } from 'path'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CDN IIFE builds for page-agent
|
|
||||||
*
|
|
||||||
* Build targets (via --mode):
|
|
||||||
* - demo: page-agent.demo.js with auto-init and built-in API
|
|
||||||
* - full (default): page-agent.js exposes PageAgent class only
|
|
||||||
*/
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const isDemo = mode === 'demo'
|
|
||||||
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
|
||||||
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
|
||||||
publicDir: false,
|
|
||||||
esbuild: {
|
|
||||||
keepNames: true,
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry,
|
|
||||||
name: 'PageAgent',
|
|
||||||
fileName: () => `${fileName}.js`,
|
|
||||||
formats: ['iife'],
|
|
||||||
},
|
|
||||||
outDir: resolve(__dirname, 'dist'),
|
|
||||||
emptyOutDir: !isDemo, // only empty on first build (full)
|
|
||||||
minify: false,
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'process.env.NODE_ENV': '"production"',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "@page-agent/core",
|
||||||
|
"private": false,
|
||||||
|
"version": "1.12.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/PageAgentCore.ts",
|
||||||
|
"types": "./src/PageAgentCore.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/PageAgentCore.ts",
|
||||||
|
"default": "./src/PageAgentCore.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "./dist/esm/page-agent-core.js",
|
||||||
|
"module": "./dist/esm/page-agent-core.js",
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"import": "./dist/esm/page-agent-core.js",
|
||||||
|
"default": "./dist/esm/page-agent-core.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"automation",
|
||||||
|
"ui-agent",
|
||||||
|
"GUI-agent",
|
||||||
|
"browser-automation",
|
||||||
|
"web-agent",
|
||||||
|
"llm",
|
||||||
|
"dom-interaction",
|
||||||
|
"web-automation",
|
||||||
|
"GUI-simulation"
|
||||||
|
],
|
||||||
|
"author": "Simon<gaomeng1900>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||||
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^5.6.2",
|
||||||
|
"@page-agent/llms": "1.12.1",
|
||||||
|
"@page-agent/page-controller": "1.12.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import { PageAgentCore, tool } from './PageAgentCore'
|
||||||
|
import type { ExecutionResult } from './types'
|
||||||
|
|
||||||
|
type TestFetch = (...args: Parameters<typeof globalThis.fetch>) => Promise<Response>
|
||||||
|
|
||||||
|
function agentResponse(args: unknown): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
name: 'AgentOutput',
|
||||||
|
arguments: JSON.stringify(args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageController(): PageController {
|
||||||
|
const browserState: BrowserState = {
|
||||||
|
url: 'https://example.test/',
|
||||||
|
title: 'Test page',
|
||||||
|
header: '',
|
||||||
|
content: '',
|
||||||
|
footer: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showMask: vi.fn(async () => {}),
|
||||||
|
hideMask: vi.fn(),
|
||||||
|
cleanUpHighlights: vi.fn(),
|
||||||
|
getLastUpdateTime: vi.fn(() => Date.now()),
|
||||||
|
getBrowserState: vi.fn(async () => browserState),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
} as unknown as PageController
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(
|
||||||
|
customFetch: TestFetch,
|
||||||
|
options: Partial<ConstructorParameters<typeof PageAgentCore>[0]> = {}
|
||||||
|
): PageAgentCore {
|
||||||
|
return new PageAgentCore({
|
||||||
|
baseURL: 'https://llm.test',
|
||||||
|
model: 'test-model',
|
||||||
|
maxRetries: 0,
|
||||||
|
stepDelay: 0,
|
||||||
|
customFetch,
|
||||||
|
customSystemPrompt: 'test',
|
||||||
|
pageController: createPageController(),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchMock() {
|
||||||
|
return vi.fn<TestFetch>()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onceActivity(
|
||||||
|
agent: PageAgentCore,
|
||||||
|
predicate: (detail: unknown) => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onActivity = (event: Event) => {
|
||||||
|
if (!predicate((event as CustomEvent).detail)) return
|
||||||
|
agent.removeEventListener('activity', onActivity)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.addEventListener('activity', onActivity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutingTool(detail: unknown, toolName: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof detail === 'object' &&
|
||||||
|
detail !== null &&
|
||||||
|
'type' in detail &&
|
||||||
|
'tool' in detail &&
|
||||||
|
detail.type === 'executing' &&
|
||||||
|
detail.tool === toolName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneResponse(text: string, success = true): Response {
|
||||||
|
return agentResponse({ action: { done: { text, success } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitResponse(seconds = 10): Response {
|
||||||
|
return agentResponse({ action: { wait: { seconds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a task that blocks on `wait`, returning once the tool is executing.
|
||||||
|
* The running promise is wrapped so awaiting this helper does not await the task.
|
||||||
|
*/
|
||||||
|
async function startBlockedTask(
|
||||||
|
agent: PageAgentCore,
|
||||||
|
task = 'first'
|
||||||
|
): Promise<{ result: Promise<ExecutionResult> }> {
|
||||||
|
const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait'))
|
||||||
|
const result = agent.execute(task)
|
||||||
|
await waitStarted
|
||||||
|
return { result }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.concurrent('PageAgentCore lifecycle', () => {
|
||||||
|
describe('normal execution', () => {
|
||||||
|
it('runs a task to natural completion', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
|
||||||
|
const result = await agent.execute('do something')
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, data: 'all done' })
|
||||||
|
expect(agent.status).toBe('completed')
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes (not errors) when the LLM reports task failure', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('gave up', false))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
|
||||||
|
const result = await agent.execute('do something')
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: false, data: 'gave up' })
|
||||||
|
expect(agent.status).toBe('completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when a task is already running', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
|
||||||
|
|
||||||
|
await agent.stop()
|
||||||
|
await result
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('aborts the running task and keeps the agent reusable', async () => {
|
||||||
|
const fetchMock = createFetchMock()
|
||||||
|
.mockResolvedValueOnce(waitResponse())
|
||||||
|
.mockResolvedValueOnce(doneResponse('second task'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: firstTask } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
await agent.stop()
|
||||||
|
expect(agent.status).toBe('stopped')
|
||||||
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
|
||||||
|
const secondTask = await agent.execute('second')
|
||||||
|
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
|
||||||
|
expect(agent.status).toBe('completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves only after the run has fully settled', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
await agent.stop()
|
||||||
|
expect(agent.status).toBe('stopped')
|
||||||
|
await expect(result).resolves.toMatchObject({ success: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op when no task is running', async () => {
|
||||||
|
const agent = createAgent(createFetchMock())
|
||||||
|
|
||||||
|
await expect(agent.stop()).resolves.toBeUndefined()
|
||||||
|
await expect(agent.stop()).resolves.toBeUndefined()
|
||||||
|
expect(agent.status).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('aborts the running task and blocks further execution', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: task } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
agent.dispose()
|
||||||
|
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
|
||||||
|
expect(agent.disposed).toBe(true)
|
||||||
|
await expect(agent.execute('again')).rejects.toThrow('has been disposed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const agent = createAgent(createFetchMock())
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
agent.dispose()
|
||||||
|
agent.dispose()
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(agent.disposed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('fails the task when the network request rejects', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockRejectedValue(new Error('network down'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
|
||||||
|
const result = await agent.execute('do something')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails the task when a tool throws', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } }))
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
customTools: {
|
||||||
|
boom: tool({
|
||||||
|
description: 'Always throws.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error('tool exploded')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await agent.execute('trigger tool error')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-throws and sets error status when onBeforeTask throws', async () => {
|
||||||
|
const agent = createAgent(createFetchMock(), {
|
||||||
|
onBeforeTask: async () => {
|
||||||
|
throw new Error('setup failed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(agent.execute('do something')).rejects.toThrow('setup failed')
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-throws and sets error status when onAfterTask throws', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
onAfterTask: async () => {
|
||||||
|
throw new Error('teardown failed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(agent.execute('do something')).rejects.toThrow('teardown failed')
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays reusable after onBeforeTask throws', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('second'))
|
||||||
|
let failOnce = true
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
onBeforeTask: async () => {
|
||||||
|
if (failOnce) {
|
||||||
|
failOnce = false
|
||||||
|
throw new Error('setup failed')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(agent.execute('first')).rejects.toThrow('setup failed')
|
||||||
|
const result = await agent.execute('second')
|
||||||
|
expect(result).toMatchObject({ success: true, data: 'second' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-throws and sets error status when onBeforeStep throws', async () => {
|
||||||
|
const agent = createAgent(createFetchMock(), {
|
||||||
|
onBeforeStep: async () => {
|
||||||
|
throw new Error('before step failed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(agent.execute('do something')).rejects.toThrow('before step failed')
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-throws and sets error status when onAfterStep throws', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
onAfterStep: async () => {
|
||||||
|
throw new Error('after step failed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(agent.execute('do something')).rejects.toThrow('after step failed')
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cancellation edge cases', () => {
|
||||||
|
it('rejects a new task while a stop is still settling', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: firstTask } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
const stopped = agent.stop()
|
||||||
|
|
||||||
|
await expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
|
||||||
|
|
||||||
|
await stopped
|
||||||
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards a custom tool result that resolves after stop', async () => {
|
||||||
|
let resolveTool!: () => void
|
||||||
|
let notifyToolStarted!: () => void
|
||||||
|
const toolFinished = new Promise<void>((resolve) => {
|
||||||
|
resolveTool = resolve
|
||||||
|
})
|
||||||
|
const toolStarted = new Promise<void>((resolve) => {
|
||||||
|
notifyToolStarted = resolve
|
||||||
|
})
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValue(
|
||||||
|
agentResponse({ action: { slow_tool: {} } })
|
||||||
|
)
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
customTools: {
|
||||||
|
slow_tool: tool({
|
||||||
|
description: 'A tool that deliberately ignores cancellation.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
notifyToolStarted()
|
||||||
|
await toolFinished
|
||||||
|
return 'ignored stop'
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = agent.execute('run slow tool')
|
||||||
|
await toolStarted
|
||||||
|
|
||||||
|
const stopped = agent.stop()
|
||||||
|
resolveTool()
|
||||||
|
await stopped
|
||||||
|
|
||||||
|
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
expect(agent.status).toBe('stopped')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||||
|
* Copyright (C) 2026 SimonLuvRamen
|
||||||
|
* All rights reserved.
|
||||||
|
*/
|
||||||
|
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
||||||
|
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||||
|
import { tools } from './tools'
|
||||||
|
import type {
|
||||||
|
AgentActivity,
|
||||||
|
AgentConfig,
|
||||||
|
AgentReflection,
|
||||||
|
AgentStatus,
|
||||||
|
AgentStepEvent,
|
||||||
|
ExecutionResult,
|
||||||
|
HistoricalEvent,
|
||||||
|
MacroToolInput,
|
||||||
|
MacroToolResult,
|
||||||
|
} from './types'
|
||||||
|
import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
|
||||||
|
|
||||||
|
export { tool, type PageAgentTool } from './tools'
|
||||||
|
export type * from './types'
|
||||||
|
|
||||||
|
export type PageAgentCoreConfig = AgentConfig & { pageController: PageController }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI agent for browser automation.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* ## Re-act Agent Loop
|
||||||
|
* - step
|
||||||
|
* - observe (gather information about current environment and context)
|
||||||
|
* - think (LLM calling)
|
||||||
|
* - reflection (evaluate history, generate memory, short-term planning)
|
||||||
|
* - action (give the action to approach the next goal)
|
||||||
|
* - act (execute the action)
|
||||||
|
* - loop
|
||||||
|
*
|
||||||
|
* ## Event System
|
||||||
|
* - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
|
||||||
|
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||||
|
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||||
|
* - `dispose` - Agent cleanup triggered
|
||||||
|
*
|
||||||
|
* ## Information Streams
|
||||||
|
* 1. **History Events** (`history` array)
|
||||||
|
* - Persistent event stream that forms agent's memory
|
||||||
|
* - Included in LLM context across steps
|
||||||
|
* - Types: steps, observations, user takeovers, llm errors
|
||||||
|
*
|
||||||
|
* 2. **Activity Events** (via `activity` event)
|
||||||
|
* - Transient UI feedback during task execution
|
||||||
|
* - NOT included in LLM context
|
||||||
|
* - Types: thinking, executing, executed, retrying, error
|
||||||
|
*/
|
||||||
|
export class PageAgentCore extends EventTarget {
|
||||||
|
readonly id = uid()
|
||||||
|
readonly config: PageAgentCoreConfig & { maxSteps: number }
|
||||||
|
readonly tools: typeof tools
|
||||||
|
/** PageController for DOM operations */
|
||||||
|
readonly pageController: PageController
|
||||||
|
|
||||||
|
task = ''
|
||||||
|
taskId = ''
|
||||||
|
/** History events */
|
||||||
|
history: HistoricalEvent[] = []
|
||||||
|
/** Whether this agent has been disposed */
|
||||||
|
disposed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the agent needs to ask the user questions.
|
||||||
|
* If unset, the `ask_user` tool will be disabled.
|
||||||
|
* Implementations should reject the promise when `signal` aborts.
|
||||||
|
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||||
|
*/
|
||||||
|
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
|
||||||
|
|
||||||
|
#status: AgentStatus = 'idle'
|
||||||
|
#llm: LLM
|
||||||
|
/**
|
||||||
|
* Task cancellation primitive: its signal reaches the LLM fetch, tools
|
||||||
|
* (via `ctx.signal`) and async callbacks. Aborted only by `stop`/`dispose`
|
||||||
|
* (during a task) or task setup, always WITHOUT a reason so `signal.reason`
|
||||||
|
* stays a standard `AbortError`.
|
||||||
|
*/
|
||||||
|
#abortController = new AbortController()
|
||||||
|
#observations: string[] = []
|
||||||
|
|
||||||
|
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
|
||||||
|
#running: Promise<void> = Promise.resolve()
|
||||||
|
#lastResult: ExecutionResult | null = null
|
||||||
|
|
||||||
|
/** internal states during a single task execution */
|
||||||
|
#states = {
|
||||||
|
/** Accumulated wait time in seconds */
|
||||||
|
totalWaitTime: 0,
|
||||||
|
/** For detecting navigation */
|
||||||
|
lastURL: '',
|
||||||
|
/** Browser state */
|
||||||
|
browserState: null as BrowserState | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(config: PageAgentCoreConfig) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.config = { ...config, maxSteps: config.maxSteps ?? 40 }
|
||||||
|
|
||||||
|
this.#llm = new LLM(this.config)
|
||||||
|
this.tools = new Map(tools)
|
||||||
|
this.pageController = config.pageController
|
||||||
|
|
||||||
|
this.#llm.addEventListener('retry', (e) => {
|
||||||
|
const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail
|
||||||
|
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
|
||||||
|
this.history.push({
|
||||||
|
type: 'error',
|
||||||
|
message: String(lastError),
|
||||||
|
rawResponse: (lastError as InvokeError).rawResponse,
|
||||||
|
})
|
||||||
|
this.history.push({
|
||||||
|
type: 'retry',
|
||||||
|
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.config.customTools) {
|
||||||
|
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||||
|
if (tool === null) {
|
||||||
|
this.tools.delete(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.tools.set(name, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.experimentalScriptExecutionTool) {
|
||||||
|
this.tools.delete('execute_javascript')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current agent status */
|
||||||
|
get status(): AgentStatus {
|
||||||
|
return this.#status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of the most recent run, or `null` before the first run completes. */
|
||||||
|
get lastResult(): ExecutionResult | null {
|
||||||
|
return this.#lastResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit statuschange event */
|
||||||
|
#emitStatusChange(): void {
|
||||||
|
this.dispatchEvent(new Event('statuschange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit historychange event */
|
||||||
|
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
|
||||||
|
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
|
||||||
|
this.dispatchEvent(new Event('historychange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit activity event - for transient UI feedback
|
||||||
|
* @param activity - Current agent activity
|
||||||
|
*/
|
||||||
|
#emitActivity(activity: AgentActivity): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update status and emit event */
|
||||||
|
#setStatus(status: AgentStatus): void {
|
||||||
|
if (this.#status !== status) {
|
||||||
|
this.#status = status
|
||||||
|
this.#emitStatusChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push an observation message to the history event stream.
|
||||||
|
* This will be visible in <agent_history> and remain persistent in memory across steps.
|
||||||
|
* @experimental @internal
|
||||||
|
* @note history change will be emitted before next step starts
|
||||||
|
*/
|
||||||
|
pushObservation(content: string): void {
|
||||||
|
this.#observations.push(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
|
||||||
|
* @note never await .stop() in a lifecycle hook.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.#status !== 'running') return
|
||||||
|
this.#abortController.abort()
|
||||||
|
await this.#running
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* external errors (pre-checks/config/hooks) will threw;
|
||||||
|
* agent errors will be caught and added to history, and return a failed result
|
||||||
|
*/
|
||||||
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
|
// pre-checks
|
||||||
|
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
||||||
|
if (this.#status === 'running') throw new Error('A task is already running.')
|
||||||
|
if (!task) throw new Error('Task is required')
|
||||||
|
|
||||||
|
this.task = task
|
||||||
|
this.taskId = uid()
|
||||||
|
|
||||||
|
this.history = []
|
||||||
|
this.#observations = []
|
||||||
|
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||||
|
this.#abortController = new AbortController()
|
||||||
|
const signal = this.#abortController.signal
|
||||||
|
|
||||||
|
let resolveRunning!: () => void
|
||||||
|
this.#running = new Promise<void>((r) => (resolveRunning = r))
|
||||||
|
|
||||||
|
this.#setStatus('running')
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
|
// Disable ask_user tool if onAskUser is not set
|
||||||
|
if (!this.onAskUser) this.tools.delete('ask_user')
|
||||||
|
|
||||||
|
const onBeforeStep = this.config.onBeforeStep
|
||||||
|
const onAfterStep = this.config.onAfterStep
|
||||||
|
const onBeforeTask = this.config.onBeforeTask
|
||||||
|
const onAfterTask = this.config.onAfterTask
|
||||||
|
const stepDelay = this.config.stepDelay ?? 0.4
|
||||||
|
const maxSteps = this.config.maxSteps
|
||||||
|
|
||||||
|
let step = 0
|
||||||
|
let taskResult: ExecutionResult
|
||||||
|
let finalStatus: AgentStatus = 'error'
|
||||||
|
|
||||||
|
await suppress(() => this.pageController.showMask())
|
||||||
|
|
||||||
|
// graceful exit
|
||||||
|
try {
|
||||||
|
await onBeforeTask?.(this)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await onBeforeStep?.(this, step)
|
||||||
|
|
||||||
|
// handle internal agent errors
|
||||||
|
try {
|
||||||
|
console.group(`step: ${step}`)
|
||||||
|
|
||||||
|
// @note It's convenient to treat stepDelay as part of the next step.
|
||||||
|
// Maybe move it to a dedicated try block for better semantics?
|
||||||
|
if (step > 0) await waitFor(stepDelay, signal)
|
||||||
|
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
|
// observe
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold('👀 Observing...'))
|
||||||
|
|
||||||
|
this.#states.browserState = await this.pageController.getBrowserState()
|
||||||
|
await this.#handleObservations(step)
|
||||||
|
|
||||||
|
// assemble prompts
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||||
|
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||||
|
]
|
||||||
|
|
||||||
|
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||||
|
|
||||||
|
// invoke LLM
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||||
|
this.#emitActivity({ type: 'thinking' })
|
||||||
|
|
||||||
|
const result = await this.#llm.invoke(messages, macroTool, signal, {
|
||||||
|
toolChoiceName: 'AgentOutput',
|
||||||
|
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||||
|
})
|
||||||
|
|
||||||
|
// assemble history
|
||||||
|
|
||||||
|
const macroResult = result.toolResult as MacroToolResult
|
||||||
|
const input = macroResult.input
|
||||||
|
const output = macroResult.output
|
||||||
|
const reflection: Partial<AgentReflection> = {
|
||||||
|
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||||
|
memory: input.memory,
|
||||||
|
next_goal: input.next_goal,
|
||||||
|
}
|
||||||
|
const actionName = Object.keys(input.action)[0]
|
||||||
|
const action: AgentStepEvent['action'] = {
|
||||||
|
name: actionName,
|
||||||
|
input: input.action[actionName],
|
||||||
|
output: output,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#emitHistoryChange({
|
||||||
|
type: 'step',
|
||||||
|
stepIndex: step,
|
||||||
|
reflection,
|
||||||
|
action,
|
||||||
|
usage: result.usage,
|
||||||
|
rawResponse: result.rawResponse,
|
||||||
|
rawRequest: result.rawRequest,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (actionName === 'done') {
|
||||||
|
const success = action.input?.success ?? false
|
||||||
|
const data = action.input?.text || 'no text provided'
|
||||||
|
console.log(chalk.green.bold('Task completed'), success, data)
|
||||||
|
taskResult = { success, data, history: this.history }
|
||||||
|
this.#lastResult = taskResult
|
||||||
|
finalStatus = 'completed'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
|
||||||
|
|
||||||
|
const isAbortError = (error as any)?.name === 'AbortError'
|
||||||
|
if (!isAbortError) console.error('Task failed', error)
|
||||||
|
const message = isAbortError ? 'Task aborted' : String(error)
|
||||||
|
this.#emitActivity({ type: 'error', message: message })
|
||||||
|
this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error })
|
||||||
|
taskResult = { success: false, data: message, history: this.history }
|
||||||
|
this.#lastResult = taskResult
|
||||||
|
finalStatus = isAbortError ? 'stopped' : 'error'
|
||||||
|
break
|
||||||
|
} finally {
|
||||||
|
// finally block runs before the break above.
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
// @note hook may throw error.
|
||||||
|
// which will override the `break` above and be handled as an external error.
|
||||||
|
// as expected.
|
||||||
|
await onAfterStep?.(this, this.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
step++
|
||||||
|
if (step > maxSteps) {
|
||||||
|
const message = 'Step count exceeded maximum limit'
|
||||||
|
console.error(message)
|
||||||
|
this.#emitActivity({ type: 'error', message: message })
|
||||||
|
this.#emitHistoryChange({ type: 'error', message: message })
|
||||||
|
taskResult = { success: false, data: message, history: this.history }
|
||||||
|
this.#lastResult = taskResult
|
||||||
|
finalStatus = 'error'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} // while
|
||||||
|
|
||||||
|
await onAfterTask?.(this, taskResult)
|
||||||
|
|
||||||
|
return taskResult
|
||||||
|
} catch (error) {
|
||||||
|
this.#emitActivity({ type: 'error', message: String(error) })
|
||||||
|
finalStatus = 'error'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await suppress(() => this.pageController.cleanUpHighlights())
|
||||||
|
await suppress(() => this.pageController.hideMask())
|
||||||
|
this.#abortController.abort()
|
||||||
|
resolveRunning()
|
||||||
|
this.#setStatus(finalStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all tools into a single MacroTool with the following input:
|
||||||
|
* - thinking: string
|
||||||
|
* - evaluation_previous_goal: string
|
||||||
|
* - memory: string
|
||||||
|
* - next_goal: string
|
||||||
|
* - action: { toolName: toolInput }
|
||||||
|
* where action must be selected from tools defined in this.tools
|
||||||
|
*/
|
||||||
|
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
|
||||||
|
const tools = this.tools
|
||||||
|
|
||||||
|
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||||
|
return z.object({ [toolName]: tool.inputSchema }).describe(tool.description)
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionSchema = z.union(actionSchemas as unknown as [z.ZodType, z.ZodType, ...z.ZodType[]])
|
||||||
|
|
||||||
|
const macroToolSchema = z.object({
|
||||||
|
// thinking: z.string().optional(),
|
||||||
|
evaluation_previous_goal: z.string().optional(),
|
||||||
|
memory: z.string().optional(),
|
||||||
|
next_goal: z.string().optional(),
|
||||||
|
action: actionSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: 'You MUST call this tool every step!',
|
||||||
|
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
||||||
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
|
const signal = this.#abortController.signal
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold('MacroTool input'), input)
|
||||||
|
const action = input.action
|
||||||
|
|
||||||
|
const toolName = Object.keys(action)[0]
|
||||||
|
const toolInput = action[toolName]
|
||||||
|
|
||||||
|
// Build reflection text, only include non-empty fields
|
||||||
|
const reflectionLines: string[] = []
|
||||||
|
if (input.evaluation_previous_goal)
|
||||||
|
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
|
||||||
|
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
|
||||||
|
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
|
||||||
|
|
||||||
|
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
|
||||||
|
|
||||||
|
if (reflectionText) {
|
||||||
|
console.log(reflectionText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the corresponding tool
|
||||||
|
const tool = tools.get(toolName)
|
||||||
|
assert(tool, `Tool ${toolName} not found`)
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||||
|
|
||||||
|
// Emit executing activity
|
||||||
|
this.#emitActivity({ type: 'executing', tool: toolName, input: toolInput })
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const result = await tool.execute.bind(this)(toolInput, { signal })
|
||||||
|
// Enforce abort even if the tool ignored the signal and resolved normally.
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||||
|
|
||||||
|
// Emit executed activity
|
||||||
|
this.#emitActivity({
|
||||||
|
type: 'executed',
|
||||||
|
tool: toolName,
|
||||||
|
input: toolInput,
|
||||||
|
output: result,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
|
||||||
|
// counting wait time
|
||||||
|
if (toolName === 'wait') {
|
||||||
|
this.#states.totalWaitTime += toolInput?.seconds || 0
|
||||||
|
} else {
|
||||||
|
this.#states.totalWaitTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return structured result
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output: result,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system prompt, dynamically replace language settings based on configured language
|
||||||
|
*/
|
||||||
|
#getSystemPrompt(): string {
|
||||||
|
if (this.config.customSystemPrompt) {
|
||||||
|
return this.config.customSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||||
|
/Default working language: \*\*.*?\*\*/,
|
||||||
|
`Default working language: **${targetLanguage}**`
|
||||||
|
)
|
||||||
|
|
||||||
|
return systemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get instructions from config
|
||||||
|
*/
|
||||||
|
async #getInstructions(): Promise<string> {
|
||||||
|
const { instructions, experimentalLlmsTxt } = this.config
|
||||||
|
|
||||||
|
const systemInstructions = instructions?.system?.trim()
|
||||||
|
let pageInstructions: string | undefined
|
||||||
|
|
||||||
|
const url = this.#states.browserState?.url || ''
|
||||||
|
if (instructions?.getPageInstructions && url) {
|
||||||
|
try {
|
||||||
|
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmsTxt = experimentalLlmsTxt && url ? await fetchLlmsTxt(url) : undefined
|
||||||
|
|
||||||
|
if (!systemInstructions && !pageInstructions && !llmsTxt) return ''
|
||||||
|
|
||||||
|
let result = '<instructions>\n'
|
||||||
|
|
||||||
|
if (systemInstructions) {
|
||||||
|
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageInstructions) {
|
||||||
|
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (llmsTxt) {
|
||||||
|
result += `<llms_txt>\n${llmsTxt}\n</llms_txt>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
result += '</instructions>\n\n'
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate system observations before each step
|
||||||
|
* @todo loop detection
|
||||||
|
* @todo console error
|
||||||
|
*/
|
||||||
|
async #handleObservations(step: number): Promise<void> {
|
||||||
|
// Accumulated wait time warning
|
||||||
|
if (this.#states.totalWaitTime >= 3) {
|
||||||
|
this.pushObservation(
|
||||||
|
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. ` +
|
||||||
|
`DO NOT wait any longer unless you have a good reason.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect URL change
|
||||||
|
const currentURL = this.#states.browserState?.url || ''
|
||||||
|
if (currentURL !== this.#states.lastURL) {
|
||||||
|
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||||
|
this.#states.lastURL = currentURL
|
||||||
|
await waitFor(0.5) // wait for page to stabilize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining steps warning
|
||||||
|
const remaining = this.config.maxSteps - step
|
||||||
|
if (remaining === 5) {
|
||||||
|
this.pushObservation(
|
||||||
|
`⚠️ Only ${remaining} steps remaining. ` +
|
||||||
|
`Consider wrapping up or calling done with partial results.`
|
||||||
|
)
|
||||||
|
} else if (remaining === 2) {
|
||||||
|
this.pushObservation(
|
||||||
|
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push observations to history and emit
|
||||||
|
if (this.#observations.length > 0) {
|
||||||
|
for (const content of this.#observations) {
|
||||||
|
this.history.push({ type: 'observation', content })
|
||||||
|
console.log(chalk.cyan('Observation:'), content)
|
||||||
|
}
|
||||||
|
this.#observations = []
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #assembleUserPrompt(): Promise<string> {
|
||||||
|
const browserState = this.#states.browserState!
|
||||||
|
|
||||||
|
let prompt = ''
|
||||||
|
|
||||||
|
// <instructions> (optional)
|
||||||
|
|
||||||
|
prompt += await this.#getInstructions()
|
||||||
|
|
||||||
|
// <agent_state>
|
||||||
|
// - <user_request>
|
||||||
|
// - <step_info>
|
||||||
|
// <agent_state>
|
||||||
|
|
||||||
|
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||||
|
|
||||||
|
prompt += '<agent_state>\n'
|
||||||
|
prompt += '<user_request>\n'
|
||||||
|
prompt += `${this.task}\n`
|
||||||
|
prompt += '</user_request>\n'
|
||||||
|
prompt += '<step_info>\n'
|
||||||
|
prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
|
||||||
|
prompt += `Current time: ${new Date().toLocaleString()}\n`
|
||||||
|
prompt += '</step_info>\n'
|
||||||
|
prompt += '</agent_state>\n\n'
|
||||||
|
|
||||||
|
// <agent_history>
|
||||||
|
// - <step_N> for steps
|
||||||
|
// - <sys> for observations and system messages
|
||||||
|
|
||||||
|
prompt += '<agent_history>\n'
|
||||||
|
|
||||||
|
let stepIndex = 0
|
||||||
|
for (const event of this.history) {
|
||||||
|
if (event.type === 'step') {
|
||||||
|
stepIndex++
|
||||||
|
prompt += `<step_${stepIndex}>\n`
|
||||||
|
prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
|
||||||
|
prompt += `Memory: ${event.reflection.memory}\n`
|
||||||
|
prompt += `Next Goal: ${event.reflection.next_goal}\n`
|
||||||
|
prompt += `Action Results: ${event.action.output}\n`
|
||||||
|
prompt += `</step_${stepIndex}>\n`
|
||||||
|
} else if (event.type === 'observation') {
|
||||||
|
prompt += `<sys>${event.content}</sys>\n`
|
||||||
|
} else if (event.type === 'user_takeover') {
|
||||||
|
prompt += `<sys>User took over control and made changes to the page</sys>\n`
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
// Error events are mainly for panel rendering, not included in LLM context
|
||||||
|
// to avoid polluting the agent's reasoning with transient errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += '</agent_history>\n\n'
|
||||||
|
|
||||||
|
// <browser_state>
|
||||||
|
|
||||||
|
let pageContent = browserState.content
|
||||||
|
if (this.config.transformPageContent) {
|
||||||
|
pageContent = await this.config.transformPageContent(pageContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += '<browser_state>\n'
|
||||||
|
prompt += browserState.header + '\n'
|
||||||
|
prompt += pageContent + '\n'
|
||||||
|
prompt += browserState.footer + '\n\n'
|
||||||
|
prompt += '</browser_state>\n\n'
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
console.log('Disposing PageAgent...')
|
||||||
|
this.disposed = true
|
||||||
|
this.pageController.dispose()
|
||||||
|
// this.history = []
|
||||||
|
this.#abortController.abort()
|
||||||
|
|
||||||
|
// Emit dispose event for UI cleanup
|
||||||
|
this.dispatchEvent(new Event('dispose'))
|
||||||
|
|
||||||
|
this.config.onDispose?.(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.md?raw' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
+10
-14
@@ -10,7 +10,7 @@ You excel at following tasks:
|
|||||||
</intro>
|
</intro>
|
||||||
|
|
||||||
<language_settings>
|
<language_settings>
|
||||||
- Default working language: **中文**
|
- Default working language: **English**
|
||||||
- Use the language that user is using. Return in user's language.
|
- Use the language that user is using. Return in user's language.
|
||||||
</language_settings>
|
</language_settings>
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
|
|||||||
<user_request>
|
<user_request>
|
||||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||||
- This has the highest priority. Make the user happy.
|
- This has the highest priority. Make the user happy.
|
||||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||||
- If the task is open ended you can plan yourself how to get it done.
|
- If the task is open ended you can plan yourself how to get it done.
|
||||||
</user_request>
|
</user_request>
|
||||||
|
|
||||||
@@ -69,8 +69,7 @@ Strictly follow these rules while using the browser and navigating the web:
|
|||||||
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||||
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
|
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||||
- If expected elements are missing, try scrolling, or navigating back.
|
|
||||||
- If the page is not fully loaded, use the `wait` action.
|
- If the page is not fully loaded, use the `wait` action.
|
||||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||||
@@ -87,12 +86,11 @@ Strictly follow these rules while using the browser and navigating the web:
|
|||||||
|
|
||||||
<capability>
|
<capability>
|
||||||
- You can only handle single page app. Do not jump out of current page.
|
- You can only handle single page app. Do not jump out of current page.
|
||||||
- Do not click on link if it will open in a new page (etc. <a target="_blank">)
|
- Do not click on link if it will open in a new page (e.g., <a target="_blank">)
|
||||||
- It is ok to fail the task.
|
- It is ok to fail the task.
|
||||||
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
|
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
|
||||||
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
||||||
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
|
- Trying too hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted results and harmful side-effects. User would rather you complete the task with a fail.
|
||||||
- If you are not clear about the request or steps. `ask_user` to clarify it.
|
|
||||||
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
||||||
</capability>
|
</capability>
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
|
|||||||
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
|
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
|
||||||
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
|
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
|
||||||
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
|
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
|
||||||
- `ask_user` for help if you have any difficulty. Users want to be kept in the loop.
|
- Ask user for help if you have any difficulty. Keep user in the loop.
|
||||||
- If you see information relevant to <user_request>, plan saving the information to memory.
|
- If you see information relevant to <user_request>, plan saving the information to memory.
|
||||||
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
|
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
|
||||||
</reasoning_rules>
|
</reasoning_rules>
|
||||||
@@ -129,7 +127,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
|
|||||||
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||||
|
|
||||||
<evaluation_examples>
|
<evaluation_examples>
|
||||||
- Positive Examples:
|
|
||||||
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
||||||
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||||
</evaluation_examples>
|
</evaluation_examples>
|
||||||
@@ -140,17 +137,16 @@ Here are examples of good output patterns. Use them as reference but never copy
|
|||||||
|
|
||||||
<next_goal_examples>
|
<next_goal_examples>
|
||||||
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
||||||
"next_goal": "Extract details from the first item on the page."
|
|
||||||
</next_goal_examples>
|
</next_goal_examples>
|
||||||
</examples>
|
</examples>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
You must ALWAYS respond with a valid JSON in this exact format:
|
|
||||||
|
|
||||||
{
|
{
|
||||||
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
||||||
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
||||||
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
|
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
|
||||||
"action":{"one_action_name": {// action-specific parameter}}
|
"action":{
|
||||||
|
"Action name": {// Action parameters}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</output>
|
</output>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Internal tools for PageAgent.
|
||||||
|
* @note Adapted from browser-use
|
||||||
|
*/
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import type { PageAgentCore } from '../PageAgentCore'
|
||||||
|
import { waitFor } from '../utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-invocation context passed to every tool execution.
|
||||||
|
* Tools MUST honor `signal` to support cooperative cancellation.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
signal: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal tool definition that has access to PageAgent `this` context
|
||||||
|
*/
|
||||||
|
export interface PageAgentTool<TParams = any> {
|
||||||
|
// name: string
|
||||||
|
description: string
|
||||||
|
inputSchema: z.ZodType<TParams>
|
||||||
|
execute: (this: PageAgentCore, args: TParams, ctx: ToolContext) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal tools for PageAgent.
|
||||||
|
* Note: Using any to allow different parameter types for each tool
|
||||||
|
*/
|
||||||
|
export const tools = new Map<string, PageAgentTool>()
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'done',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Complete task. Text is your final response to the user — keep it concise unless the user explicitly asks for detail.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
success: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
// @note main loop will handle this one
|
||||||
|
return Promise.resolve('Task completed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'wait',
|
||||||
|
tool({
|
||||||
|
description: 'Wait for x seconds. Can be used to wait until the page or data is fully loaded.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
seconds: z.number().min(1).max(10).default(1),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
|
// try to subtract LLM calling time from the actual wait time
|
||||||
|
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||||
|
const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
|
||||||
|
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
|
||||||
|
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||||
|
await waitFor(actualWaitTime, signal)
|
||||||
|
|
||||||
|
const waitedSeconds = (secondsSinceLastUpdate + actualWaitTime).toFixed(2)
|
||||||
|
return `✅ Waited for ${waitedSeconds} seconds.`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'ask_user',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Ask the user a question and wait for their answer. Use this if you need more information or clarification.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
question: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
|
if (!this.onAskUser) {
|
||||||
|
throw new Error('ask_user tool requires onAskUser callback to be set')
|
||||||
|
}
|
||||||
|
const answer = await this.onAskUser(input.question, { signal })
|
||||||
|
return `User answered: ${answer}`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'click_element_by_index',
|
||||||
|
tool({
|
||||||
|
description: 'Click element by index',
|
||||||
|
inputSchema: z.object({
|
||||||
|
index: z.int().min(0),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
const result = await this.pageController.clickElement(input.index)
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'input_text',
|
||||||
|
tool({
|
||||||
|
description: 'Click and type text into an interactive input element',
|
||||||
|
inputSchema: z.object({
|
||||||
|
index: z.int().min(0),
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
const result = await this.pageController.inputText(input.index, input.text)
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'select_dropdown_option',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Select dropdown option for interactive element index by the text of the option you want to select',
|
||||||
|
inputSchema: z.object({
|
||||||
|
index: z.int().min(0),
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
const result = await this.pageController.selectOption(input.index, input.text)
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @note Reference from browser-use
|
||||||
|
*/
|
||||||
|
tools.set(
|
||||||
|
'scroll',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Scroll vertically. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
down: z.boolean().default(true),
|
||||||
|
num_pages: z.number().min(0).max(10).optional().default(0.1),
|
||||||
|
pixels: z.number().int().min(0).optional(),
|
||||||
|
index: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
const result = await this.pageController.scroll({
|
||||||
|
...input,
|
||||||
|
numPages: input.num_pages,
|
||||||
|
})
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo Tables need a dedicated parser to extract structured data. This tool is useless.
|
||||||
|
*/
|
||||||
|
tools.set(
|
||||||
|
'scroll_horizontally',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Scroll horizontally. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
right: z.boolean().default(true),
|
||||||
|
pixels: z.number().int().min(0),
|
||||||
|
index: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
const result = await this.pageController.scrollHorizontally(input)
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.set(
|
||||||
|
'execute_javascript',
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
|
||||||
|
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
|
||||||
|
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
script: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
|
const result = await this.pageController.executeJavascript(input.script, signal)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
return result.message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// @todo send_keys
|
||||||
|
// @todo upload_file
|
||||||
|
// @todo extract_structured_data
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import type { LLMConfig } from '@page-agent/llms'
|
||||||
|
|
||||||
|
// @note circular dependency but okay
|
||||||
|
import type { PageAgentCore } from './PageAgentCore'
|
||||||
|
import type { PageAgentTool } from './tools'
|
||||||
|
|
||||||
|
/** Supported UI languages */
|
||||||
|
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||||
|
|
||||||
|
export interface AgentConfig extends LLMConfig {
|
||||||
|
language?: SupportedLanguage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of steps the agent can take per task.
|
||||||
|
* @default 40
|
||||||
|
*/
|
||||||
|
maxSteps?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tools to extend PageAgent capabilities
|
||||||
|
* @experimental
|
||||||
|
* @note You can also override or remove internal tools by using the same name.
|
||||||
|
* @see PageAgentTool
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // override internal tool
|
||||||
|
* import { z } from 'zod/v4'
|
||||||
|
* import { tool } from 'page-agent'
|
||||||
|
* const customTools = {
|
||||||
|
* ask_user: tool({
|
||||||
|
* description:
|
||||||
|
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||||
|
* inputSchema: z.object({
|
||||||
|
* question: z.string(),
|
||||||
|
* }),
|
||||||
|
* execute: async function (this: PageAgent, input) {
|
||||||
|
* const answer = await do_some_thing(input.question)
|
||||||
|
* return "✅ Received user answer: " + answer
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // remove internal tool
|
||||||
|
* const customTools = {
|
||||||
|
* ask_user: null // never ask user questions
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
customTools?: Record<string, PageAgentTool | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instructions to guide the agent's behavior
|
||||||
|
*/
|
||||||
|
instructions?: {
|
||||||
|
/**
|
||||||
|
* Global system-level instructions, applied to all tasks
|
||||||
|
*/
|
||||||
|
system?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic page-level instructions callback
|
||||||
|
* Called before each step to get instructions for the current page
|
||||||
|
* @param url - Current page URL (window.location.href)
|
||||||
|
* @returns Instructions string, or undefined/null to skip
|
||||||
|
*/
|
||||||
|
getPageInstructions?: (url: string) => string | undefined | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hooks for task execution.
|
||||||
|
* @experimental API may change in future versions.
|
||||||
|
*
|
||||||
|
* All hooks receive the agent instance as first parameter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before each step execution.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param stepCount - Current step number (0-indexed)
|
||||||
|
*/
|
||||||
|
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after each step execution.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param history - Current history of events
|
||||||
|
*/
|
||||||
|
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before task execution starts.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
*/
|
||||||
|
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after task execution completes (success or failure).
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param result - The execution result
|
||||||
|
*/
|
||||||
|
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the agent is disposed.
|
||||||
|
* @experimental
|
||||||
|
* @note This hook can block the disposal process if it's async.
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param reason - Optional reason for disposal
|
||||||
|
*/
|
||||||
|
onDispose?: (agent: PageAgentCore, reason?: string) => void
|
||||||
|
|
||||||
|
// page behavior hooks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||||
|
* @note Can cause unpredictable side effects.
|
||||||
|
* @note May bypass some safe guards and data-masking mechanisms.
|
||||||
|
*/
|
||||||
|
experimentalScriptExecutionTool?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Fetch /llms.txt from current site origin and include as context.
|
||||||
|
* Only fetched once per origin per task.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
experimentalLlmsTxt?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform page content before sending to LLM.
|
||||||
|
* Called after DOM extraction and simplification, before LLM invocation.
|
||||||
|
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||||
|
*
|
||||||
|
* @param content - Simplified page content that will be sent to LLM
|
||||||
|
* @returns Transformed content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Mask phone numbers
|
||||||
|
* transformPageContent: async (content) => {
|
||||||
|
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
transformPageContent?: (content: string) => Promise<string> | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completely override the default system prompt.
|
||||||
|
* @experimental Use with caution - incorrect prompts may break agent behavior.
|
||||||
|
*/
|
||||||
|
customSystemPrompt?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay between steps in seconds.
|
||||||
|
* @default 0.4
|
||||||
|
*/
|
||||||
|
stepDelay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent reflection state - the reflection-before-action model
|
||||||
|
*
|
||||||
|
* Every tool call must first reflect on:
|
||||||
|
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||||
|
* - memory: Key information to remember for future steps
|
||||||
|
* - next_goal: What should be accomplished in the next action?
|
||||||
|
*/
|
||||||
|
export interface AgentReflection {
|
||||||
|
evaluation_previous_goal: string
|
||||||
|
memory: string
|
||||||
|
next_goal: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacroTool input structure
|
||||||
|
*
|
||||||
|
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||||
|
* Before executing any action, the LLM must output its reasoning state.
|
||||||
|
*/
|
||||||
|
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||||
|
action: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacroTool output structure
|
||||||
|
*/
|
||||||
|
export interface MacroToolResult {
|
||||||
|
input: MacroToolInput
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single agent step with reflection and action
|
||||||
|
*/
|
||||||
|
export interface AgentStepEvent {
|
||||||
|
type: 'step'
|
||||||
|
stepIndex: number
|
||||||
|
reflection: Partial<AgentReflection>
|
||||||
|
action: {
|
||||||
|
name: string
|
||||||
|
input: any
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
usage: {
|
||||||
|
promptTokens: number
|
||||||
|
completionTokens: number
|
||||||
|
totalTokens: number
|
||||||
|
cachedTokens?: number
|
||||||
|
reasoningTokens?: number
|
||||||
|
}
|
||||||
|
/** Raw LLM response for debugging */
|
||||||
|
rawResponse?: unknown
|
||||||
|
/** Raw LLM request for debugging */
|
||||||
|
rawRequest?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent observation event (stays in memory)
|
||||||
|
*/
|
||||||
|
export interface ObservationEvent {
|
||||||
|
type: 'observation'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User takeover event
|
||||||
|
*/
|
||||||
|
export interface UserTakeoverEvent {
|
||||||
|
type: 'user_takeover'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry event - LLM call is being retried
|
||||||
|
*/
|
||||||
|
export interface RetryEvent {
|
||||||
|
type: 'retry'
|
||||||
|
message: string
|
||||||
|
attempt: number
|
||||||
|
maxAttempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error event - fatal error from LLM or execution
|
||||||
|
*/
|
||||||
|
export interface AgentErrorEvent {
|
||||||
|
type: 'error'
|
||||||
|
message: string
|
||||||
|
rawResponse?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for all history events
|
||||||
|
*/
|
||||||
|
export type HistoricalEvent =
|
||||||
|
AgentStepEvent | ObservationEvent | UserTakeoverEvent | RetryEvent | AgentErrorEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent lifecycle status.
|
||||||
|
*/
|
||||||
|
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
*
|
||||||
|
* Unlike historical events (which are persisted), activities are ephemeral
|
||||||
|
* and represent "what the agent is doing right now". UI components should
|
||||||
|
* listen to 'activity' events to show real-time feedback.
|
||||||
|
*
|
||||||
|
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||||
|
*/
|
||||||
|
export type AgentActivity =
|
||||||
|
| { type: 'thinking' }
|
||||||
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
success: boolean
|
||||||
|
data: string
|
||||||
|
history: HistoricalEvent[]
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import { InvokeError, InvokeErrorTypes } from '@page-agent/llms'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import type { PageAgentTool } from '../tools'
|
||||||
|
|
||||||
|
const log = console.log.bind(console, chalk.yellow('[autoFixer]'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize LLM response and fix common format issues.
|
* Normalize LLM response and fix common format issues.
|
||||||
@@ -9,10 +15,11 @@ import chalk from 'chalk'
|
|||||||
* - Arguments wrapped as double JSON string
|
* - Arguments wrapped as double JSON string
|
||||||
* - Nested function call format
|
* - Nested function call format
|
||||||
* - Missing action field (fallback to wait)
|
* - Missing action field (fallback to wait)
|
||||||
|
* - Primitive action input for single-field tools (e.g. `{"click_element_by_index": 2}`)
|
||||||
* - etc.
|
* - etc.
|
||||||
*/
|
*/
|
||||||
export function normalizeResponse(response: any): any {
|
export function normalizeResponse(response: any, tools?: Map<string, PageAgentTool>): any {
|
||||||
let resolvedArguments = null as any
|
let resolvedArguments: any
|
||||||
|
|
||||||
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||||
if (!choice) throw new Error('No choices in response')
|
if (!choice) throw new Error('No choices in response')
|
||||||
@@ -29,7 +36,7 @@ export function normalizeResponse(response: any): any {
|
|||||||
|
|
||||||
// case: sometimes the model only returns the action level
|
// case: sometimes the model only returns the action level
|
||||||
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||||
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
log(`#1: fixing tool_call`)
|
||||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -42,13 +49,13 @@ export function normalizeResponse(response: any): any {
|
|||||||
|
|
||||||
// case: sometimes the content json includes upper level wrapper
|
// case: sometimes the content json includes upper level wrapper
|
||||||
if (resolvedArguments?.name === 'AgentOutput') {
|
if (resolvedArguments?.name === 'AgentOutput') {
|
||||||
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
log(`#2: fixing tool_call`)
|
||||||
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// case: sometimes even 2-levels of wrapping
|
// case: sometimes even 2-levels of wrapping
|
||||||
if (resolvedArguments?.type === 'function') {
|
if (resolvedArguments?.type === 'function') {
|
||||||
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
log(`#3: fixing tool_call`)
|
||||||
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +68,7 @@ export function normalizeResponse(response: any): any {
|
|||||||
!resolvedArguments?.next_goal &&
|
!resolvedArguments?.next_goal &&
|
||||||
!resolvedArguments?.thinking
|
!resolvedArguments?.thinking
|
||||||
) {
|
) {
|
||||||
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
log(`#4: fixing tool_call`)
|
||||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -78,10 +85,15 @@ export function normalizeResponse(response: any): any {
|
|||||||
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate and fix action input using tool schemas
|
||||||
|
if (resolvedArguments.action && tools) {
|
||||||
|
resolvedArguments.action = validateAction(resolvedArguments.action, tools)
|
||||||
|
}
|
||||||
|
|
||||||
// fix incomplete formats
|
// fix incomplete formats
|
||||||
if (!resolvedArguments.action) {
|
if (!resolvedArguments.action) {
|
||||||
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
log(`#5: fixing tool_call`)
|
||||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
resolvedArguments.action = { wait: { seconds: 1 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// pack back to standard format
|
// pack back to standard format
|
||||||
@@ -108,6 +120,53 @@ export function normalizeResponse(response: any): any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate action against tool schemas. Provides clear error messages
|
||||||
|
* instead of letting the union schema produce unreadable errors.
|
||||||
|
*
|
||||||
|
* Also coerces primitive inputs for single-field tools:
|
||||||
|
* e.g. `{"click_element_by_index": 2}` → `{"click_element_by_index": {"index": 2}}`
|
||||||
|
*/
|
||||||
|
function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
||||||
|
if (typeof action !== 'object' || action === null) return action
|
||||||
|
|
||||||
|
const toolName = Object.keys(action)[0]
|
||||||
|
if (!toolName) return action
|
||||||
|
|
||||||
|
const tool = tools.get(toolName)
|
||||||
|
if (!tool) {
|
||||||
|
const available = Array.from(tools.keys()).join(', ')
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
|
`Unknown action "${toolName}". Available: ${available}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = action[toolName]
|
||||||
|
const schema = tool.inputSchema
|
||||||
|
|
||||||
|
// coerce primitive input for single-field tools
|
||||||
|
if (schema instanceof z.ZodObject && value !== null && typeof value !== 'object') {
|
||||||
|
const requiredKey = Object.keys(schema.shape).find(
|
||||||
|
(k) => !(schema.shape as Record<string, z.ZodType>)[k].safeParse(undefined).success
|
||||||
|
)
|
||||||
|
if (requiredKey) {
|
||||||
|
log(`coercing primitive action input for "${toolName}"`)
|
||||||
|
value = { [requiredKey]: value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = schema.safeParse(value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
|
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [toolName]: result.data }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse JSON, return original input if not json.
|
* Safely parse JSON, return original input if not json.
|
||||||
*/
|
*/
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import chalk from 'chalk'
|
||||||
|
|
||||||
|
export * from './autoFixer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for `seconds`. If a `signal` is provided, the wait is cancellable:
|
||||||
|
* aborting rejects with the signal's reason (an `AbortError`).
|
||||||
|
*/
|
||||||
|
export async function waitFor(seconds: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!signal) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.throwIfAborted()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
resolve()
|
||||||
|
}, seconds * 1000)
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
// reason is a DOMException AbortError.
|
||||||
|
reject(signal.reason as DOMException)
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length > maxLength) {
|
||||||
|
return text.substring(0, maxLength) + '...'
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
export function randomID(existingIDs?: string[]): string {
|
||||||
|
let id = Math.random().toString(36).substring(2, 11)
|
||||||
|
|
||||||
|
if (!existingIDs) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TRY = 1000
|
||||||
|
let tryCount = 0
|
||||||
|
|
||||||
|
while (existingIDs.includes(id)) {
|
||||||
|
id = Math.random().toString(36).substring(2, 11)
|
||||||
|
tryCount++
|
||||||
|
if (tryCount > MAX_TRY) {
|
||||||
|
throw new Error('randomID: too many tries')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
const _global = globalThis as any
|
||||||
|
|
||||||
|
if (!_global.__PAGE_AGENT_IDS__) {
|
||||||
|
_global.__PAGE_AGENT_IDS__ = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = _global.__PAGE_AGENT_IDS__
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random ID.
|
||||||
|
* @note Unique within this window.
|
||||||
|
*/
|
||||||
|
export function uid() {
|
||||||
|
const id = randomID(ids)
|
||||||
|
ids.push(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmsTxtCache = new Map<string, string | null>()
|
||||||
|
|
||||||
|
/** Fetch /llms.txt for a URL's origin. Cached per origin, `null` = tried and not found. */
|
||||||
|
export async function fetchLlmsTxt(url: string): Promise<string | null> {
|
||||||
|
let origin: string
|
||||||
|
try {
|
||||||
|
origin = new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return null // Invalid URL
|
||||||
|
}
|
||||||
|
// about:blank, data:, file:
|
||||||
|
if (origin === 'null') return null
|
||||||
|
|
||||||
|
if (llmsTxtCache.has(origin)) return llmsTxtCache.get(origin)!
|
||||||
|
|
||||||
|
const endpoint = `${origin}/llms.txt`
|
||||||
|
let result: string | null = null
|
||||||
|
try {
|
||||||
|
console.log(chalk.gray(`[llms.txt] Fetching ${endpoint}`))
|
||||||
|
const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) })
|
||||||
|
if (res.ok) {
|
||||||
|
result = await res.text()
|
||||||
|
console.log(chalk.green(`[llms.txt] Found (${result.length} chars)`))
|
||||||
|
if (result.length > 1000) {
|
||||||
|
console.log(chalk.yellow(`[llms.txt] Truncating to 1000 chars`))
|
||||||
|
result = truncate(result, 1000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug(chalk.gray(`[llms.txt] ${res.status} for ${endpoint}`))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(chalk.gray(`[llms.txt] not found for ${endpoint}`), e)
|
||||||
|
}
|
||||||
|
llmsTxtCache.set(origin, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple assertion function that throws an error if the condition is falsy
|
||||||
|
* @param condition - The condition to assert
|
||||||
|
* @param message - Optional error message
|
||||||
|
* @throws Error if condition is falsy
|
||||||
|
*/
|
||||||
|
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
const errorMessage = message ?? 'Assertion failed'
|
||||||
|
|
||||||
|
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
|
||||||
|
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress errors from a function.
|
||||||
|
*/
|
||||||
|
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import dts from 'unplugin-dts/vite'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
// ES Module for NPM Package
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
plugins: [
|
||||||
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
|
bundleTypes: true,
|
||||||
|
compilerOptions: {
|
||||||
|
composite: true,
|
||||||
|
noEmit: false,
|
||||||
|
emitDeclarationOnly: true,
|
||||||
|
declaration: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||||
|
],
|
||||||
|
publicDir: false,
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/PageAgentCore.ts'),
|
||||||
|
name: 'PageAgentCore',
|
||||||
|
fileName: 'page-agent-core',
|
||||||
|
formats: ['es'],
|
||||||
|
},
|
||||||
|
outDir: resolve(__dirname, 'dist', 'esm'),
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'chalk',
|
||||||
|
'zod',
|
||||||
|
'zod/v4',
|
||||||
|
// all the internal packages
|
||||||
|
/^@page-agent\//,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
minify: false,
|
||||||
|
sourcemap: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"production"',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'core',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
silent: 'passed-only',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Privacy Policy for Page Agent Extension
|
||||||
|
|
||||||
|
This document has moved. Please see our full **[Terms of Use & Privacy](../../docs/terms-and-privacy.md)**.
|
||||||
|
|
||||||
|
Online: https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/assets/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/lib/hooks"
|
||||||
|
},
|
||||||
|
"registries": {
|
||||||
|
"@magicui": "https://magicui.design/r/{name}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Page Agent Extension API
|
||||||
|
|
||||||
|
Integrate the Page Agent extension into your web app and trigger multi-page browser tasks from page JavaScript.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Install the browser extension
|
||||||
|
|
||||||
|
Primary channel:
|
||||||
|
|
||||||
|
- Chrome Web Store: https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj
|
||||||
|
|
||||||
|
Latest updates are often published earlier on:
|
||||||
|
|
||||||
|
- GitHub Releases: https://github.com/alibaba/page-agent/releases
|
||||||
|
|
||||||
|
### 2. Install type definitions (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @page-agent/core --save-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Authorization (Token)
|
||||||
|
|
||||||
|
The token allows your page JS to call the extension API (`window.PAGE_AGENT_EXT`) and execute multi-page tasks.
|
||||||
|
|
||||||
|
Why token-based access is required:
|
||||||
|
|
||||||
|
- The extension has broad browser permissions (page access, navigation, multi-tab control).
|
||||||
|
- If abused, it can harm user privacy and security.
|
||||||
|
- Users must explicitly provide the token only to applications they trust.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
1. Open the extension side panel and copy your auth token.
|
||||||
|
2. Set the token in your page:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
|
|
||||||
|
// Wait for extension injection (up to 1 second)
|
||||||
|
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (window.PAGE_AGENT_EXT) return true
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
if (await waitForExtension()) {
|
||||||
|
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||||
|
baseURL: 'https://api.openai.com/v1',
|
||||||
|
apiKey: 'your-api-key',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
onStatusChange: (status) => console.log('Status:', status),
|
||||||
|
onActivity: (activity) => console.log('Activity:', activity),
|
||||||
|
})
|
||||||
|
console.log('Result:', result)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global API
|
||||||
|
|
||||||
|
After token match, the extension injects APIs into `window`.
|
||||||
|
|
||||||
|
### `window.PAGE_AGENT_EXT_VERSION`
|
||||||
|
|
||||||
|
Extension version string (for capability checks before using the main API).
|
||||||
|
|
||||||
|
### `window.PAGE_AGENT_EXT`
|
||||||
|
|
||||||
|
Main namespace object.
|
||||||
|
|
||||||
|
#### `PAGE_AGENT_EXT.execute(task, config)`
|
||||||
|
|
||||||
|
Execute one agent task.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
| -------- | --------------- | -------- | ------------------------------------ |
|
||||||
|
| `task` | `string` | Yes | Task description |
|
||||||
|
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||||
|
|
||||||
|
Returns: `Promise<ExecutionResult>`
|
||||||
|
|
||||||
|
#### `PAGE_AGENT_EXT.stop()`
|
||||||
|
|
||||||
|
Stop the current task.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **Normal browser windows only.** This extension relies on tab group API which does not work for pop-up window or PWA App window.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
Install `@page-agent/core` for complete types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
|
|
||||||
|
export interface ExecuteConfig {
|
||||||
|
baseURL: string
|
||||||
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
|
// Global system-level instructions for the agent.
|
||||||
|
// Equivalent to AgentConfig.instructions.system.
|
||||||
|
systemInstruction?: string
|
||||||
|
|
||||||
|
// Include the initial tab where page JS starts. Default: true.
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
|
||||||
|
// Control all unpinned tabs in the window instead of only the tab group.
|
||||||
|
// When enabled, agent sees and can switch to every non-pinned tab.
|
||||||
|
// Default: false. Experimental.
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
|
||||||
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
`AgentStatus`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||||
|
```
|
||||||
|
|
||||||
|
`AgentActivity`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AgentActivity =
|
||||||
|
| { type: 'thinking' }
|
||||||
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
`HistoricalEvent`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type HistoricalEvent =
|
||||||
|
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||||
|
| { type: 'observation'; content: string }
|
||||||
|
| { type: 'user_takeover' }
|
||||||
|
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExecutionResult`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExecutionResult {
|
||||||
|
success: boolean
|
||||||
|
data: string
|
||||||
|
history: HistoricalEvent[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Execution
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||||
|
'Fill in the email field with test@example.com and click Submit',
|
||||||
|
{
|
||||||
|
baseURL: 'https://api.openai.com/v1',
|
||||||
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
includeInitialTab: false, // Optional: exclude current tab
|
||||||
|
onStatusChange: (status) => console.log(status),
|
||||||
|
onActivity: (activity) => console.log(activity),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop the Current Task
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
window.PAGE_AGENT_EXT!.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Window Type Declaration
|
||||||
|
|
||||||
|
If you are not importing `@page-agent/core`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
|
|
||||||
|
interface ExecuteConfig {
|
||||||
|
baseURL: string
|
||||||
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
|
systemInstruction?: string
|
||||||
|
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
PAGE_AGENT_EXT_VERSION?: string
|
||||||
|
PAGE_AGENT_EXT?: {
|
||||||
|
version: string
|
||||||
|
execute: Execute
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@page-agent/ext",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.12.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build:ext": "wxt build",
|
||||||
|
"zip": "wxt zip",
|
||||||
|
"postinstall": "wxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.19",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
|
"@radix-ui/react-switch": "^1.3.3",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"@types/chrome": "^0.2.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
|
"motion": "^12.42.2",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"rough-notation": "^0.5.1",
|
||||||
|
"simple-icons": "^16.25.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wxt": "^0.20.27"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@page-agent/core": "1.12.1",
|
||||||
|
"@page-agent/llms": "1.12.1",
|
||||||
|
"@page-agent/page-controller": "1.12.1",
|
||||||
|
"@page-agent/ui": "1.12.1",
|
||||||
|
"ai-motion": "^0.4.8",
|
||||||
|
"chalk": "^5.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extName": {
|
||||||
|
"message": "Page Agent Ext"
|
||||||
|
},
|
||||||
|
"extDescription": {
|
||||||
|
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||||
|
},
|
||||||
|
"extActionTitle": {
|
||||||
|
"message": "Open Page Agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extName": {
|
||||||
|
"message": "Page Agent Ext"
|
||||||
|
},
|
||||||
|
"extDescription": {
|
||||||
|
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||||
|
},
|
||||||
|
"extActionTitle": {
|
||||||
|
"message": "打开 Page Agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,100 @@
|
|||||||
|
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
||||||
|
|
||||||
|
import { RemotePageController } from './RemotePageController'
|
||||||
|
import { TabsController } from './TabsController'
|
||||||
|
import SYSTEM_PROMPT from './system_prompt.md?raw'
|
||||||
|
import { createTabTools } from './tabTools'
|
||||||
|
|
||||||
|
/** Detect user language from browser settings */
|
||||||
|
function detectLanguage(): 'en-US' | 'zh-CN' {
|
||||||
|
const lang = navigator.language || navigator.languages?.[0] || 'en-US'
|
||||||
|
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiPageAgentConfig extends AgentConfig {
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MultiPageAgent
|
||||||
|
* - use with extension
|
||||||
|
* - can be used from a side panel or a content script
|
||||||
|
*/
|
||||||
|
export class MultiPageAgent extends PageAgentCore {
|
||||||
|
constructor(config: MultiPageAgentConfig) {
|
||||||
|
// multi page controller
|
||||||
|
const tabsController = new TabsController()
|
||||||
|
const pageController = new RemotePageController(tabsController)
|
||||||
|
const customTools = createTabTools(tabsController)
|
||||||
|
|
||||||
|
// system prompt - auto-detect language if not specified
|
||||||
|
const language = config.language ?? detectLanguage()
|
||||||
|
const targetLanguage = language === 'zh-CN' ? '中文' : 'English'
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||||
|
/Default working language: \*\*.*?\*\*/,
|
||||||
|
`Default working language: **${targetLanguage}**`
|
||||||
|
)
|
||||||
|
|
||||||
|
const includeInitialTab = config.includeInitialTab ?? true
|
||||||
|
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project agent status into chrome.storage. The content script polls
|
||||||
|
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
|
||||||
|
*
|
||||||
|
* When the agent is in side-panel and user closed the side-panel.
|
||||||
|
* There is no chance for isAgentRunning to be set false.
|
||||||
|
* (unload event doesn't work well in side panel.)
|
||||||
|
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
||||||
|
* This heartbeat mechanism acts as a backup.
|
||||||
|
*/
|
||||||
|
let heartBeatInterval: number | null = null
|
||||||
|
|
||||||
|
super({
|
||||||
|
...config,
|
||||||
|
// Disabled: AbortSignal cannot cross contexts
|
||||||
|
experimentalScriptExecutionTool: false,
|
||||||
|
pageController: pageController as any,
|
||||||
|
customTools: customTools,
|
||||||
|
customSystemPrompt: systemPrompt,
|
||||||
|
|
||||||
|
onBeforeTask: async (agent) => {
|
||||||
|
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBeforeStep: async (agent) => {
|
||||||
|
// pull latest tab state so that tabs changes can be observed
|
||||||
|
await tabsController.syncTabs()
|
||||||
|
if (!tabsController.currentTabId) return
|
||||||
|
// make sure the current tab is loaded before the step starts
|
||||||
|
await tabsController.waitUntilTabLoaded(tabsController.currentTabId)
|
||||||
|
},
|
||||||
|
|
||||||
|
onDispose: () => {
|
||||||
|
if (heartBeatInterval) {
|
||||||
|
clearInterval(heartBeatInterval)
|
||||||
|
heartBeatInterval = null
|
||||||
|
}
|
||||||
|
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
|
||||||
|
|
||||||
|
tabsController.dispose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.addEventListener('statuschange', () => {
|
||||||
|
const running = this.status === 'running'
|
||||||
|
|
||||||
|
if (running && !heartBeatInterval) {
|
||||||
|
heartBeatInterval = window.setInterval(() => {
|
||||||
|
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
|
||||||
|
}, 1_000)
|
||||||
|
} else if (!running && heartBeatInterval) {
|
||||||
|
clearInterval(heartBeatInterval)
|
||||||
|
heartBeatInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* background logics for RemotePageController
|
||||||
|
* - redirect messages from RemotePageController(Agent, extension pages) to ContentScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function handlePageControlMessage(
|
||||||
|
message: { type: 'PAGE_CONTROL'; action: string; payload: any; targetTabId: number },
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
sendResponse: (response: unknown) => void
|
||||||
|
): true | undefined {
|
||||||
|
const PREFIX = '[RemotePageController.background]'
|
||||||
|
|
||||||
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
|
|
||||||
|
const { action, payload, targetTabId } = message
|
||||||
|
|
||||||
|
if (action === 'get_my_tab_id') {
|
||||||
|
debug('get_my_tab_id', sender.tab?.id)
|
||||||
|
sendResponse({ tabId: sender.tab?.id || null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxy to content script
|
||||||
|
chrome.tabs
|
||||||
|
.sendMessage(targetTabId, {
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
sendResponse(result)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(PREFIX, error)
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* content script for RemotePageController
|
||||||
|
*/
|
||||||
|
import { PageController } from '@page-agent/page-controller'
|
||||||
|
|
||||||
|
export function initPageController() {
|
||||||
|
let pageController: PageController | null = null
|
||||||
|
let intervalID: number | null = null
|
||||||
|
|
||||||
|
const myTabIdPromise = chrome.runtime
|
||||||
|
.sendMessage({ type: 'PAGE_CONTROL', action: 'get_my_tab_id' })
|
||||||
|
.then((response) => {
|
||||||
|
return (response as { tabId: number | null }).tabId
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
function getPC(): PageController {
|
||||||
|
if (!pageController) {
|
||||||
|
pageController = new PageController({
|
||||||
|
enableMask: false,
|
||||||
|
viewportExpansion: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pageController
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalID = window.setInterval(async () => {
|
||||||
|
const agentHeartbeat = (await chrome.storage.local.get('agentHeartbeat')).agentHeartbeat
|
||||||
|
const now = Date.now()
|
||||||
|
const agentInTouch = typeof agentHeartbeat === 'number' && now - agentHeartbeat < 2_000
|
||||||
|
|
||||||
|
const isAgentRunning = (await chrome.storage.local.get('isAgentRunning')).isAgentRunning
|
||||||
|
const currentTabId = (await chrome.storage.local.get('currentTabId')).currentTabId
|
||||||
|
|
||||||
|
const shouldShowMask = isAgentRunning && agentInTouch && currentTabId === (await myTabIdPromise)
|
||||||
|
|
||||||
|
if (shouldShowMask) {
|
||||||
|
const pc = getPC()
|
||||||
|
pc.initMask()
|
||||||
|
await pc.showMask()
|
||||||
|
} else {
|
||||||
|
// await getPC().hideMask()
|
||||||
|
if (pageController) {
|
||||||
|
pageController.hideMask()
|
||||||
|
pageController.cleanUpHighlights()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAgentRunning && agentInTouch) {
|
||||||
|
if (pageController) {
|
||||||
|
pageController.dispose()
|
||||||
|
pageController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||||
|
if (message.type !== 'PAGE_CONTROL') {
|
||||||
|
// sendResponse({
|
||||||
|
// success: false,
|
||||||
|
// error: `[RemotePageController.ContentScript]: Invalid message type: ${message.type}`,
|
||||||
|
// })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, payload } = message
|
||||||
|
const methodName = getMethodName(action)
|
||||||
|
|
||||||
|
const pc = getPC() as any
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'get_last_update_time':
|
||||||
|
case 'get_browser_state':
|
||||||
|
case 'update_tree':
|
||||||
|
case 'clean_up_highlights':
|
||||||
|
case 'click_element':
|
||||||
|
case 'input_text':
|
||||||
|
case 'select_option':
|
||||||
|
case 'scroll':
|
||||||
|
case 'scroll_horizontally':
|
||||||
|
case 'execute_javascript':
|
||||||
|
pc[methodName](...(payload || []))
|
||||||
|
.then((result: any) => sendResponse(result))
|
||||||
|
.catch((error: any) =>
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Unknown PAGE_CONTROL action: ${action}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodName(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'get_last_update_time':
|
||||||
|
return 'getLastUpdateTime' as const
|
||||||
|
case 'get_browser_state':
|
||||||
|
return 'getBrowserState' as const
|
||||||
|
case 'update_tree':
|
||||||
|
return 'updateTree' as const
|
||||||
|
case 'clean_up_highlights':
|
||||||
|
return 'cleanUpHighlights' as const
|
||||||
|
|
||||||
|
// DOM actions
|
||||||
|
|
||||||
|
case 'click_element':
|
||||||
|
return 'clickElement' as const
|
||||||
|
case 'input_text':
|
||||||
|
return 'inputText' as const
|
||||||
|
case 'select_option':
|
||||||
|
return 'selectOption' as const
|
||||||
|
case 'scroll':
|
||||||
|
return 'scroll' as const
|
||||||
|
case 'scroll_horizontally':
|
||||||
|
return 'scrollHorizontally' as const
|
||||||
|
case 'execute_javascript':
|
||||||
|
return 'executeJavascript' as const
|
||||||
|
|
||||||
|
default:
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { BrowserState } from '@page-agent/page-controller'
|
||||||
|
|
||||||
|
import type { TabsController } from './TabsController'
|
||||||
|
|
||||||
|
const PREFIX = '[RemotePageController]'
|
||||||
|
|
||||||
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
|
|
||||||
|
function sendMessage(message: {
|
||||||
|
type: 'PAGE_CONTROL'
|
||||||
|
action: string
|
||||||
|
targetTabId: number
|
||||||
|
payload?: any
|
||||||
|
}): Promise<any> {
|
||||||
|
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||||
|
console.error(PREFIX, message.action, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent side page controller.
|
||||||
|
* - live in the agent env (extension page or content script)
|
||||||
|
* - communicates with remote PageController via sw
|
||||||
|
*/
|
||||||
|
export class RemotePageController {
|
||||||
|
tabsController: TabsController
|
||||||
|
|
||||||
|
constructor(tabsController: TabsController) {
|
||||||
|
this.tabsController = tabsController
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTabId(): number | null {
|
||||||
|
return this.tabsController.currentTabId
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentUrl(): Promise<string> {
|
||||||
|
if (!this.currentTabId) return ''
|
||||||
|
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
|
return url || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentTitle(): Promise<string> {
|
||||||
|
if (!this.currentTabId) return ''
|
||||||
|
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
|
return title || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastUpdateTime(): Promise<number> {
|
||||||
|
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||||
|
return sendMessage({
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action: 'get_last_update_time',
|
||||||
|
targetTabId: this.currentTabId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBrowserState(): Promise<BrowserState> {
|
||||||
|
let browserState: BrowserState
|
||||||
|
debug('getBrowserState', this.currentTabId)
|
||||||
|
|
||||||
|
const currentUrl = await this.getCurrentUrl()
|
||||||
|
const currentTitle = await this.getCurrentTitle()
|
||||||
|
|
||||||
|
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
|
||||||
|
browserState = {
|
||||||
|
url: currentUrl,
|
||||||
|
title: currentTitle,
|
||||||
|
header: '',
|
||||||
|
content: '(empty page. either current page is not readable or not loaded yet.)',
|
||||||
|
footer: '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
browserState = await sendMessage({
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action: 'get_browser_state',
|
||||||
|
targetTabId: this.currentTabId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = await this.tabsController.summarizeTabs()
|
||||||
|
browserState.header = sum + '\n\n' + (browserState.header || '')
|
||||||
|
|
||||||
|
debug('getBrowserState: success', this.currentTabId, browserState)
|
||||||
|
|
||||||
|
return browserState
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTree(): Promise<void> {
|
||||||
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMessage({
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action: 'update_tree',
|
||||||
|
targetTabId: this.currentTabId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanUpHighlights(): Promise<void> {
|
||||||
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMessage({
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action: 'clean_up_highlights',
|
||||||
|
targetTabId: this.currentTabId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
||||||
|
const res = await this.remoteCallDomAction('click_element', args)
|
||||||
|
// @note may cause page navigation, wait for 1 second to ensure the page loading started
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async inputText(...args: any[]): Promise<DomActionReturn> {
|
||||||
|
return this.remoteCallDomAction('input_text', args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
||||||
|
return this.remoteCallDomAction('select_option', args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scroll(...args: any[]): Promise<DomActionReturn> {
|
||||||
|
return this.remoteCallDomAction('scroll', args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
||||||
|
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
|
||||||
|
|
||||||
|
/** @note Managed by content script via storage polling. */
|
||||||
|
async showMask(): Promise<void> {}
|
||||||
|
/** @note Managed by content script via storage polling. */
|
||||||
|
async hideMask(): Promise<void> {}
|
||||||
|
/** @note Managed by content script via storage polling. */
|
||||||
|
dispose(): void {}
|
||||||
|
|
||||||
|
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
||||||
|
if (!this.currentTabId) {
|
||||||
|
return { success: false, message: 'RemotePageController not initialized.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMessage({
|
||||||
|
type: 'PAGE_CONTROL',
|
||||||
|
action: action,
|
||||||
|
targetTabId: this.currentTabId!,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomActionReturn {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL can run content scripts.
|
||||||
|
*/
|
||||||
|
export function isContentScriptAllowed(url: string | undefined): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
const restrictedPatterns = [
|
||||||
|
/^chrome:\/\//,
|
||||||
|
/^chrome-extension:\/\//,
|
||||||
|
/^about:/,
|
||||||
|
/^edge:\/\//,
|
||||||
|
/^brave:\/\//,
|
||||||
|
/^opera:\/\//,
|
||||||
|
/^vivaldi:\/\//,
|
||||||
|
/^file:\/\//,
|
||||||
|
/^view-source:/,
|
||||||
|
/^devtools:\/\//,
|
||||||
|
]
|
||||||
|
|
||||||
|
return !restrictedPatterns.some((pattern) => pattern.test(url))
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* background logics for TabsController
|
||||||
|
*
|
||||||
|
* Keep this stateless: pure request/response handlers only, no in-memory
|
||||||
|
* state, no ports, no event pushing. MV3 SW should be killed and restarted at
|
||||||
|
* any time (idle timeout, extension update) without special handling.
|
||||||
|
*/
|
||||||
|
import type { TabAction } from './TabsController'
|
||||||
|
|
||||||
|
const PREFIX = '[TabsController.background]'
|
||||||
|
|
||||||
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve active tab.
|
||||||
|
*
|
||||||
|
* - `tabs.query({ active: true })` does not work in multi-window scenarios.
|
||||||
|
* - Extension pages (side panel, hub tab) can resolve their own windowId.
|
||||||
|
* We just find the active tab within that window.
|
||||||
|
* - Content scripts (PAGE_AGENT_EXT) can't self-report a windowId.
|
||||||
|
* Chrome populates `sender.tab` for every content-script message,
|
||||||
|
* which is the tab hosting the script.
|
||||||
|
*/
|
||||||
|
async function resolveActiveTab(
|
||||||
|
payload: { windowId?: number } | undefined,
|
||||||
|
sender: chrome.runtime.MessageSender
|
||||||
|
): Promise<chrome.tabs.Tab> {
|
||||||
|
const windowId = payload?.windowId
|
||||||
|
|
||||||
|
if (windowId != null) {
|
||||||
|
debug('get_active_tab: resolving via caller-reported windowId', windowId)
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, windowId })
|
||||||
|
if (!tab) throw new Error(`No active tab found in window ${windowId}.`)
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender.tab) {
|
||||||
|
debug('get_active_tab: resolving via sender.tab (content script)', sender.tab.id)
|
||||||
|
return sender.tab
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'Cannot resolve active tab: caller reported no windowId and is not a content script (no sender.tab).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleTabControlMessage(
|
||||||
|
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
sendResponse: (response: unknown) => void
|
||||||
|
): true | undefined {
|
||||||
|
const { action, payload } = message
|
||||||
|
|
||||||
|
switch (action as TabAction) {
|
||||||
|
case 'get_active_tab': {
|
||||||
|
debug('get_active_tab', payload)
|
||||||
|
resolveActiveTab(payload, sender)
|
||||||
|
.then((tab) => {
|
||||||
|
debug('get_active_tab: success', tab)
|
||||||
|
sendResponse({ success: true, tab })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_tab_info': {
|
||||||
|
debug('get_tab_info', payload)
|
||||||
|
chrome.tabs
|
||||||
|
.get(payload.tabId)
|
||||||
|
.then((tab) => {
|
||||||
|
debug('get_tab_info: success', tab)
|
||||||
|
sendResponse(tab)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'open_new_tab': {
|
||||||
|
debug('open_new_tab', payload)
|
||||||
|
chrome.tabs
|
||||||
|
.create({ url: payload.url, windowId: payload.windowId, active: false })
|
||||||
|
.then((newTab) => {
|
||||||
|
debug('open_new_tab: success', newTab)
|
||||||
|
sendResponse({ success: true, tabId: newTab.id })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create_tab_group': {
|
||||||
|
debug('create_tab_group', payload)
|
||||||
|
chrome.tabs
|
||||||
|
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||||
|
.then((groupId) => {
|
||||||
|
debug('create_tab_group: success', groupId)
|
||||||
|
sendResponse({ success: true, groupId })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(PREFIX, 'Failed to create tab group', error)
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_tab_group': {
|
||||||
|
debug('update_tab_group', payload)
|
||||||
|
chrome.tabGroups
|
||||||
|
.update(payload.groupId, payload.properties)
|
||||||
|
.then(() => {
|
||||||
|
sendResponse({ success: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'add_tab_to_group': {
|
||||||
|
debug('add_tab_to_group', payload)
|
||||||
|
chrome.tabs
|
||||||
|
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
||||||
|
.then(() => {
|
||||||
|
sendResponse({ success: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'close_tab': {
|
||||||
|
debug('close_tab', payload)
|
||||||
|
chrome.tabs
|
||||||
|
.remove(payload.tabId)
|
||||||
|
.then(() => {
|
||||||
|
sendResponse({ success: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true // async response
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_window_tabs': {
|
||||||
|
chrome.tabs
|
||||||
|
.query({ windowId: payload.windowId })
|
||||||
|
.then((tabs) => {
|
||||||
|
sendResponse({ success: true, tabs })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendResponse({ error: `Unknown action: ${action}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import { isContentScriptAllowed } from './RemotePageController'
|
||||||
|
|
||||||
|
const PREFIX = '[TabsController]'
|
||||||
|
|
||||||
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
|
|
||||||
|
function sendMessage(message: {
|
||||||
|
type: 'TAB_CONTROL'
|
||||||
|
action: TabAction
|
||||||
|
payload?: any
|
||||||
|
}): Promise<any> {
|
||||||
|
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||||
|
console.error(PREFIX, message.action, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the window hosting this script's own context, when knowable.
|
||||||
|
*
|
||||||
|
* Extension pages (side panel, hub tab) have `chrome.windows` access and can
|
||||||
|
* identify their own window directly via `getCurrent()`.
|
||||||
|
* Content scripts have no `chrome.windows` access; they resolve `undefined`
|
||||||
|
* here and the background script falls back to `sender.tab` instead.
|
||||||
|
*/
|
||||||
|
async function getOwnWindowId(): Promise<number | undefined> {
|
||||||
|
if (typeof chrome.windows === 'undefined') return undefined
|
||||||
|
const win = await chrome.windows.getCurrent()
|
||||||
|
return win.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for managing browser tabs.
|
||||||
|
* - live in the agent env (extension page or content script)
|
||||||
|
* - no chrome apis. call sw for tab operations
|
||||||
|
* - store tabs states, pull tabs info and detect changes
|
||||||
|
*/
|
||||||
|
export class TabsController {
|
||||||
|
currentTabId: number | null = null
|
||||||
|
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
/* tracked window */
|
||||||
|
private windowId: number | null = null
|
||||||
|
/* tracked tabs */
|
||||||
|
private tabs: TabMeta[] = []
|
||||||
|
private initialTabId: number | null = null
|
||||||
|
private tabGroupId: number | null = null
|
||||||
|
private experimentalIncludeAllTabs = false
|
||||||
|
private task: string = ''
|
||||||
|
|
||||||
|
async init(task: string, options: TabsInitOptions = {}) {
|
||||||
|
const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options
|
||||||
|
debug('init', task, options)
|
||||||
|
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error('TabsController already disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateCurrentTabId(null)
|
||||||
|
|
||||||
|
this.windowId = null
|
||||||
|
this.tabs = []
|
||||||
|
this.tabGroupId = null
|
||||||
|
this.initialTabId = null
|
||||||
|
this.experimentalIncludeAllTabs = experimentalIncludeAllTabs
|
||||||
|
this.task = task
|
||||||
|
|
||||||
|
const activeTabResult = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_active_tab',
|
||||||
|
payload: { windowId: await getOwnWindowId() },
|
||||||
|
})
|
||||||
|
|
||||||
|
this.initialTabId = activeTabResult.tab?.id
|
||||||
|
this.windowId = activeTabResult.tab?.windowId
|
||||||
|
|
||||||
|
if (!this.initialTabId || !this.windowId) {
|
||||||
|
if (activeTabResult.error) {
|
||||||
|
throw new Error(activeTabResult.error)
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get active tab')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (experimentalIncludeAllTabs) {
|
||||||
|
const allTabs = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_window_tabs',
|
||||||
|
payload: { windowId: this.windowId },
|
||||||
|
})
|
||||||
|
for (const tab of allTabs.tabs as chrome.tabs.Tab[]) {
|
||||||
|
if (tab.id && !tab.pinned && isContentScriptAllowed(tab.url)) {
|
||||||
|
this.addTab({
|
||||||
|
id: tab.id,
|
||||||
|
isInitial: tab.id === this.initialTabId,
|
||||||
|
url: tab.url,
|
||||||
|
title: tab.title,
|
||||||
|
status: tab.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.tabs.find((t) => t.id === this.initialTabId)) {
|
||||||
|
this.currentTabId = this.initialTabId
|
||||||
|
await this.createTabGroup([this.initialTabId])
|
||||||
|
}
|
||||||
|
} else if (includeInitialTab) {
|
||||||
|
const info = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_tab_info',
|
||||||
|
payload: { tabId: this.initialTabId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isContentScriptAllowed(info.url) && !info.pinned) {
|
||||||
|
this.currentTabId = this.initialTabId
|
||||||
|
|
||||||
|
this.addTab({
|
||||||
|
id: this.initialTabId,
|
||||||
|
isInitial: true,
|
||||||
|
url: info.url,
|
||||||
|
title: info.title,
|
||||||
|
status: info.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.createTabGroup([this.initialTabId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateCurrentTabId(this.currentTabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openNewTab(url: string): Promise<string> {
|
||||||
|
debug('openNewTab', url)
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'open_new_tab',
|
||||||
|
payload: { url, windowId: this.windowId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Failed to open new tab: ${result.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = result.tabId as number
|
||||||
|
|
||||||
|
this.addTab({
|
||||||
|
id: tabId,
|
||||||
|
isInitial: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.switchToTab(tabId)
|
||||||
|
|
||||||
|
if (!this.tabGroupId) {
|
||||||
|
await this.createTabGroup([tabId])
|
||||||
|
} else {
|
||||||
|
await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'add_tab_to_group',
|
||||||
|
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitUntilTabLoaded(tabId)
|
||||||
|
|
||||||
|
return `✅ Opened new tab ID ${tabId} with URL ${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToTab(tabId: number): Promise<string> {
|
||||||
|
debug('switchToTab', tabId)
|
||||||
|
|
||||||
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (!targetTab) {
|
||||||
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateCurrentTabId(tabId)
|
||||||
|
|
||||||
|
return `✅ Switched to tab ID ${tabId}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTab(tabId: number): Promise<string> {
|
||||||
|
debug('closeTab', tabId)
|
||||||
|
|
||||||
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (!targetTab) {
|
||||||
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
|
}
|
||||||
|
if (targetTab.isInitial) {
|
||||||
|
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'close_tab',
|
||||||
|
payload: { tabId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.tabs = this.tabs.filter((t) => t.id !== tabId)
|
||||||
|
if (this.currentTabId === tabId) {
|
||||||
|
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
|
||||||
|
if (newCurrentTab) {
|
||||||
|
await this.switchToTab(newCurrentTab.id)
|
||||||
|
} else {
|
||||||
|
await this.updateCurrentTabId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `✅ Closed tab ID ${tabId}.`
|
||||||
|
} else {
|
||||||
|
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTabGroup(tabIds: number[]) {
|
||||||
|
const result = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'create_tab_group',
|
||||||
|
payload: { tabIds, windowId: this.windowId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(`Failed to create tab group: ${result?.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tabGroupId = result.groupId as number
|
||||||
|
|
||||||
|
await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'update_tab_group',
|
||||||
|
payload: {
|
||||||
|
groupId: this.tabGroupId,
|
||||||
|
properties: {
|
||||||
|
title: `PageAgent(${this.task})`,
|
||||||
|
color: randomColor(),
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTab(meta: TabMeta) {
|
||||||
|
if (this.tabs.find((t) => t.id === meta.id)) return
|
||||||
|
this.tabs.push(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCurrentTabId(tabId: number | null) {
|
||||||
|
debug('updateCurrentTabId', tabId)
|
||||||
|
|
||||||
|
this.currentTabId = tabId
|
||||||
|
await chrome.storage.local.set({ currentTabId: tabId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
||||||
|
// use cached tab info if available
|
||||||
|
const tabMeta = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (tabMeta && tabMeta.url && tabMeta.title) {
|
||||||
|
return { title: tabMeta.title, url: tabMeta.url }
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, pull the latest tab info from the background script
|
||||||
|
debug('getTabInfo: pulling from background script', tabId)
|
||||||
|
const result = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_tab_info',
|
||||||
|
payload: { tabId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tabMeta) {
|
||||||
|
tabMeta.url = result.url
|
||||||
|
tabMeta.title = result.title
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async summarizeTabs(): Promise<string> {
|
||||||
|
const summaries = [
|
||||||
|
`| Tab ID | URL | Title | Status | Current |`,
|
||||||
|
`|-----|-----|-----|-----|-----|`,
|
||||||
|
]
|
||||||
|
for (const tab of this.tabs) {
|
||||||
|
const { title, url } = await this.getTabInfo(tab.id)
|
||||||
|
summaries.push(
|
||||||
|
`| ${tab.id} | ${url} | ${title} | ${tab.status ?? '-'} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!this.tabs.length) {
|
||||||
|
summaries.push('\nNo tabs available. Open a tab if needed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
||||||
|
const tab = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
|
if (tab.status === 'complete') return
|
||||||
|
|
||||||
|
// When a tracked tab is closed or untracked.
|
||||||
|
// The tab object will be removed from the tab list.
|
||||||
|
// Finding the latest tab object is the only way to know if it's closed.
|
||||||
|
|
||||||
|
debug('waitUntilTabLoaded', tabId)
|
||||||
|
await waitUntil(async () => {
|
||||||
|
await this.syncTabs()
|
||||||
|
const latest = this.tabs.find((t) => t.id === tabId)
|
||||||
|
return !latest || latest.status !== 'loading'
|
||||||
|
}, 4_000)
|
||||||
|
|
||||||
|
const latest = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (latest?.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the window's tabs from the background.
|
||||||
|
* Pulling is better than pushing. Long-lived ports are stateful troublemakers.
|
||||||
|
*/
|
||||||
|
async syncTabs(): Promise<void> {
|
||||||
|
if (this.disposed || this.windowId == null) return
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_window_tabs',
|
||||||
|
payload: { windowId: this.windowId },
|
||||||
|
})
|
||||||
|
// sendMessage already logged the failure; keep the stale mirror
|
||||||
|
if (!result?.success) return
|
||||||
|
|
||||||
|
const liveTabs = (result.tabs as chrome.tabs.Tab[]).filter((t) => t.id != null)
|
||||||
|
const liveIds = new Set(liveTabs.map((t) => t.id!))
|
||||||
|
|
||||||
|
const closedIds = this.tabs.filter((t) => !liveIds.has(t.id)).map((t) => t.id)
|
||||||
|
if (closedIds.length) {
|
||||||
|
debug('syncTabs: tabs closed', closedIds)
|
||||||
|
this.tabs = this.tabs.filter((t) => liveIds.has(t.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTabs: TabMeta[] = []
|
||||||
|
for (const live of liveTabs) {
|
||||||
|
const tracked = this.tabs.find((t) => t.id === live.id)
|
||||||
|
if (tracked) {
|
||||||
|
tracked.url = live.url
|
||||||
|
tracked.title = live.title
|
||||||
|
tracked.status = live.status as TabMeta['status']
|
||||||
|
} else if (this.shouldTrack(live)) {
|
||||||
|
debug('syncTabs: new tab', live.id, live.url)
|
||||||
|
const meta: TabMeta = {
|
||||||
|
id: live.id!,
|
||||||
|
isInitial: false,
|
||||||
|
url: live.url,
|
||||||
|
title: live.title,
|
||||||
|
status: live.status,
|
||||||
|
}
|
||||||
|
this.addTab(meta)
|
||||||
|
newTabs.push(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the page like a user would: focus the newest tab it opened.
|
||||||
|
// If the current tab is gone, fall back to the last tracked one.
|
||||||
|
if (newTabs.length) {
|
||||||
|
await this.switchToTab(newTabs[newTabs.length - 1].id)
|
||||||
|
} else if (this.currentTabId != null && !this.tabs.find((t) => t.id === this.currentTabId)) {
|
||||||
|
const fallback = this.tabs[this.tabs.length - 1]
|
||||||
|
if (fallback) {
|
||||||
|
await this.switchToTab(fallback.id)
|
||||||
|
} else {
|
||||||
|
debug('syncTabs: no fallback tab found, updating current tab to null')
|
||||||
|
await this.updateCurrentTabId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldTrack(tab: chrome.tabs.Tab): boolean {
|
||||||
|
if (this.tabGroupId != null && tab.groupId === this.tabGroupId) return true
|
||||||
|
return (
|
||||||
|
this.experimentalIncludeAllTabs &&
|
||||||
|
tab.windowId === this.windowId &&
|
||||||
|
!tab.pinned &&
|
||||||
|
isContentScriptAllowed(tab.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
debug('dispose')
|
||||||
|
this.disposed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsInitOptions {
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabAction =
|
||||||
|
| 'get_active_tab'
|
||||||
|
| 'get_tab_info'
|
||||||
|
| 'open_new_tab'
|
||||||
|
| 'create_tab_group'
|
||||||
|
| 'update_tab_group'
|
||||||
|
| 'add_tab_to_group'
|
||||||
|
| 'close_tab'
|
||||||
|
| 'get_tab_title'
|
||||||
|
| 'get_window_tabs'
|
||||||
|
|
||||||
|
interface TabMeta {
|
||||||
|
id: number
|
||||||
|
isInitial: boolean
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
status?: 'loading' | 'unloaded' | 'complete'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_GROUP_COLORS = ['blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan'] as const
|
||||||
|
|
||||||
|
type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
|
||||||
|
|
||||||
|
function randomColor(): TabGroupColor {
|
||||||
|
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until condition becomes true
|
||||||
|
* @returns Returns when condition becomes true, false if timeout
|
||||||
|
* @param timeoutMS Timeout in milliseconds, default 1 minutes
|
||||||
|
* @param throwIfTimeout Reject on timeout instead of resolving with `false`
|
||||||
|
*/
|
||||||
|
async function waitUntil(
|
||||||
|
check: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMS = 60_000,
|
||||||
|
throwIfTimeout = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (await check()) return true
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
if (await check()) return resolve(true)
|
||||||
|
if (Date.now() - start > timeoutMS) {
|
||||||
|
if (throwIfTimeout) {
|
||||||
|
return reject(new Error(`waitUntil timed out after ${timeoutMS}ms`))
|
||||||
|
} else {
|
||||||
|
return resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(poll, 100)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(poll, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { LLMConfig } from '@page-agent/llms'
|
||||||
|
|
||||||
|
// Demo LLM for testing
|
||||||
|
export const DEMO_MODEL = 'qwen3.5-plus'
|
||||||
|
export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
||||||
|
// export const DEMO_API_KEY = 'NA'
|
||||||
|
|
||||||
|
export const DEMO_CONFIG: LLMConfig = {
|
||||||
|
baseURL: DEMO_BASE_URL,
|
||||||
|
model: DEMO_MODEL,
|
||||||
|
// apiKey: DEMO_API_KEY,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
|
||||||
|
export const LEGACY_TESTING_ENDPOINTS = [
|
||||||
|
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isTestingEndpoint(url: string): boolean {
|
||||||
|
const normalized = url.replace(/\/+$/, '')
|
||||||
|
return normalized === DEMO_BASE_URL || LEGACY_TESTING_ENDPOINTS.some((ep) => normalized === ep)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateLegacyEndpoint(config: LLMConfig): LLMConfig {
|
||||||
|
const normalized = config.baseURL.replace(/\/+$/, '')
|
||||||
|
if (LEGACY_TESTING_ENDPOINTS.some((ep) => normalized === ep)) {
|
||||||
|
return { ...DEMO_CONFIG }
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
|
||||||
|
|
||||||
|
<intro>
|
||||||
|
You excel at following tasks:
|
||||||
|
1. Navigating complex websites and extracting precise information
|
||||||
|
2. Automating form submissions and interactive web actions
|
||||||
|
3. Gathering and saving information
|
||||||
|
4. Operate effectively in an agent loop
|
||||||
|
5. Efficiently performing diverse web tasks
|
||||||
|
</intro>
|
||||||
|
|
||||||
|
<language_settings>
|
||||||
|
- Default working language: **English**
|
||||||
|
- Use the language that user is using. Return in user's language.
|
||||||
|
</language_settings>
|
||||||
|
|
||||||
|
<input>
|
||||||
|
At every step, your input will consist of:
|
||||||
|
1. <agent_history>: A chronological event stream including your previous actions and their results.
|
||||||
|
2. <agent_state>: Current <user_request> and <step_info>.
|
||||||
|
3. <browser_state>: Tabs, Current Tab, Current URL, interactive elements indexed for actions, and visible page content.
|
||||||
|
</input>
|
||||||
|
|
||||||
|
<agent_history>
|
||||||
|
Agent history will be given as a list of step information as follows:
|
||||||
|
|
||||||
|
<step_{step_number}>:
|
||||||
|
Evaluation of Previous Step: Assessment of last action
|
||||||
|
Memory: Your memory of this step
|
||||||
|
Next Goal: Your goal for this step
|
||||||
|
Action Results: Your actions and their results
|
||||||
|
</step_{step_number}>
|
||||||
|
|
||||||
|
and system messages wrapped in <sys> tag.
|
||||||
|
</agent_history>
|
||||||
|
|
||||||
|
<user_request>
|
||||||
|
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||||
|
- This has the highest priority. Make the user happy.
|
||||||
|
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||||
|
- If the task is open ended you can plan yourself how to get it done.
|
||||||
|
</user_request>
|
||||||
|
|
||||||
|
<browser_state>
|
||||||
|
1. Browser State will be given as:
|
||||||
|
|
||||||
|
Open Tabs: Open tabs with their ids.
|
||||||
|
Current Tab: The tab you are currently viewing.
|
||||||
|
Current URL: URL of the page you are currently viewing.
|
||||||
|
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
|
||||||
|
- index: Numeric identifier for interaction
|
||||||
|
- type: HTML element type (button, input, etc.)
|
||||||
|
- text: Element description
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
[33]<div>User form</div>
|
||||||
|
\t*[35]<button aria-label='Submit form'>Submit</button>
|
||||||
|
|
||||||
|
Note that:
|
||||||
|
- Only elements with numeric indexes in [] are interactive
|
||||||
|
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
|
||||||
|
- Elements tagged with `*[` are the new clickable elements that appeared on the website since the last step - if url has not changed.
|
||||||
|
- Pure text elements without [] are not interactive.
|
||||||
|
</browser_state>
|
||||||
|
|
||||||
|
<browser_rules>
|
||||||
|
Strictly follow these rules while using the browser and navigating the web:
|
||||||
|
- Only interact with elements that have a numeric [index] assigned.
|
||||||
|
- Only use indexes that are explicitly provided.
|
||||||
|
- If the page changes after, for example, an input text action, analyze if you need to interact with new elements, e.g. selecting the right option from the list.
|
||||||
|
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||||
|
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||||
|
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||||
|
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||||
|
- If the page is not fully loaded, use the `wait` action.
|
||||||
|
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||||
|
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||||
|
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
|
||||||
|
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
|
||||||
|
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
|
||||||
|
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
|
||||||
|
- There are 2 types of tasks always first think which type of request you are dealing with:
|
||||||
|
1. Very specific step by step instructions:
|
||||||
|
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
|
||||||
|
2. Open ended tasks. Plan yourself, be creative in achieving them.
|
||||||
|
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
|
||||||
|
</browser_rules>
|
||||||
|
|
||||||
|
<task_completion_rules>
|
||||||
|
You must call the `done` action in one of three cases:
|
||||||
|
- When you have fully completed the USER REQUEST.
|
||||||
|
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
|
||||||
|
- When you feel stuck or unable to solve user request. Or user request is not clear or contains inappropriate content.
|
||||||
|
- When it is ABSOLUTELY IMPOSSIBLE to continue.
|
||||||
|
|
||||||
|
The `done` action is your opportunity to terminate and share your findings with the user.
|
||||||
|
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
|
||||||
|
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
|
||||||
|
- You can use the `text` field of the `done` action to communicate your findings and to provide a coherent reply to the user and fulfill the USER REQUEST.
|
||||||
|
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
|
||||||
|
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
|
||||||
|
- If the user asks for a structured output, your `done` action's schema may be modified. Take this schema into account when solving the task!
|
||||||
|
</task_completion_rules>
|
||||||
|
|
||||||
|
<reasoning_rules>
|
||||||
|
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
|
||||||
|
|
||||||
|
- Reason about <agent_history> to track progress and context toward <user_request>.
|
||||||
|
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
|
||||||
|
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
|
||||||
|
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
|
||||||
|
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
|
||||||
|
- Ask user for help if you have any difficulty. Keep user in the loop.
|
||||||
|
- If you see information relevant to <user_request>, plan saving the information to memory.
|
||||||
|
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
|
||||||
|
</reasoning_rules>
|
||||||
|
|
||||||
|
<examples>
|
||||||
|
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||||
|
|
||||||
|
<evaluation_examples>
|
||||||
|
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
||||||
|
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||||
|
</evaluation_examples>
|
||||||
|
|
||||||
|
<memory_examples>
|
||||||
|
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
|
||||||
|
</memory_examples>
|
||||||
|
|
||||||
|
<next_goal_examples>
|
||||||
|
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
||||||
|
</next_goal_examples>
|
||||||
|
</examples>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
{
|
||||||
|
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
||||||
|
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
||||||
|
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
|
||||||
|
"action":{
|
||||||
|
"Action name": {// Action parameters}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Tab control tools for browser extension
|
||||||
|
*
|
||||||
|
* These tools allow the agent to manage multiple browser tabs:
|
||||||
|
* - open_new_tab: Open a new tab and set it as current
|
||||||
|
* - switch_to_tab: Switch to an existing tab
|
||||||
|
* - close_tab: Close a tab (optionally switch to another)
|
||||||
|
*/
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import type { TabsController } from './TabsController'
|
||||||
|
|
||||||
|
/** Tool definition compatible with PageAgentCore customTools */
|
||||||
|
interface TabTool {
|
||||||
|
description: string
|
||||||
|
inputSchema: z.ZodType
|
||||||
|
execute: (input: unknown) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tab control tools bound to a TabsManager instance.
|
||||||
|
* These tools are injected into PageAgentCore via customTools config.
|
||||||
|
*/
|
||||||
|
export function createTabTools(tabsController: TabsController): Record<string, TabTool> {
|
||||||
|
return {
|
||||||
|
open_new_tab: {
|
||||||
|
description:
|
||||||
|
'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
url: z.string().describe('The URL to open in the new tab'),
|
||||||
|
}),
|
||||||
|
execute: async (input: unknown) => {
|
||||||
|
const { url } = input as { url: string }
|
||||||
|
try {
|
||||||
|
return await tabsController.openNewTab(url)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
switch_to_tab: {
|
||||||
|
description:
|
||||||
|
'Switch to an existing tab by its ID. After switching, all page operations will target the new current tab. You can only switch to tabs in the tab list shown in browser state.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
tab_id: z.number().int().describe('The tab ID to switch to'),
|
||||||
|
}),
|
||||||
|
execute: async (input: unknown) => {
|
||||||
|
const { tab_id } = input as { tab_id: number }
|
||||||
|
try {
|
||||||
|
return await tabsController.switchToTab(tab_id)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
close_tab: {
|
||||||
|
description:
|
||||||
|
'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
tab_id: z.number().int().describe('The tab ID to close'),
|
||||||
|
}),
|
||||||
|
execute: async (input: unknown) => {
|
||||||
|
const { tab_id } = input as { tab_id: number }
|
||||||
|
try {
|
||||||
|
return await tabsController.closeTab(tab_id)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* React hook for using AgentController
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AgentActivity,
|
||||||
|
AgentStatus,
|
||||||
|
ExecutionResult,
|
||||||
|
HistoricalEvent,
|
||||||
|
SupportedLanguage,
|
||||||
|
} from '@page-agent/core'
|
||||||
|
import type { LLMConfig } from '@page-agent/llms'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { MultiPageAgent } from './MultiPageAgent'
|
||||||
|
import { DEMO_CONFIG, migrateLegacyEndpoint } from './constants'
|
||||||
|
|
||||||
|
/** Language preference: undefined means follow system */
|
||||||
|
export type LanguagePreference = SupportedLanguage | undefined
|
||||||
|
|
||||||
|
export interface AdvancedConfig {
|
||||||
|
maxSteps?: number
|
||||||
|
systemInstruction?: string
|
||||||
|
experimentalLlmsTxt?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
disableNamedToolChoice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
||||||
|
language?: LanguagePreference
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAgentResult {
|
||||||
|
status: AgentStatus
|
||||||
|
history: HistoricalEvent[]
|
||||||
|
activity: AgentActivity | null
|
||||||
|
currentTask: string
|
||||||
|
config: ExtConfig | null
|
||||||
|
execute: (task: string) => Promise<ExecutionResult>
|
||||||
|
stop: () => void
|
||||||
|
configure: (config: ExtConfig) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgent(): UseAgentResult {
|
||||||
|
const agentRef = useRef<MultiPageAgent | null>(null)
|
||||||
|
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||||
|
const [history, setHistory] = useState<HistoricalEvent[]>([])
|
||||||
|
const [activity, setActivity] = useState<AgentActivity | null>(null)
|
||||||
|
const [currentTask, setCurrentTask] = useState('')
|
||||||
|
const [config, setConfig] = useState<ExtConfig | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chrome.storage.local.get(['llmConfig', 'language', 'advancedConfig']).then((result) => {
|
||||||
|
let llmConfig = (result.llmConfig as LLMConfig) ?? DEMO_CONFIG
|
||||||
|
const language = (result.language as SupportedLanguage) || undefined
|
||||||
|
const advancedConfig = (result.advancedConfig as AdvancedConfig) ?? {}
|
||||||
|
|
||||||
|
// Auto-migrate legacy testing endpoints
|
||||||
|
const migrated = migrateLegacyEndpoint(llmConfig)
|
||||||
|
if (migrated !== llmConfig) {
|
||||||
|
llmConfig = migrated
|
||||||
|
chrome.storage.local.set({ llmConfig: migrated })
|
||||||
|
} else if (!result.llmConfig) {
|
||||||
|
chrome.storage.local.set({ llmConfig: DEMO_CONFIG })
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return
|
||||||
|
|
||||||
|
const { systemInstruction, ...agentConfig } = config
|
||||||
|
const agent = new MultiPageAgent({
|
||||||
|
...agentConfig,
|
||||||
|
instructions: systemInstruction ? { system: systemInstruction } : undefined,
|
||||||
|
})
|
||||||
|
agentRef.current = agent
|
||||||
|
|
||||||
|
const handleStatusChange = (e: Event) => {
|
||||||
|
const newStatus = agent.status as AgentStatus
|
||||||
|
setStatus(newStatus)
|
||||||
|
if (newStatus !== 'running') {
|
||||||
|
setActivity(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHistoryChange = (e: Event) => {
|
||||||
|
setHistory([...agent.history])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = (e: Event) => {
|
||||||
|
const newActivity = (e as CustomEvent).detail as AgentActivity
|
||||||
|
setActivity(newActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.addEventListener('statuschange', handleStatusChange)
|
||||||
|
agent.addEventListener('historychange', handleHistoryChange)
|
||||||
|
agent.addEventListener('activity', handleActivity)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
agent.removeEventListener('statuschange', handleStatusChange)
|
||||||
|
agent.removeEventListener('historychange', handleHistoryChange)
|
||||||
|
agent.removeEventListener('activity', handleActivity)
|
||||||
|
agent.dispose()
|
||||||
|
}
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
const execute = useCallback(async (task: string) => {
|
||||||
|
const agent = agentRef.current
|
||||||
|
if (!agent) throw new Error('Agent not initialized')
|
||||||
|
|
||||||
|
setCurrentTask(task)
|
||||||
|
setHistory([])
|
||||||
|
return agent.execute(task)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
agentRef.current?.stop()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const configure = useCallback(
|
||||||
|
async ({
|
||||||
|
language,
|
||||||
|
maxSteps,
|
||||||
|
systemInstruction,
|
||||||
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
|
disableNamedToolChoice,
|
||||||
|
...llmConfig
|
||||||
|
}: ExtConfig) => {
|
||||||
|
await chrome.storage.local.set({ llmConfig })
|
||||||
|
if (language) {
|
||||||
|
await chrome.storage.local.set({ language })
|
||||||
|
} else {
|
||||||
|
await chrome.storage.local.remove('language')
|
||||||
|
}
|
||||||
|
const advancedConfig: AdvancedConfig = {
|
||||||
|
maxSteps,
|
||||||
|
systemInstruction,
|
||||||
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
|
disableNamedToolChoice,
|
||||||
|
}
|
||||||
|
await chrome.storage.local.set({ advancedConfig })
|
||||||
|
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
history,
|
||||||
|
activity,
|
||||||
|
currentTask,
|
||||||
|
config,
|
||||||
|
execute,
|
||||||
|
stop,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.19 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--animate-blink-cursor: blink-cursor 1.2s step-end infinite;
|
||||||
|
@keyframes blink-cursor {
|
||||||
|
0%,
|
||||||
|
49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-a {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-b {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,402 @@
|
|||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
CornerUpLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FoldVertical,
|
||||||
|
HatGlasses,
|
||||||
|
Home,
|
||||||
|
Loader2,
|
||||||
|
Scale,
|
||||||
|
UnfoldVertical,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { siGithub } from 'simple-icons'
|
||||||
|
|
||||||
|
import { DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||||
|
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: ExtConfig | null
|
||||||
|
onSave: (config: ExtConfig) => Promise<void>
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||||
|
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||||
|
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||||
|
const [apiKey, setApiKey] = useState(config?.apiKey)
|
||||||
|
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
||||||
|
const [maxSteps, setMaxSteps] = useState(config?.maxSteps)
|
||||||
|
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
||||||
|
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||||
|
config?.experimentalLlmsTxt ?? false
|
||||||
|
)
|
||||||
|
const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState(
|
||||||
|
config?.experimentalIncludeAllTabs ?? false
|
||||||
|
)
|
||||||
|
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
||||||
|
config?.disableNamedToolChoice ?? false
|
||||||
|
)
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [userAuthToken, setUserAuthToken] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [showToken, setShowToken] = useState(false)
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
|
||||||
|
const [prevConfig, setPrevConfig] = useState(config)
|
||||||
|
if (prevConfig !== config) {
|
||||||
|
setPrevConfig(config)
|
||||||
|
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||||
|
setModel(config?.model || DEMO_MODEL)
|
||||||
|
setApiKey(config?.apiKey)
|
||||||
|
setLanguage(config?.language)
|
||||||
|
setMaxSteps(config?.maxSteps)
|
||||||
|
setSystemInstruction(config?.systemInstruction ?? '')
|
||||||
|
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
||||||
|
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
|
||||||
|
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for user auth token every second until found
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const fetchToken = async () => {
|
||||||
|
const result = await chrome.storage.local.get('PageAgentExtUserAuthToken')
|
||||||
|
const token = result.PageAgentExtUserAuthToken
|
||||||
|
if (typeof token === 'string' && token) {
|
||||||
|
setUserAuthToken(token)
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval)
|
||||||
|
interval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchToken()
|
||||||
|
interval = setInterval(fetchToken, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCopyToken = async () => {
|
||||||
|
if (userAuthToken) {
|
||||||
|
await navigator.clipboard.writeText(userAuthToken)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
model,
|
||||||
|
language,
|
||||||
|
maxSteps: maxSteps || undefined,
|
||||||
|
systemInstruction: systemInstruction || undefined,
|
||||||
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
|
disableNamedToolChoice,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4 relative">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Settings</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-2 right-3 cursor-pointer"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<CornerUpLeft className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Auth Token Section */}
|
||||||
|
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||||
|
<label htmlFor="user-auth-token" className="text-xs font-medium text-muted-foreground">
|
||||||
|
User Auth Token
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1">
|
||||||
|
Give a website the ability to call this extension.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
id="user-auth-token"
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
userAuthToken
|
||||||
|
? showToken
|
||||||
|
? userAuthToken
|
||||||
|
: `${userAuthToken.slice(0, 4)}${'•'.repeat(userAuthToken.length - 8)}${userAuthToken.slice(-4)}`
|
||||||
|
: 'Loading...'
|
||||||
|
}
|
||||||
|
className="text-xs h-8 font-mono bg-background"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
disabled={!userAuthToken}
|
||||||
|
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||||
|
aria-pressed={showToken}
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
disabled={!userAuthToken}
|
||||||
|
aria-label="Copy token"
|
||||||
|
>
|
||||||
|
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||||
|
{copied ? 'Token copied' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hub link */}
|
||||||
|
<a
|
||||||
|
href="/hub.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||||
|
>
|
||||||
|
Manage Page Agent Hub
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="base-url" className="text-xs text-muted-foreground">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="base-url"
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
className="text-xs h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Testing API notice */}
|
||||||
|
{isTestingEndpoint(baseURL) && (
|
||||||
|
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
|
||||||
|
You are using our testing API. By using this you agree to the{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
Terms of Use & Privacy Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="model" className="text-xs text-muted-foreground">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="model"
|
||||||
|
placeholder="gpt-5.1"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
className="text-xs h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="api-key" className="text-xs text-muted-foreground">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
id="api-key"
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
// placeholder="sk-..."
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
className="text-xs h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||||
|
>
|
||||||
|
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Response Language</label>
|
||||||
|
<select
|
||||||
|
value={language ?? ''}
|
||||||
|
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
|
||||||
|
className="h-8 text-xs rounded-md border border-input bg-background px-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">System</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Config */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
{advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{advancedOpen && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="max-steps" className="text-xs text-muted-foreground">
|
||||||
|
Max Steps
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="max-steps"
|
||||||
|
type="number"
|
||||||
|
placeholder="40"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={maxSteps ?? ''}
|
||||||
|
onChange={(e) => setMaxSteps(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
className="text-xs h-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">System Instruction</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Additional instructions for the agent..."
|
||||||
|
value={systemInstruction}
|
||||||
|
onChange={(e) => setSystemInstruction(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="text-xs rounded-md border border-input bg-background px-3 py-2 resize-y min-h-[60px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-xs text-muted-foreground">Disable named tool_choice</span>
|
||||||
|
<Switch checked={disableNamedToolChoice} onCheckedChange={setDisableNamedToolChoice} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||||
|
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-xs text-muted-foreground">Experimental include all tabs</span>
|
||||||
|
<Switch
|
||||||
|
checked={experimentalIncludeAllTabs}
|
||||||
|
onCheckedChange={setExperimentalIncludeAllTabs}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 h-8 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<span>
|
||||||
|
Version <span className="font-mono">v{__VERSION__}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg role="img" viewBox="0 0 24 24" className="size-3 fill-current">
|
||||||
|
<path d={siGithub.path} />
|
||||||
|
</svg>
|
||||||
|
<span>Source Code</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<a
|
||||||
|
href="https://alibaba.github.io/page-agent/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Home className="size-3" />
|
||||||
|
<span>Home Page</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<HatGlasses className="size-3" />
|
||||||
|
<span>Privacy</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* attribute */}
|
||||||
|
<div className="text-[10px] text-muted-foreground bg-background fixed bottom-0 w-full flex justify-around">
|
||||||
|
<span className="leading-loose">
|
||||||
|
Built with ♥️ by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/gaomeng1900"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
@Simon
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { AlertTriangle, Eraser, RotateCcw } from 'lucide-react'
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false, error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary]', error, errorInfo.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResetConfig = async () => {
|
||||||
|
await chrome.storage.local.remove(['llmConfig', 'language', 'advancedConfig'])
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.hasError) {
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen bg-background p-6 text-center">
|
||||||
|
<AlertTriangle className="size-12 text-destructive mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-xs">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={this.handleResetConfig}>
|
||||||
|
<Eraser className="size-3.5 mr-2" />
|
||||||
|
Reset Config
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={this.handleReload}>
|
||||||
|
<RotateCcw className="size-3.5 mr-2" />
|
||||||
|
Reload Panel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ArrowLeft, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { type SessionRecord, deleteSession, getSession } from '@/lib/db'
|
||||||
|
|
||||||
|
import { EventCard } from './cards'
|
||||||
|
|
||||||
|
export function HistoryDetail({
|
||||||
|
sessionId,
|
||||||
|
onBack,
|
||||||
|
onRerun,
|
||||||
|
}: {
|
||||||
|
sessionId: string
|
||||||
|
onBack: () => void
|
||||||
|
onRerun: (task: string) => void
|
||||||
|
}) {
|
||||||
|
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSession(sessionId).then((s) => setSession(s ?? null))
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium truncate">History</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Task */}
|
||||||
|
<div className="border-b px-3 py-2 bg-muted/30">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
||||||
|
<div className="text-xs font-medium" title={session.task}>
|
||||||
|
{session.task}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRerun(session.task)}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Run again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteSession(sessionId)
|
||||||
|
onBack()
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events (read-only) */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
{session.history.map((event, index) => (
|
||||||
|
<EventCard key={index} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
History,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||||
|
import { downloadHistoryExport } from '@/lib/history-export'
|
||||||
|
|
||||||
|
function timeAgo(ts: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||||
|
if (seconds < 60) return 'just now'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryList({
|
||||||
|
onSelect,
|
||||||
|
onBack,
|
||||||
|
onRerun,
|
||||||
|
}: {
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onBack: () => void
|
||||||
|
onRerun: (task: string) => void
|
||||||
|
}) {
|
||||||
|
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSessions(await listSessions())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HistoryList] Failed to load sessions:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
await deleteSession(id)
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (e: React.MouseEvent, session: SessionRecord) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadHistoryExport(session.task, session.createdAt, session.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRerun = (e: React.MouseEvent, task: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRerun(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label="Back"
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium flex-1">History</span>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await clearSessions()
|
||||||
|
setSessions([])
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3 mr-1" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col" aria-label="Loading history" aria-busy="true">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-3 py-2.5 border-b">
|
||||||
|
<div className="size-3.5 mt-0.5 rounded-full bg-muted animate-pulse shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-2.5 bg-muted animate-pulse rounded w-3/4" />
|
||||||
|
<div className="h-2 bg-muted animate-pulse rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && sessions.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
|
||||||
|
<History className="size-8 opacity-30" />
|
||||||
|
<p className="text-xs">No history yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelect(session.id)}
|
||||||
|
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||||
|
>
|
||||||
|
{/* Status icon */}
|
||||||
|
{session.status === 'completed' ? (
|
||||||
|
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||||
|
<div className="flex items-center mt-0.5">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleRerun(e, session.task)}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title="Run task again"
|
||||||
|
aria-label={`Run history task again: ${session.task}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleExport(e, session)}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title="Export history JSON"
|
||||||
|
aria-label={`Export history for ${session.task}`}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(e, session.id)}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
title="Delete history"
|
||||||
|
aria-label={`Delete history for ${session.task}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import type {
|
||||||
|
AgentActivity,
|
||||||
|
AgentErrorEvent,
|
||||||
|
AgentStepEvent,
|
||||||
|
HistoricalEvent,
|
||||||
|
ObservationEvent,
|
||||||
|
RetryEvent,
|
||||||
|
} from '@page-agent/core'
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Keyboard,
|
||||||
|
Mouse,
|
||||||
|
MoveVertical,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Fragment, useState } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Result card for done action
|
||||||
|
function ResultCard({
|
||||||
|
success,
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
success: boolean
|
||||||
|
text: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-3',
|
||||||
|
success ? 'border-green-500/30 bg-green-500/10' : 'border-destructive/30 bg-destructive/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{success ? (
|
||||||
|
<CheckCircle className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-3.5 text-destructive" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium',
|
||||||
|
success ? 'text-green-600 dark:text-green-400' : 'text-destructive'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Result: {success ? 'Success' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single reflection item with truncation
|
||||||
|
function ReflectionItem({ icon, value }: { icon: string; value: string }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<span className="text-xs flex justify-center">{icon}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] text-muted-foreground cursor-pointer hover:text-muted-foreground/70',
|
||||||
|
!expanded && 'line-clamp-1'
|
||||||
|
)}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflection section in step card
|
||||||
|
function ReflectionSection({
|
||||||
|
reflection,
|
||||||
|
}: {
|
||||||
|
reflection: {
|
||||||
|
evaluation_previous_goal?: string
|
||||||
|
memory?: string
|
||||||
|
next_goal?: string
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const items = [
|
||||||
|
{ icon: '☑️', label: 'eval', value: reflection.evaluation_previous_goal },
|
||||||
|
{ icon: '🧠', label: 'memory', value: reflection.memory },
|
||||||
|
{ icon: '🎯', label: 'goal', value: reflection.next_goal },
|
||||||
|
].filter((item) => item.value)
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
|
||||||
|
Reflection
|
||||||
|
</div> */}
|
||||||
|
<div className="grid grid-cols-[14px_1fr] gap-x-2 gap-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ReflectionItem key={item.label} icon={item.icon} value={item.value!} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get icon for action type
|
||||||
|
function ActionIcon({ name, className }: { name: string; className?: string }) {
|
||||||
|
const icons: Record<string, React.ReactNode> = {
|
||||||
|
click_element_by_index: <Mouse className={className} />,
|
||||||
|
input: <Keyboard className={className} />,
|
||||||
|
scroll: <MoveVertical className={className} />,
|
||||||
|
go_to_url: <Globe className={className} />,
|
||||||
|
}
|
||||||
|
return icons[name] || <Zap className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy button with "Copied!" feedback
|
||||||
|
function CopyButton({ text, label }: { text: string; label: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
}}
|
||||||
|
className="text-[9px] text-muted-foreground hover:text-foreground transition-colors border px-1 rounded shrink-0 cursor-pointer backdrop-blur-xs"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract message content by role from raw request
|
||||||
|
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||||
|
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||||
|
if (!messages) return null
|
||||||
|
if (!Array.isArray(messages)) return null
|
||||||
|
const msg =
|
||||||
|
role === 'system'
|
||||||
|
? messages.find((m) => m.role === role)
|
||||||
|
: messages.findLast((m) => m.role === role)
|
||||||
|
if (!msg?.content) return null
|
||||||
|
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw request/response section (collapsible tabs, for debugging)
|
||||||
|
function RawSection({ rawRequest, rawResponse }: { rawRequest?: unknown; rawResponse?: unknown }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'request' | 'response' | null>(null)
|
||||||
|
|
||||||
|
if (!rawRequest && !rawResponse) return null
|
||||||
|
|
||||||
|
const handleTabClick = (tab: 'request' | 'response') => {
|
||||||
|
setActiveTab(activeTab === tab ? null : tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
activeTab === 'request' ? rawRequest : activeTab === 'response' ? rawResponse : null
|
||||||
|
|
||||||
|
const systemPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'system') : null
|
||||||
|
const userPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'user') : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 border-t border-dashed pt-2">
|
||||||
|
<div className="flex items-center gap-3 -my-1">
|
||||||
|
{rawRequest != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabClick('request')}
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||||
|
activeTab === 'request'
|
||||||
|
? 'text-foreground border-foreground'
|
||||||
|
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Raw Request
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{rawResponse != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabClick('response')}
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||||
|
activeTab === 'response'
|
||||||
|
? 'text-foreground border-foreground'
|
||||||
|
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Raw Response
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{content != null && (
|
||||||
|
<div className="relative mt-1.5">
|
||||||
|
<div className="absolute top-1 right-1 flex gap-1">
|
||||||
|
{systemPrompt && <CopyButton text={systemPrompt} label="Copy System" />}
|
||||||
|
{userPrompt && <CopyButton text={userPrompt} label="Copy User" />}
|
||||||
|
<CopyButton text={JSON.stringify(content, null, 4)} label="Copy" />
|
||||||
|
</div>
|
||||||
|
<pre className="p-2 pt-5 text-[10px] text-foreground/70 bg-muted rounded overflow-x-auto max-h-60 overflow-y-auto">
|
||||||
|
{JSON.stringify(content, null, 4)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({ event }: { event: AgentStepEvent }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-l-2 border-l-blue-500/50 border bg-muted/40 p-2.5">
|
||||||
|
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-2">
|
||||||
|
Step #{event.stepIndex! + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reflection */}
|
||||||
|
{event.reflection && <ReflectionSection reflection={event.reflection} />}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{event.action && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-1">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ActionIcon
|
||||||
|
name={event.action.name}
|
||||||
|
className="size-3.5 text-blue-500 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-foreground/80 mb-0.5 wrap-anywhere break-all line-clamp-1 hover:line-clamp-none">
|
||||||
|
<span className="font-medium text-foreground/70">{event.action.name}</span>
|
||||||
|
{event.action.name !== 'done' && (
|
||||||
|
<span className="text-muted-foreground/70 ml-1.5">
|
||||||
|
{JSON.stringify(event.action.input)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground/70 grid grid-cols-[auto_1fr] gap-1.5">
|
||||||
|
<span className="">└</span>
|
||||||
|
<span className="wrap-anywhere break-all line-clamp-1 hover:line-clamp-3">
|
||||||
|
{event.action.output}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw Response */}
|
||||||
|
<RawSection rawRequest={event.rawRequest} rawResponse={event.rawResponse} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObservationCard({ event }: { event: ObservationEvent }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-l-2 border-l-green-500/50 border bg-muted/40 p-2.5">
|
||||||
|
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
|
||||||
|
Observation
|
||||||
|
</div> */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Eye className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span className="text-[11px] text-muted-foreground">{event.content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetryCard({ event }: { event: RetryEvent }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<RefreshCw className="size-3 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{event.message} ({event.attempt}/{event.maxAttempts})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ event }: { event: AgentErrorEvent }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-destructive">{event.message}</span>
|
||||||
|
</div>
|
||||||
|
<RawSection rawResponse={event.rawResponse} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History event card component
|
||||||
|
export function EventCard({ event }: { event: HistoricalEvent }) {
|
||||||
|
// Done action - show as result card
|
||||||
|
if (event.type === 'step' && event.action?.name === 'done') {
|
||||||
|
const input = event.action.input as { text?: string; success?: boolean }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StepCard event={event as AgentStepEvent} />
|
||||||
|
<ResultCard
|
||||||
|
success={input?.success ?? true}
|
||||||
|
text={input?.text || event.action.output || ''}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'step') {
|
||||||
|
return <StepCard event={event as AgentStepEvent} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'observation') {
|
||||||
|
return <ObservationCard event={event as ObservationEvent} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'retry') {
|
||||||
|
return <RetryCard event={event as RetryEvent} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'error') {
|
||||||
|
return <ErrorCard event={event as AgentErrorEvent} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity card with animation
|
||||||
|
export function ActivityCard({ activity }: { activity: AgentActivity }) {
|
||||||
|
const getActivityInfo = () => {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'thinking':
|
||||||
|
return { text: 'Thinking...', color: 'text-blue-500' }
|
||||||
|
case 'executing':
|
||||||
|
return { text: `Executing ${activity.tool}...`, color: 'text-amber-500' }
|
||||||
|
case 'executed':
|
||||||
|
return { text: `Done: ${activity.tool}`, color: 'text-green-500' }
|
||||||
|
case 'retrying':
|
||||||
|
return {
|
||||||
|
text: `Retrying (${activity.attempt}/${activity.maxAttempts})...`,
|
||||||
|
color: 'text-amber-500',
|
||||||
|
}
|
||||||
|
case 'error':
|
||||||
|
return { text: activity.message, color: 'text-destructive' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = getActivityInfo()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 animate-pulse">
|
||||||
|
<div className="relative">
|
||||||
|
<Sparkles className={cn('size-3.5', info.color)} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-0.5 -right-0.5 size-1.5 rounded-full animate-ping',
|
||||||
|
activity.type === 'thinking'
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: activity.type === 'executing'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: activity.type === 'retrying'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: activity.type === 'error'
|
||||||
|
? 'bg-destructive'
|
||||||
|
: 'bg-green-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-xs', info.color)}>{info.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { AgentStatus } from '@page-agent/core'
|
||||||
|
import { Motion } from 'ai-motion'
|
||||||
|
import { BookOpen, Globe } from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { siGithub } from 'simple-icons'
|
||||||
|
|
||||||
|
import { TypingAnimation } from '@/components/ui/typing-animation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Status dot indicator
|
||||||
|
export function StatusDot({ status }: { status: AgentStatus }) {
|
||||||
|
const colorClass = {
|
||||||
|
idle: 'bg-muted-foreground',
|
||||||
|
running: 'bg-blue-500',
|
||||||
|
completed: 'bg-green-500',
|
||||||
|
error: 'bg-destructive',
|
||||||
|
stopped: 'bg-muted-foreground',
|
||||||
|
}[status]
|
||||||
|
|
||||||
|
const label = {
|
||||||
|
idle: 'Ready',
|
||||||
|
running: 'Running',
|
||||||
|
completed: 'Done',
|
||||||
|
error: 'Error',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
}[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 mr-2">
|
||||||
|
<span
|
||||||
|
className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ className }: { className?: string }) {
|
||||||
|
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen ai-motion glow overlay, shown only while running
|
||||||
|
export function MotionOverlay({ active }: { active: boolean }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const motionRef = useRef<Motion | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||||
|
const motion = new Motion({
|
||||||
|
mode,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderRadius: 14,
|
||||||
|
glowWidth: mode === 'dark' ? 120 : 60,
|
||||||
|
styles: { position: 'absolute', inset: '0' },
|
||||||
|
})
|
||||||
|
motionRef.current = motion
|
||||||
|
containerRef.current!.appendChild(motion.element)
|
||||||
|
motion.autoResize(containerRef.current!)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[MotionOverlay] Motion unavailable:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
motionRef.current?.dispose()
|
||||||
|
motionRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const motion = motionRef.current
|
||||||
|
if (!motion) return
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
if (active) {
|
||||||
|
motion.start()
|
||||||
|
motion.fadeIn()
|
||||||
|
} else {
|
||||||
|
motion.fadeOut().then(() => !disposed && motion.pause())
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 opacity-60 overflow-hidden"
|
||||||
|
style={{ display: active ? undefined : 'none' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state with logo and breathing glow
|
||||||
|
export function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6">
|
||||||
|
<div className="relative select-none pointer-events-none">
|
||||||
|
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_180deg,oklch(0.55_0.2_280),oklch(0.5_0.15_230),oklch(0.6_0.18_310),oklch(0.55_0.2_280))] blur-2xl animate-[glow-a_5s_ease-in-out_infinite]" />
|
||||||
|
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_0deg,oklch(0.55_0.18_160),oklch(0.5_0.2_200),oklch(0.6_0.15_120),oklch(0.55_0.18_160))] blur-2xl animate-[glow-b_5s_ease-in-out_infinite]" />
|
||||||
|
<Logo className="relative size-20 opacity-80" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-foreground mb-1">Page Agent Ext</h2>
|
||||||
|
<TypingAnimation
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
words={[
|
||||||
|
'Enter a task to automate this page',
|
||||||
|
'Execute multi-page tasks',
|
||||||
|
'Call this extension from your web page',
|
||||||
|
'Use this extension in your own agents',
|
||||||
|
]}
|
||||||
|
cursorStyle="underscore"
|
||||||
|
loop
|
||||||
|
startOnView={false}
|
||||||
|
typeSpeed={20}
|
||||||
|
deleteSpeed={10}
|
||||||
|
pauseDelay={3000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-muted-foreground">
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
title="GitHub"
|
||||||
|
>
|
||||||
|
<svg role="img" viewBox="0 0 24 24" className="size-4 fill-current">
|
||||||
|
<path d={siGithub.path} />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://alibaba.github.io/page-agent/docs/features/chrome-extension"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
title="Documentation"
|
||||||
|
>
|
||||||
|
<BookOpen className="size-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://alibaba.github.io/page-agent"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
title="Website"
|
||||||
|
>
|
||||||
|
<Globe className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline:
|
||||||
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
'icon-sm': 'size-8',
|
||||||
|
'icon-lg': 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn('leading-none font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = 'legend',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
'mb-3 font-medium',
|
||||||
|
'data-[variant=legend]:text-base',
|
||||||
|
'data-[variant=label]:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||||
|
horizontal: [
|
||||||
|
'flex-row items-center',
|
||||||
|
'[&>[data-slot=field-label]]:flex-auto',
|
||||||
|
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
|
||||||
|
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||||
|
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: 'vertical',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||||
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||||
|
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||||
|
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn('text-destructive text-sm font-normal', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||||
|
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||||
|
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||||
|
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||||
|
|
||||||
|
// Focus state — soft multi-color glow matching ai-motion palette
|
||||||
|
'has-[[data-slot=input-group-control]:focus-visible]:border-blue-400/60',
|
||||||
|
'has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_1px_rgba(57,182,255,0.2),0_0_8px_rgba(57,182,255,0.15),0_0_16px_rgba(189,69,251,0.1)]',
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||||
|
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||||
|
'block-start':
|
||||||
|
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
|
||||||
|
'block-end':
|
||||||
|
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: 'inline-start',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = 'inline-start',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector('input')?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
|
||||||
|
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||||
|
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'xs',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'xs',
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn('group/item-group flex flex-col', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn('my-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
outline: 'border-border',
|
||||||
|
muted: 'bg-muted/50',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'p-4 gap-4 ',
|
||||||
|
sm: 'py-3 px-4 gap-2.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'div'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = 'system' } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps['theme']}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--normal-bg': 'var(--popover)',
|
||||||
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
|
'--normal-border': 'var(--border)',
|
||||||
|
'--border-radius': 'var(--radius)',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn('size-4 animate-spin', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { MotionProps, motion, useInView } from 'motion/react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface TypingAnimationProps extends MotionProps {
|
||||||
|
children?: string
|
||||||
|
words?: string[]
|
||||||
|
className?: string
|
||||||
|
duration?: number
|
||||||
|
typeSpeed?: number
|
||||||
|
deleteSpeed?: number
|
||||||
|
delay?: number
|
||||||
|
pauseDelay?: number
|
||||||
|
loop?: boolean
|
||||||
|
as?: React.ElementType
|
||||||
|
startOnView?: boolean
|
||||||
|
showCursor?: boolean
|
||||||
|
blinkCursor?: boolean
|
||||||
|
cursorStyle?: 'line' | 'block' | 'underscore'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypingAnimation({
|
||||||
|
children,
|
||||||
|
words,
|
||||||
|
className,
|
||||||
|
duration = 100,
|
||||||
|
typeSpeed,
|
||||||
|
deleteSpeed,
|
||||||
|
delay = 0,
|
||||||
|
pauseDelay = 1000,
|
||||||
|
loop = false,
|
||||||
|
as: Component = 'span',
|
||||||
|
startOnView = true,
|
||||||
|
showCursor = true,
|
||||||
|
blinkCursor = true,
|
||||||
|
cursorStyle = 'line',
|
||||||
|
...props
|
||||||
|
}: TypingAnimationProps) {
|
||||||
|
const MotionComponent = motion.create(Component, {
|
||||||
|
forwardMotionProps: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [displayedText, setDisplayedText] = useState<string>('')
|
||||||
|
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||||
|
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||||
|
const [phase, setPhase] = useState<'typing' | 'pause' | 'deleting'>('typing')
|
||||||
|
const elementRef = useRef<HTMLElement | null>(null)
|
||||||
|
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||||
|
amount: 0.3,
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wordsToAnimate = useMemo(() => words || (children ? [children] : []), [words, children])
|
||||||
|
const hasMultipleWords = wordsToAnimate.length > 1
|
||||||
|
|
||||||
|
const typingSpeed = typeSpeed || duration
|
||||||
|
const deletingSpeed = deleteSpeed || typingSpeed / 2
|
||||||
|
|
||||||
|
const shouldStart = startOnView ? isInView : true
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldStart || wordsToAnimate.length === 0) return
|
||||||
|
|
||||||
|
const timeoutDelay =
|
||||||
|
delay > 0 && displayedText === ''
|
||||||
|
? delay
|
||||||
|
: phase === 'typing'
|
||||||
|
? typingSpeed
|
||||||
|
: phase === 'deleting'
|
||||||
|
? deletingSpeed
|
||||||
|
: pauseDelay
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const currentWord = wordsToAnimate[currentWordIndex] || ''
|
||||||
|
const graphemes = Array.from(currentWord)
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 'typing':
|
||||||
|
if (currentCharIndex < graphemes.length) {
|
||||||
|
setDisplayedText(graphemes.slice(0, currentCharIndex + 1).join(''))
|
||||||
|
setCurrentCharIndex(currentCharIndex + 1)
|
||||||
|
} else {
|
||||||
|
if (hasMultipleWords || loop) {
|
||||||
|
const isLastWord = currentWordIndex === wordsToAnimate.length - 1
|
||||||
|
if (!isLastWord || loop) {
|
||||||
|
setPhase('pause')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'pause':
|
||||||
|
setPhase('deleting')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'deleting':
|
||||||
|
if (currentCharIndex > 0) {
|
||||||
|
setDisplayedText(graphemes.slice(0, currentCharIndex - 1).join(''))
|
||||||
|
setCurrentCharIndex(currentCharIndex - 1)
|
||||||
|
} else {
|
||||||
|
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||||
|
setCurrentWordIndex(nextIndex)
|
||||||
|
setPhase('typing')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, timeoutDelay)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [
|
||||||
|
shouldStart,
|
||||||
|
phase,
|
||||||
|
currentCharIndex,
|
||||||
|
currentWordIndex,
|
||||||
|
displayedText,
|
||||||
|
wordsToAnimate,
|
||||||
|
hasMultipleWords,
|
||||||
|
loop,
|
||||||
|
typingSpeed,
|
||||||
|
deletingSpeed,
|
||||||
|
pauseDelay,
|
||||||
|
delay,
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentWordGraphemes = Array.from(wordsToAnimate[currentWordIndex] || '')
|
||||||
|
const isComplete =
|
||||||
|
!loop &&
|
||||||
|
currentWordIndex === wordsToAnimate.length - 1 &&
|
||||||
|
currentCharIndex >= currentWordGraphemes.length &&
|
||||||
|
phase !== 'deleting'
|
||||||
|
|
||||||
|
const shouldShowCursor =
|
||||||
|
showCursor &&
|
||||||
|
!isComplete &&
|
||||||
|
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||||||
|
|
||||||
|
const getCursorChar = () => {
|
||||||
|
switch (cursorStyle) {
|
||||||
|
case 'block':
|
||||||
|
return '▌'
|
||||||
|
case 'underscore':
|
||||||
|
return '_'
|
||||||
|
case 'line':
|
||||||
|
default:
|
||||||
|
return '|'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionComponent
|
||||||
|
ref={elementRef}
|
||||||
|
className={cn('leading-[5rem] tracking-[-0.02em]', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{displayedText}
|
||||||
|
{shouldShowCursor && (
|
||||||
|
<span className={cn('inline-block', blinkCursor && 'animate-blink-cursor')}>
|
||||||
|
{getCursorChar()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</MotionComponent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||||
|
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
||||||
|
|
||||||
|
export default defineBackground(() => {
|
||||||
|
console.log('[Background] Service Worker started')
|
||||||
|
|
||||||
|
// generate user auth token
|
||||||
|
|
||||||
|
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||||
|
if (result.PageAgentExtUserAuthToken) return
|
||||||
|
|
||||||
|
const userAuthToken = crypto.randomUUID()
|
||||||
|
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
// message proxy
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||||
|
if (message.type === 'TAB_CONTROL') {
|
||||||
|
return handleTabControlMessage(message, sender, sendResponse)
|
||||||
|
} else if (message.type === 'PAGE_CONTROL') {
|
||||||
|
return handlePageControlMessage(message, sender, sendResponse)
|
||||||
|
} else {
|
||||||
|
sendResponse({ error: 'Unknown message type' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// external messages (from localhost launcher page via externally_connectable)
|
||||||
|
|
||||||
|
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message.type === 'OPEN_HUB') {
|
||||||
|
openOrFocusHubTab(message.wsPort).then(() => {
|
||||||
|
if (sender.tab?.id) chrome.tabs.remove(sender.tab.id)
|
||||||
|
sendResponse({ ok: true })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// setup
|
||||||
|
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openOrFocusHubTab(wsPort: number) {
|
||||||
|
const hubUrl = chrome.runtime.getURL('hub.html')
|
||||||
|
const existing = await chrome.tabs.query({ url: `${hubUrl}*` })
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].id) {
|
||||||
|
await chrome.tabs.update(existing[0].id, {
|
||||||
|
active: true,
|
||||||
|
url: `${hubUrl}?ws=${wsPort}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await chrome.tabs.create({ url: `${hubUrl}?ws=${wsPort}`, pinned: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { initPageController } from '@/agent/RemotePageController.content'
|
||||||
|
|
||||||
|
// import { DEMO_CONFIG } from '@/agent/constants'
|
||||||
|
|
||||||
|
const DEBUG_PREFIX = '[Content]'
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['<all_urls>'],
|
||||||
|
runAt: 'document_end',
|
||||||
|
|
||||||
|
main() {
|
||||||
|
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
||||||
|
initPageController()
|
||||||
|
|
||||||
|
// if auth token matches, expose agent to page
|
||||||
|
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||||
|
// extension side token.
|
||||||
|
// @note this is isolated world. it is safe to assume user script cannot access it
|
||||||
|
const extToken = result.PageAgentExtUserAuthToken
|
||||||
|
if (!extToken) return
|
||||||
|
|
||||||
|
// page side token
|
||||||
|
const pageToken = localStorage.getItem('PageAgentExtUserAuthToken')
|
||||||
|
if (!pageToken) return
|
||||||
|
|
||||||
|
if (pageToken !== extToken) return
|
||||||
|
|
||||||
|
console.log('[PageAgentExt]: Auth tokens match. Exposing agent to page.')
|
||||||
|
|
||||||
|
// add isolated world script
|
||||||
|
exposeAgentToPage().then(
|
||||||
|
// add main-world script
|
||||||
|
() => injectScript('/main-world.js')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function exposeAgentToPage() {
|
||||||
|
const { MultiPageAgent } = await import('@/agent/MultiPageAgent')
|
||||||
|
console.log('[PageAgentExt]: MultiPageAgent loaded')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* singleton MultiPageAgent to handle requests from the page
|
||||||
|
*/
|
||||||
|
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
|
||||||
|
|
||||||
|
window.addEventListener('message', async (e) => {
|
||||||
|
if (e.source !== window) return
|
||||||
|
|
||||||
|
const data = e.data
|
||||||
|
if (typeof data !== 'object' || data === null) return
|
||||||
|
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
|
||||||
|
|
||||||
|
const { action, payload, id } = data
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'execute': {
|
||||||
|
// singleton check
|
||||||
|
if (multiPageAgent && multiPageAgent.status === 'running') {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'execute_result',
|
||||||
|
error: 'Agent is already running a task. Please wait until it finishes.',
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { task, config } = payload
|
||||||
|
const { systemInstruction, ...agentConfig } = config
|
||||||
|
|
||||||
|
// Dispose old instance before creating new one
|
||||||
|
multiPageAgent?.dispose()
|
||||||
|
|
||||||
|
multiPageAgent = new MultiPageAgent({
|
||||||
|
...agentConfig,
|
||||||
|
instructions: systemInstruction ? { system: systemInstruction } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// events
|
||||||
|
|
||||||
|
multiPageAgent.addEventListener('statuschange', (event) => {
|
||||||
|
if (!multiPageAgent) return
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'status_change_event',
|
||||||
|
payload: multiPageAgent.status,
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
multiPageAgent.addEventListener('activity', (event) => {
|
||||||
|
if (!multiPageAgent) return
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'activity_event',
|
||||||
|
payload: (event as CustomEvent).detail,
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
multiPageAgent.addEventListener('historychange', (event) => {
|
||||||
|
if (!multiPageAgent) return
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'history_change_event',
|
||||||
|
payload: multiPageAgent.history,
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// result
|
||||||
|
|
||||||
|
const result = await multiPageAgent.execute(task)
|
||||||
|
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'execute_result',
|
||||||
|
payload: result,
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||||
|
id,
|
||||||
|
action: 'execute_result',
|
||||||
|
error: (error as Error).message,
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stop': {
|
||||||
|
multiPageAgent?.stop()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`${DEBUG_PREFIX} Unknown action from page:`, action)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { FoldVertical, Plug, PlugZap, Square, UnfoldVertical, Unplug } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useAgent } from '@/agent/useAgent'
|
||||||
|
import { ActivityCard, EventCard } from '@/components/cards'
|
||||||
|
import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
import { useHubWs } from './hub-ws'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||||
|
const { wsState } = useHubWs(execute, stop, configure, config)
|
||||||
|
|
||||||
|
const historyRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyRef.current) {
|
||||||
|
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [history, activity])
|
||||||
|
|
||||||
|
const isRunning = status === 'running'
|
||||||
|
const WsIcon = wsState === 'connected' ? PlugZap : wsState === 'connecting' ? Plug : Unplug
|
||||||
|
const wsLabel = {
|
||||||
|
connected: 'Connected',
|
||||||
|
connecting: 'Connecting…',
|
||||||
|
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'No connection',
|
||||||
|
}[wsState]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-background">
|
||||||
|
{/* Left — Protocol docs */}
|
||||||
|
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
|
||||||
|
<a
|
||||||
|
href="https://alibaba.github.io/page-agent/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-5 h-12 border-b hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Logo className="size-5" />
|
||||||
|
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wider text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded px-1.5 py-0.5">
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||||
|
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
|
||||||
|
<p>
|
||||||
|
Page Agent Hub lets local apps (e.g. MCP servers) control the Page Agent extension via
|
||||||
|
WebSocket.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Check out the official{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent/tree/main/packages/mcp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
MCP server package
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HubConfig />
|
||||||
|
|
||||||
|
<ProtocolDocsCollapsible />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t px-5 py-3 text-[10px] text-muted-foreground/60 flex items-center justify-between">
|
||||||
|
<span className="font-mono">v{__VERSION__}</span>
|
||||||
|
<span>
|
||||||
|
Built with ♥️ by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/gaomeng1900"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
@Simon
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right — Live session */}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 relative">
|
||||||
|
<MotionOverlay active={isRunning} />
|
||||||
|
|
||||||
|
<header className="flex items-center justify-between border-b px-5 h-12">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<WsIcon className="size-3.5" />
|
||||||
|
<span>{wsLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusDot status={status} />
|
||||||
|
{isRunning && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={stop} className="h-7 text-xs">
|
||||||
|
<Square className="size-3 mr-1" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Task banner */}
|
||||||
|
{currentTask && (
|
||||||
|
<div className="border-b px-5 py-2 bg-muted/30">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||||
|
Current Task
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium truncate" title={currentTask}>
|
||||||
|
{currentTask}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event stream */}
|
||||||
|
<div ref={historyRef} className="flex-1 overflow-y-auto p-5 space-y-2">
|
||||||
|
{!currentTask && history.length === 0 && !isRunning && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||||
|
<WsIcon className="size-10 opacity-30" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{wsState === 'connected'
|
||||||
|
? 'Waiting for task from external caller…'
|
||||||
|
: 'No active session'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.map((event, index) => (
|
||||||
|
<EventCard key={index} event={event} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activity && <ActivityCard activity={activity} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HubConfig() {
|
||||||
|
const [allowAll, setAllowAll] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chrome.storage.local.get('allowAllHubConnection').then((r) => {
|
||||||
|
setAllowAll(r.allowAllHubConnection === true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = (checked: boolean) => {
|
||||||
|
setAllowAll(checked)
|
||||||
|
chrome.storage.local.set({ allowAllHubConnection: checked })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Config
|
||||||
|
</h3>
|
||||||
|
<div className="group/hub relative">
|
||||||
|
<label
|
||||||
|
className={`flex items-center justify-between p-3 rounded-md border cursor-pointer text-xs ${allowAll ? 'bg-amber-500/10 border-amber-500/30 text-amber-600' : 'bg-muted/50 text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
Auto-approve connections
|
||||||
|
<Switch
|
||||||
|
checked={allowAll}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
className={allowAll ? 'data-[state=checked]:bg-amber-500' : ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* hide with invisible absolute opacity-0*/}
|
||||||
|
<div className="group-hover/hub:visible group-hover/hub:opacity-100 transition-opacity duration-150 left-0 right-0 top-full z-10 pt-2">
|
||||||
|
<div className="relative p-2.5 rounded-md border border-border bg-background/60 backdrop-blur-md shadow-2xl text-muted-foreground text-xs leading-relaxed">
|
||||||
|
<div className="absolute -top-1.5 left-5 size-3 rotate-45 rounded-[1px] border-l border-t border-border bg-background/60 backdrop-blur-md" />
|
||||||
|
By default, each connection requires your approval before running tasks. <br />
|
||||||
|
Enable this to skip per-session approval.
|
||||||
|
<br />
|
||||||
|
<span className="font-semibold">* Use with caution!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtocolDocsCollapsible() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-semibold text-foreground/80 uppercase tracking-wider cursor-pointer"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
{open ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 space-y-4 text-xs text-muted-foreground">
|
||||||
|
<p className="text-[10px]">
|
||||||
|
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Flow</h4>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
|
||||||
|
<li>Hub opens WS to caller's server</li>
|
||||||
|
<li>
|
||||||
|
Sends <code className="text-[10px]">ready</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Caller sends <code className="text-[10px]">execute</code> with task
|
||||||
|
</li>
|
||||||
|
<li>Hub runs agent, streams events</li>
|
||||||
|
<li>
|
||||||
|
Hub sends <code className="text-[10px]">result</code> or{' '}
|
||||||
|
<code className="text-[10px]">error</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Caller → Hub</h4>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "execute", task: string, config?: object }
|
||||||
|
{ type: "stop" }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Hub → Caller</h4>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "ready" }
|
||||||
|
{ type: "result", success: boolean, data: string }
|
||||||
|
{ type: "error", message: string }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Hub WebSocket Protocol
|
||||||
|
*
|
||||||
|
* Hub connects as WS client to `ws://localhost:{port}`.
|
||||||
|
* All messages are JSON. One task at a time.
|
||||||
|
*
|
||||||
|
* Inbound (Caller → Hub):
|
||||||
|
* { type: "execute", task: string, config?: object }
|
||||||
|
* { type: "stop" }
|
||||||
|
*
|
||||||
|
* Outbound (Hub → Caller):
|
||||||
|
* { type: "ready" }
|
||||||
|
* { type: "result", success: boolean, data: string }
|
||||||
|
* { type: "error", message: string }
|
||||||
|
*/
|
||||||
|
import type { ExecutionResult } from '@page-agent/core'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { ExtConfig } from '@/agent/useAgent'
|
||||||
|
|
||||||
|
// --- Protocol types ---
|
||||||
|
|
||||||
|
interface ExecuteMessage {
|
||||||
|
type: 'execute'
|
||||||
|
task: string
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StopMessage {
|
||||||
|
type: 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboundMessage = ExecuteMessage | StopMessage
|
||||||
|
|
||||||
|
interface ReadyMessage {
|
||||||
|
type: 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultMessage {
|
||||||
|
type: 'result'
|
||||||
|
success: boolean
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorMessage {
|
||||||
|
type: 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutboundMessage = ReadyMessage | ResultMessage | ErrorMessage
|
||||||
|
|
||||||
|
export type HubWsState = 'connecting' | 'connected' | 'disconnected'
|
||||||
|
|
||||||
|
// --- HubWs class ---
|
||||||
|
|
||||||
|
export interface HubWsHandlers {
|
||||||
|
onExecute: (
|
||||||
|
task: string,
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
) => Promise<{ success: boolean; data: string }>
|
||||||
|
onStop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Framework-agnostic WebSocket client for Hub.
|
||||||
|
* Connects to an external WS server, receives tasks, dispatches to handlers,
|
||||||
|
* and sends results back. No React, no DOM.
|
||||||
|
*/
|
||||||
|
export class HubWs {
|
||||||
|
#ws: WebSocket | null = null
|
||||||
|
#state: HubWsState = 'disconnected'
|
||||||
|
#busy = false
|
||||||
|
#approved = false
|
||||||
|
#handlers: HubWsHandlers
|
||||||
|
#port: number
|
||||||
|
#onStateChange: (state: HubWsState) => void
|
||||||
|
|
||||||
|
constructor(port: number, handlers: HubWsHandlers, onStateChange: (state: HubWsState) => void) {
|
||||||
|
this.#port = port
|
||||||
|
this.#handlers = handlers
|
||||||
|
this.#onStateChange = onStateChange
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this.#state
|
||||||
|
}
|
||||||
|
|
||||||
|
get busy() {
|
||||||
|
return this.#busy
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.#ws) return
|
||||||
|
this.#setState('connecting')
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${this.#port}`)
|
||||||
|
this.#ws = ws
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
this.#setState('connected')
|
||||||
|
this.#send({ type: 'ready' })
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
this.#ws = null
|
||||||
|
this.#busy = false
|
||||||
|
this.#approved = false
|
||||||
|
this.#setState('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
this.#handleMessage(event.data as string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.#ws?.close()
|
||||||
|
this.#ws = null
|
||||||
|
this.#busy = false
|
||||||
|
this.#approved = false
|
||||||
|
this.#setState('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
#setState(state: HubWsState) {
|
||||||
|
if (this.#state === state) return
|
||||||
|
this.#state = state
|
||||||
|
this.#onStateChange(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#send(msg: OutboundMessage) {
|
||||||
|
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.#ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleMessage(raw: string) {
|
||||||
|
let msg: InboundMessage
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.#checkApproval())) {
|
||||||
|
this.#send({ type: 'error', message: 'User denied the connection request.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'execute':
|
||||||
|
this.#handleExecute(msg)
|
||||||
|
break
|
||||||
|
case 'stop':
|
||||||
|
this.#handlers.onStop()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #checkApproval(): Promise<boolean> {
|
||||||
|
if (this.#approved) return true
|
||||||
|
|
||||||
|
const { allowAllHubConnection } = await chrome.storage.local.get('allowAllHubConnection')
|
||||||
|
if (allowAllHubConnection === true) {
|
||||||
|
this.#approved = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
'An external application is requesting to control your browser via Page Agent Ext.\nAllow this session?'
|
||||||
|
)
|
||||||
|
if (ok) this.#approved = true
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleExecute(msg: ExecuteMessage) {
|
||||||
|
if (this.#busy) {
|
||||||
|
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#busy = true
|
||||||
|
try {
|
||||||
|
const result = await this.#handlers.onExecute(msg.task, msg.config)
|
||||||
|
this.#send({ type: 'result', success: result.success, data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
this.#send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
||||||
|
} finally {
|
||||||
|
this.#busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- React hook ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that bridges HubWs to the agent's execute/stop/configure.
|
||||||
|
* Handles the config-before-execute dance internally.
|
||||||
|
*/
|
||||||
|
export function useHubWs(
|
||||||
|
execute: (task: string) => Promise<ExecutionResult>,
|
||||||
|
stop: () => void,
|
||||||
|
configure: (config: ExtConfig) => Promise<void>,
|
||||||
|
config: ExtConfig | null
|
||||||
|
): { wsState: HubWsState } {
|
||||||
|
const wsPort = new URLSearchParams(location.search).get('ws')
|
||||||
|
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
||||||
|
const hubWsRef = useRef<HubWs | null>(null)
|
||||||
|
|
||||||
|
const latestRef = useRef({ execute, stop, configure, config })
|
||||||
|
useEffect(() => {
|
||||||
|
latestRef.current = { execute, stop, configure, config }
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wsPort) return
|
||||||
|
|
||||||
|
const hubWs = new HubWs(
|
||||||
|
Number(wsPort),
|
||||||
|
{
|
||||||
|
onExecute: async (task, incomingConfig) => {
|
||||||
|
const { execute, configure, config } = latestRef.current
|
||||||
|
if (incomingConfig) {
|
||||||
|
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||||
|
}
|
||||||
|
const result = await execute(task)
|
||||||
|
return { success: result.success, data: result.data }
|
||||||
|
},
|
||||||
|
onStop: () => latestRef.current.stop(),
|
||||||
|
},
|
||||||
|
setWsState
|
||||||
|
)
|
||||||
|
|
||||||
|
hubWs.connect()
|
||||||
|
hubWsRef.current = hubWs
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hubWs.disconnect()
|
||||||
|
hubWsRef.current = null
|
||||||
|
}
|
||||||
|
}, [wsPort])
|
||||||
|
|
||||||
|
return { wsState }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
|
||||||
|
<title>Page Agent Hub</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
import '@/assets/index.css'
|
||||||
|
|
||||||
|
const syncDarkMode = () => {
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncDarkMode()
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
|
|
||||||
|
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||||
|
|
||||||
|
export interface ExecuteConfig {
|
||||||
|
baseURL: string
|
||||||
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global system-level instructions for the agent.
|
||||||
|
* Equivalent to `AgentConfig.instructions.system`.
|
||||||
|
*/
|
||||||
|
systemInstruction?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
|
||||||
|
/** Control all unpinned tabs in the window instead of only the tab group. */
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
|
||||||
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineUnlistedScript(() => {
|
||||||
|
let _lastId = 0
|
||||||
|
function getId() {
|
||||||
|
_lastId += 1
|
||||||
|
return _lastId
|
||||||
|
}
|
||||||
|
|
||||||
|
const execute: Execute = async (task, config) => {
|
||||||
|
if (typeof task !== 'string') throw new Error('Task must be a string')
|
||||||
|
if (task.trim().length === 0) throw new Error('Task cannot be empty')
|
||||||
|
if (!config) throw new Error('Config is required')
|
||||||
|
if (!config.baseURL) throw new Error('Config must have a baseURL')
|
||||||
|
if (!config.model) throw new Error('Config must have a model')
|
||||||
|
|
||||||
|
const id = getId()
|
||||||
|
|
||||||
|
const promise = new Promise<ExecutionResult>((resolve, reject) => {
|
||||||
|
function handleMessage(e: MessageEvent) {
|
||||||
|
if (e.source !== window) return
|
||||||
|
|
||||||
|
const data = e.data
|
||||||
|
if (typeof data !== 'object' || data === null) return
|
||||||
|
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
|
||||||
|
if (data.id !== id) return
|
||||||
|
|
||||||
|
// events
|
||||||
|
|
||||||
|
if (data.action === 'status_change_event' && config.onStatusChange) {
|
||||||
|
config.onStatusChange(data.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'activity_event' && config.onActivity) {
|
||||||
|
config.onActivity(data.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'history_change_event' && config.onHistoryUpdate) {
|
||||||
|
config.onHistoryUpdate(data.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action !== 'execute_result') return
|
||||||
|
|
||||||
|
// execute_result
|
||||||
|
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
reject(new Error(data.error))
|
||||||
|
} else {
|
||||||
|
resolve(data.payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @note will be removed on dispose or result
|
||||||
|
window.addEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_REQUEST',
|
||||||
|
id,
|
||||||
|
action: 'execute',
|
||||||
|
payload: {
|
||||||
|
task,
|
||||||
|
config: {
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
model: config.model,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
systemInstruction: config.systemInstruction,
|
||||||
|
includeInitialTab: config.includeInitialTab,
|
||||||
|
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
const id = getId()
|
||||||
|
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
channel: 'PAGE_AGENT_EXT_REQUEST',
|
||||||
|
id,
|
||||||
|
action: 'stop',
|
||||||
|
},
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
;(window as any).PAGE_AGENT_EXT_VERSION = __VERSION__
|
||||||
|
;(window as any).PAGE_AGENT_EXT = {
|
||||||
|
version: __VERSION__,
|
||||||
|
execute,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { History, Send, Settings, Square } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { ConfigPanel } from '@/components/ConfigPanel'
|
||||||
|
import { HistoryDetail } from '@/components/HistoryDetail'
|
||||||
|
import { HistoryList } from '@/components/HistoryList'
|
||||||
|
import { ActivityCard, EventCard } from '@/components/cards'
|
||||||
|
import { EmptyState, Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupTextarea,
|
||||||
|
} from '@/components/ui/input-group'
|
||||||
|
import { saveSession } from '@/lib/db'
|
||||||
|
|
||||||
|
import { useAgent } from '../../agent/useAgent'
|
||||||
|
|
||||||
|
type View =
|
||||||
|
| { name: 'chat' }
|
||||||
|
| { name: 'config' }
|
||||||
|
| { name: 'history' }
|
||||||
|
| { name: 'history-detail'; sessionId: string }
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [view, setView] = useState<View>({ name: 'chat' })
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const historyRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||||
|
|
||||||
|
// Persist session when task finishes
|
||||||
|
const prevStatusRef = useRef(status)
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevStatusRef.current
|
||||||
|
prevStatusRef.current = status
|
||||||
|
|
||||||
|
if (
|
||||||
|
prev === 'running' &&
|
||||||
|
(status === 'completed' || status === 'error' || status === 'stopped') &&
|
||||||
|
history.length > 0 &&
|
||||||
|
currentTask
|
||||||
|
) {
|
||||||
|
saveSession({ task: currentTask, history, status }).catch((err) =>
|
||||||
|
console.error('[SidePanel] Failed to save session:', err)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [status, history, currentTask])
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new events
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyRef.current) {
|
||||||
|
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [history, activity])
|
||||||
|
|
||||||
|
const runTask = useCallback(
|
||||||
|
(task: string) => {
|
||||||
|
const normalizedTask = task.trim()
|
||||||
|
if (!normalizedTask || status === 'running') return
|
||||||
|
|
||||||
|
setInputValue('')
|
||||||
|
setView({ name: 'chat' })
|
||||||
|
|
||||||
|
execute(normalizedTask).catch((error) => {
|
||||||
|
console.error('[SidePanel] Failed to execute task:', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[execute, status]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e?: React.SyntheticEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
runTask(inputValue)
|
||||||
|
},
|
||||||
|
[inputValue, runTask]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
console.log('[SidePanel] Stopping task...')
|
||||||
|
stop()
|
||||||
|
}, [stop])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View routing ---
|
||||||
|
|
||||||
|
if (view.name === 'config') {
|
||||||
|
return (
|
||||||
|
<ConfigPanel
|
||||||
|
config={config}
|
||||||
|
onSave={async (newConfig) => {
|
||||||
|
await configure(newConfig)
|
||||||
|
setView({ name: 'chat' })
|
||||||
|
}}
|
||||||
|
onClose={() => setView({ name: 'chat' })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.name === 'history') {
|
||||||
|
return (
|
||||||
|
<HistoryList
|
||||||
|
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
||||||
|
onBack={() => setView({ name: 'chat' })}
|
||||||
|
onRerun={runTask}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.name === 'history-detail') {
|
||||||
|
return (
|
||||||
|
<HistoryDetail
|
||||||
|
sessionId={view.sessionId}
|
||||||
|
onBack={() => setView({ name: 'history' })}
|
||||||
|
onRerun={runTask}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat view ---
|
||||||
|
|
||||||
|
const isRunning = status === 'running'
|
||||||
|
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col h-screen bg-background">
|
||||||
|
<MotionOverlay active={isRunning} />
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Logo className="size-5" />
|
||||||
|
<span className="text-sm font-medium">Page Agent Ext</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<StatusDot status={status} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setView({ name: 'history' })}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label="History"
|
||||||
|
title="History"
|
||||||
|
>
|
||||||
|
<History className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setView({ name: 'config' })}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label="Settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Current task */}
|
||||||
|
{currentTask && (
|
||||||
|
<div className="border-b px-3 py-2 bg-muted/30">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
||||||
|
<div className="text-xs font-medium truncate" title={currentTask}>
|
||||||
|
{currentTask}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<div ref={historyRef} className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
{showEmptyState && <EmptyState />}
|
||||||
|
|
||||||
|
{history.map((event, index) => (
|
||||||
|
<EventCard key={index} event={event} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Activity indicator at bottom */}
|
||||||
|
{activity && <ActivityCard activity={activity} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<footer className="border-t p-3">
|
||||||
|
<InputGroup className="relative rounded-lg">
|
||||||
|
<InputGroupTextarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="Describe your task... (Enter to send)"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="text-xs pr-12 min-h-10"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end" className="absolute bottom-0 right-0">
|
||||||
|
{isRunning ? (
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleStop}
|
||||||
|
className="size-7"
|
||||||
|
aria-label="Stop task"
|
||||||
|
title="Stop task"
|
||||||
|
>
|
||||||
|
<Square className="size-3" />
|
||||||
|
</InputGroupButton>
|
||||||
|
) : (
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => handleSubmit()}
|
||||||
|
disabled={!inputValue.trim()}
|
||||||
|
className="size-7 cursor-pointer"
|
||||||
|
aria-label="Send"
|
||||||
|
title="Send"
|
||||||
|
>
|
||||||
|
<Send className="size-3" />
|
||||||
|
</InputGroupButton>
|
||||||
|
)}
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
|
||||||
|
<title>Page Agent</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
import '@/assets/index.css'
|
||||||
|
|
||||||
|
// Sync dark mode with system preference
|
||||||
|
const syncDarkMode = () => {
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncDarkMode()
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { HistoricalEvent } from '@page-agent/core'
|
||||||
|
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'
|
||||||
|
|
||||||
|
const DB_NAME = 'page-agent-ext'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
id: string
|
||||||
|
task: string
|
||||||
|
history: HistoricalEvent[]
|
||||||
|
status: 'completed' | 'error' | 'stopped'
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageAgentDB extends DBSchema {
|
||||||
|
sessions: {
|
||||||
|
key: string
|
||||||
|
value: SessionRecord
|
||||||
|
indexes: { 'by-created': number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<PageAgentDB>> | null = null
|
||||||
|
|
||||||
|
function getDB() {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<PageAgentDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
const store = db.createObjectStore('sessions', { keyPath: 'id' })
|
||||||
|
store.createIndex('by-created', 'createdAt')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(
|
||||||
|
session: Omit<SessionRecord, 'id' | 'createdAt'>
|
||||||
|
): Promise<SessionRecord> {
|
||||||
|
const db = await getDB()
|
||||||
|
const record: SessionRecord = {
|
||||||
|
...session,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
await db.put('sessions', record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List sessions, newest first */
|
||||||
|
export async function listSessions(): Promise<SessionRecord[]> {
|
||||||
|
const db = await getDB()
|
||||||
|
const all = await db.getAllFromIndex('sessions', 'by-created')
|
||||||
|
return all.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<SessionRecord | undefined> {
|
||||||
|
const db = await getDB()
|
||||||
|
return db.get('sessions', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.delete('sessions', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessions(): Promise<void> {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.clear('sessions')
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { HistoricalEvent } from '@page-agent/core'
|
||||||
|
|
||||||
|
const EXPORT_FILE_PREFIX = 'page-agent-history'
|
||||||
|
const MAX_TASK_SLUG_LENGTH = 40
|
||||||
|
|
||||||
|
export function serializeHistoryExport(history: HistoricalEvent[]): string {
|
||||||
|
return `${JSON.stringify(history, null, 2)}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHistoryExportFilename(task: string, createdAt: number): string {
|
||||||
|
const taskSlug = sanitizeTaskForFilename(task)
|
||||||
|
const timestamp = formatTimestampForFilename(createdAt)
|
||||||
|
|
||||||
|
return taskSlug
|
||||||
|
? `${EXPORT_FILE_PREFIX}-${taskSlug}-${timestamp}.json`
|
||||||
|
: `${EXPORT_FILE_PREFIX}-${timestamp}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadHistoryExport(
|
||||||
|
task: string,
|
||||||
|
createdAt: number,
|
||||||
|
history: HistoricalEvent[]
|
||||||
|
): void {
|
||||||
|
const filename = buildHistoryExportFilename(task, createdAt)
|
||||||
|
const content = serializeHistoryExport(history)
|
||||||
|
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTaskForFilename(task: string): string {
|
||||||
|
return task
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, MAX_TASK_SLUG_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestampForFilename(createdAt: number): string {
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = pad(date.getMonth() + 1)
|
||||||
|
const day = pad(date.getDate())
|
||||||
|
const hours = pad(date.getHours())
|
||||||
|
const minutes = pad(date.getMinutes())
|
||||||
|
const seconds = pad(date.getSeconds())
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value: number): string {
|
||||||
|
return value.toString().padStart(2, '0')
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user