├── .github └── workflows │ └── release.yml ├── .gitignore ├── .gitmodules ├── CNAME ├── LICENSE ├── PRIVACY.md ├── README.md ├── README_CN.md ├── background.js ├── content.js ├── icons ├── icon.iconset │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png ├── icon128.png ├── icon16.png └── icon48.png ├── index.html ├── lib ├── pdf.js └── pdf.worker.js ├── manifest.json ├── src ├── components │ ├── api-card.js │ ├── chat-container.js │ ├── chat-list.js │ ├── context-menu.js │ └── message-input.js ├── handlers │ └── message-handler.js ├── main.js ├── services │ └── chat.js └── utils │ ├── chat-manager.js │ ├── image.js │ ├── storage-adapter.js │ ├── theme.js │ ├── ui.js │ └── viewport.js ├── statics └── image.png ├── styles ├── base │ ├── reset.css │ └── variables.css ├── components │ ├── api-settings.css │ ├── chat-container.css │ ├── chat-list.css │ ├── code.css │ ├── context-menu.css │ ├── image-preview.css │ ├── image-tag.css │ ├── input.css │ ├── message.css │ ├── reasoning.css │ ├── settings.css │ └── sidebar.css ├── main.css └── utils │ └── animations.css └── vercel.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Extension 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # 当推送新的版本标签时触发 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | submodules: 'recursive' # 这会递归地检出所有子模块 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '16' 20 | 21 | - name: Create ZIP file 22 | run: | 23 | zip -r cerebr.zip . \ 24 | -x "*.git*" \ 25 | -x "*.github*" \ 26 | -x "*.DS_Store" \ 27 | -x "README*" 28 | 29 | - name: Create Release 30 | id: create_release 31 | uses: softprops/action-gh-release@v1 32 | with: 33 | files: cerebr.zip 34 | draft: false 35 | prerelease: false 36 | generate_release_notes: true 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .wrangler 3 | node_modules 4 | package-lock.json 5 | package.json -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "htmd"] 2 | path = htmd 3 | url = https://github.com/yym68686/htmd.git 4 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | cerebr.yym68686.top -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, the 15 | GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to your 20 | programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new free 27 | programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether gratis 35 | or for a fee, you must pass on to the recipients the same freedoms that 36 | you received. You must make sure that they, too, receive or can get the 37 | source code. And you must show them these terms so they know their 38 | rights. 39 | 40 | Developers that use the GNU General Public License protect your rights 41 | with two steps: (1) assert copyright on the software, and (2) offer you 42 | this License giving you legal permission to copy, distribute and/or 43 | modify it. 44 | 45 | For the developers' and authors' protection, the GPL clearly explains 46 | that there is no warranty for this free software. For both users' and 47 | authors' sake, the GPL requires that modified versions be marked as 48 | changed, so that their problems will not be attributed erroneously to 49 | authors of previous versions. 50 | 51 | Some devices are designed to deny users access to install or run 52 | modified versions of the software inside them, although the manufacturer 53 | can do so. This is fundamentally incompatible with the aim of 54 | protecting users' freedom to change the software. The systematic 55 | pattern of such abuse occurs in the area of products for individuals to 56 | use, which is precisely where it is most unacceptable. Therefore, we 57 | have designed this version of the GPL to prohibit the practice for those 58 | products. If such problems arise substantially in other domains, we 59 | stand ready to extend this provision to those domains in future versions 60 | of the GPL, as needed to protect the freedom of users. 61 | 62 | Finally, every program is threatened constantly by software patents. 63 | States should not allow patents to restrict development and use of 64 | software on general-purpose computers, but in those that do, we wish to 65 | avoid the special danger that patents applied to a free program could 66 | make it effectively proprietary. To prevent this, the GPL assures that 67 | patents cannot be used to render the program non-free. 68 | 69 | The precise terms and conditions for copying, distribution and 70 | modification follow. 71 | 72 | TERMS AND CONDITIONS 73 | 74 | 0. Definitions. 75 | 76 | "This License" refers to version 3 of the GNU General Public License. 77 | 78 | "Copyright" also means copyright-like laws that apply to other 79 | kinds of works, such as semiconductor masks. 80 | 81 | "The Program" refers to any copyrightable work licensed under 82 | this License. Each licensee is addressed as "you". "Licensees" and 83 | "recipients" may be individuals or organizations. 84 | 85 | To "modify" a work means to copy from or adapt all or part of the work 86 | in a fashion requiring copyright permission, other than the making of an 87 | exact copy. The resulting work is called a "modified version" of the 88 | earlier work or a work "based on" the earlier work. 89 | 90 | A "covered work" means either the unmodified Program or a work based 91 | on the Program. 92 | 93 | To "propagate" a work means to do anything with it that, without 94 | permission, would make you directly or secondarily liable for 95 | infringement under applicable copyright law, except executing it on a 96 | computer or modifying a private copy. Propagation includes copying, 97 | distribution (with or without modification), making available to the 98 | public, and in some countries other activities as well. 99 | 100 | To "convey" a work means any kind of propagation that enables other 101 | parties to make or receive copies. Mere interaction with a user through 102 | a computer network, with no transfer of a copy, is not conveying. 103 | 104 | An interactive user interface displays "Appropriate Legal Notices" 105 | to the extent that it includes a convenient and prominently visible 106 | feature that (1) displays an appropriate copyright notice, and (2) 107 | tells the user that there is no warranty for the work (except to the 108 | extent that warranties are provided), that licensees may convey the 109 | work under this License, and how to view a copy of this License. If 110 | the interface presents a list of user commands or options, such as a 111 | menu, a prominent item in the list meets this criterion. 112 | 113 | 1. Source Code. 114 | 115 | The "source code" for a work means the preferred form of the work 116 | for making modifications to it. "Object code" means any non-source 117 | form of a work. 118 | 119 | A "Standard Interface" means an interface that either is an official 120 | standard defined by a recognized standards body, or, in the case of 121 | interfaces specified for a particular programming language, one that is 122 | widely used among developers working in that language. 123 | 124 | The "System Libraries" of an executable work include anything, other 125 | than the work as a whole, that (a) is included in the normal form of 126 | packaging a Major Component, but which is not part of that Major 127 | Component, and (b) is installed by the installer or by the operating 128 | system as a consequence of the installation of the Major Component, but 129 | which is not otherwise copied. 130 | 131 | The "Corresponding Source" for a work in object code form means all 132 | the source code needed to regenerate, install, and (for an executable 133 | work) run the object code and to modify the work, including scripts to 134 | control those activities. However, it does not include the work's 135 | System Libraries, or general-purpose tools or generally available free 136 | programs which are used unmodified in performing those activities but 137 | which are not part of the work. For example, Corresponding Source 138 | includes interface definition files associated with source files for 139 | the work, and the source code for shared libraries and dynamically 140 | linked subprograms that the work is specifically designed to require, 141 | such as by intimate data communication or control flow between those 142 | subprograms and other parts of the work. 143 | 144 | The Corresponding Source need not include anything that users 145 | can regenerate automatically from other parts of the Corresponding 146 | Source. 147 | 148 | 2. Basic Permissions. 149 | 150 | All rights granted under this License are granted for the term of 151 | copyright on the Program, and are irrevocable provided the stated 152 | conditions are met. This License explicitly affirms your unlimited 153 | permission to run the unmodified Program. The output from running a 154 | covered work is covered by this License only if the output, given its 155 | content, constitutes a covered work. This License acknowledges your 156 | rights of fair use or other equivalent, as provided by copyright law. 157 | 158 | You may make, run and propagate covered works that you do not 159 | convey, without conditions so long as your license otherwise remains 160 | in force. You may convey covered works to others for the sole purpose 161 | of having them make modifications exclusively for you, or provide you 162 | with facilities for running those works, provided that you comply with 163 | every other obligation under this License. 164 | 165 | Conveying under any other circumstances is permitted solely under 166 | the conditions stated below. Sublicensing is not allowed; section 10 167 | makes it unnecessary. 168 | 169 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 170 | 171 | No covered work shall be deemed part of an effective technological 172 | measure under any applicable law fulfilling obligations under article 173 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 174 | similar laws prohibiting or restricting circumvention of such 175 | measures. 176 | 177 | When you convey a covered work, you waive any legal power to forbid 178 | circumvention of technological measures to the extent such circumvention 179 | is effected by exercising rights under this License with respect to 180 | the covered work, and you disclaim any intention to limit operation or 181 | modification of the work as a means of enforcing, against the work's 182 | users, your or third parties' legal rights to forbid circumvention of 183 | technological measures. 184 | 185 | 4. Conveying Verbatim Copies. 186 | 187 | You may convey verbatim copies of the Program's source code as you 188 | receive it, in any medium, provided that you conspicuously and 189 | appropriately publish on each copy an appropriate copyright notice; 190 | keep intact all notices stating that this License and any 191 | non-permissive terms added in accord with section 7 apply to the code; 192 | keep intact all notices of the absence of any warranty; and give all recipients 193 | a copy of this License along with the Program. 194 | 195 | You may not charge a fee for the Program except charging a 196 | convenience fee for later providing access to a copy. This does not 197 | involve other restrictions and is not considered a licensing fee. 198 | 199 | 5. Conveying Modified Source Versions. 200 | 201 | You may convey a work based on the Program, or the modifications to 202 | produce it from the Program, in the form of source code under the 203 | terms of section 4, provided that you also meet all of these conditions: 204 | 205 | a) The work must carry prominent notices stating that you modified 206 | it, and giving a relevant date. 207 | 208 | b) The work must carry prominent notices stating that it is 209 | released under this License and any conditions added under section 210 | 7. This requirement modifies the requirement in section 4 to 211 | "keep intact all notices". 212 | 213 | c) You must license the entire work, as a whole, under this License 214 | to anyone who comes into possession of a copy. This License will 215 | therefore apply, along with any applicable section 7 additional terms, 216 | to the whole of the work, and all its parts, regardless of how they 217 | are packaged. This License gives no permission to license the work 218 | in any other way, but it does not invalidate such permission if you 219 | have separately received it. 220 | 221 | d) If the work has interactive user interfaces, each must display 222 | Appropriate Legal Notices; however, if the Program has interactive 223 | interfaces that do not display Appropriate Legal Notices, your work 224 | need not make them do so. 225 | 226 | A compilation of a covered work with other separate and independent 227 | works, which are not by their nature extensions of the covered work, 228 | and which are not combined with it such as to form a larger program, 229 | in or on a volume of a storage or distribution medium, is called an 230 | "aggregate" if the compilation and its resulting copyright are not 231 | used to limit the access or legal rights of the compilation's users 232 | beyond what the individual works permit. Inclusion of a covered work 233 | in an aggregate does not cause this License to apply to the other 234 | parts of the aggregate. 235 | 236 | 6. Conveying Non-Source Forms. 237 | 238 | You may convey a covered work in object code form under the terms of 239 | sections 4 and 5, provided that you also convey the machine-readable 240 | Corresponding Source under the terms of this License, in one of these ways: 241 | 242 | a) Convey the object code in, or embodied in, a physical product 243 | (including a physical distribution medium), together with the 244 | Corresponding Source fixed on a durable physical medium 245 | customarily used for software interchange. 246 | 247 | b) Convey the object code in, or embodied in, a physical product 248 | (including a physical distribution medium), together with a 249 | written offer, valid for at least three years, to give anyone who 250 | possesses the object code a copy of the Corresponding Source for a 251 | charge no more than your cost of physically performing this 252 | conveying of copying. 253 | 254 | c) Convey individual copies of the object code with a copy of the 255 | written offer to provide the Corresponding Source. This 256 | alternative is allowed only occasionally and noncommercially, and 257 | only if you received the object code with such an offer, in accord 258 | with subsection 6b. 259 | 260 | d) Convey the object code by offering access from a designated 261 | place (gratis or for a charge), and offer equivalent access to the 262 | Corresponding Source in the same way through the same place at no 263 | further charge. You need not require recipients to copy the 264 | Corresponding Source along with the object code. If the place 265 | to copy the object code is a network server, the Corresponding Source 266 | may be on a different server (operated by you or a third party) 267 | that supports equivalent copying facilities, provided you maintain 268 | clear directions next to the object code saying where to find the 269 | Corresponding Source. Regardless of what server hosts the 270 | Corresponding Source, you remain obligated to ensure that it is 271 | available for as long as needed to satisfy these requirements. 272 | 273 | e) Convey the object code using peer-to-peer transmission, provided 274 | you inform other peers where the object code and Corresponding 275 | Source of the work are being offered to the general public at no 276 | charge under subsection 6d. 277 | 278 | A separable portion of the object code, whose source code is excluded 279 | from the Corresponding Source as a System Library, need not be 280 | included in conveying the object code work. 281 | 282 | A "User Product" is either (1) a "consumer product", which is any 283 | tangible personal property which is normally used for personal, family, 284 | or household purposes, or (2) anything designed or sold for incorporation 285 | into a dwelling. In determining whether a product is a consumer product, 286 | doubtful cases shall be resolved in favor of coverage. For a particular 287 | product received by a particular user, "normally used" refers to a 288 | typical or common use of that class of product, regardless of the status 289 | of the particular user or of the way in which the particular user 290 | actually uses, or expects or is expected to use, the product. The 291 | "System Libraries" of the product are the integral parts of the 292 | product. A product is not a consumer product if it is designed or sold 293 | for incorporation into a building or other dwelling. A "make 294 | available" means making the object code available to others in a 295 | manner that allows them to obtain a copy. 296 | 297 | 7. Additional Terms. 298 | 299 | "Additional permissions" are terms that supplement the terms of this 300 | License by making exceptions from one or more of its conditions. 301 | Additional permissions that are applicable to the entire Program shall 302 | be treated as though they were included in this License, to the extent 303 | that they are valid under applicable law. If additional permissions 304 | apply only to part of the Program, that part may be used separately 305 | under those permissions, but the entire Program remains governed by 306 | this License without regard to the additional permissions. 307 | 308 | When you convey a copy of a covered work, you may at your option 309 | remove any additional permissions from that copy, or from any part of 310 | it. (Additional permissions may be written to require their own 311 | removal in certain cases when you modify the work.) You may place 312 | additional permissions on material, added by you to a covered work, 313 | for which you have or can give appropriate copyright permission. 314 | 315 | Notwithstanding any other provision of this License, for material you 316 | add to a covered work, you may (if authorized by the copyright holders of 317 | that material) supplement the terms of this License with terms: 318 | 319 | a) Disclaiming warranty or limiting liability differently from the 320 | terms of sections 15 and 16 of this License; or 321 | 322 | b) Requiring preservation of specified reasonable legal notices or 323 | author attributions in that material or in the Appropriate Legal 324 | Notices displayed by works containing it; or 325 | 326 | c) Prohibiting misrepresentation of the origin of that material, or 327 | requiring that modified versions of such material be marked in 328 | reasonable ways as different from the original version; or 329 | 330 | d) Limiting the use for publicity purposes of names of licensors or 331 | authors of the material; or 332 | 333 | e) Declining to grant rights under trademark law for use of some 334 | trade names, trademarks, or service marks; or 335 | 336 | f) Requiring indemnification of licensors and authors of that 337 | material by anyone who conveys the material (or modified versions of 338 | it) with contractual assumptions of liability to the recipient, for 339 | any liability that these contractual assumptions directly impose on 340 | those licensors and authors. 341 | 342 | All other non-permissive additional terms are considered "further 343 | restrictions" within the meaning of section 10. If the Program as you 344 | received it, or any part of it, contains a notice stating that it is 345 | governed by this License along with a term that is a further 346 | restriction, you may remove that term. If a license document contains 347 | a further restriction but permits relicensing or conveying under this 348 | License, you may add to a covered work material governed by the terms 349 | of that license document any terms that disclaim, or limit, liability for 350 | certain types of damages, the extent of which is incalculable under 351 | applicable law. 352 | 353 | 8. Termination. 354 | 355 | You may not propagate or modify a covered work except as expressly 356 | provided under this License. Any attempt otherwise to propagate or 357 | modify it is void, and will automatically terminate your rights under 358 | this License (including any patent licenses granted under the third 359 | party provisions of section 11). 360 | 361 | However, if you cease all violation of this License, then your 362 | license from a particular copyright holder is reinstated (a) 363 | provisionally, unless and until such holder explicitly and finally 364 | terminates your license, and (b) permanently, if the copyright holder 365 | so chooses, and if you meet the conditions of reinstatement. 366 | 367 | Termination of your rights under this section does not terminate the 368 | licenses of parties who have received copies or rights from you under 369 | this License. If your rights have been terminated and not permanently 370 | reinstated, you do not qualify to receive new licenses for the same 371 | material under section 10. 372 | 373 | 9. Acceptance Not Required for Having Copies. 374 | 375 | You are not required to accept this License in order to receive or 376 | run a copy of the Program. Ancillary propagation of a covered work 377 | occurring solely as a consequence of using peer-to-peer transmission 378 | to receive a copy likewise does not require acceptance. However, 379 | nothing other than this License grants you permission to propagate or 380 | modify any covered work. These actions infringe copyright if you do not 381 | accept this License. Therefore, by modifying or propagating a 382 | covered work, you indicate your acceptance of this License to do so. 383 | 384 | 10. Automatic Licensing of Downstream Recipients. 385 | 386 | Each time you convey a covered work, the recipient automatically 387 | receives a license from the original licensors, to run, modify and 388 | propagate that work, subject to this License. You are not responsible 389 | for enforcing compliance by third parties with this License. 390 | 391 | An "entity transaction" is a transaction transferring control of an 392 | organization, or substantially all assets of one, or subdividing an 393 | organization, or merging organizations. If such an entity transaction 394 | occurs, each party to the transaction that receives a copy of the 395 | Program also receives whatever licenses to the Program the party 396 | held before the transaction. You may not impose any further 397 | restrictions on the exercise of the rights granted or affirmed under 398 | this License. 399 | 400 | 11. Patents. 401 | 402 | A "contributor" is a copyright holder who authorizes use of the 403 | Program under this License. The work thus licensed is called the 404 | contributor's "contributor version". 405 | Contributors provide an express grant of patent rights under this 406 | License for their contributions, which is, unless explicitly stated 407 | otherwise, a non-exclusive, worldwide, royalty-free patent license to 408 | make, use, sell, offer for sale, import, and otherwise run, modify and 409 | propagate the contributions. The patent license covers the act of 410 | contributing the work, as well as the practice of making, using, selling, 411 | offering for sale, importing, and propagating the work. In the case 412 | of a contributor's essential patent claims, such a license is granted 413 | for the term of any patent infringement litigation, but only to the 414 | extent necessary to protect the exercise of the rights granted herein. 415 | 416 | Each contributor grants you a non-exclusive, worldwide, 417 | royalty-free patent license under their essential patent claims, to 418 | make, use, sell, offer for sale, import, and otherwise run, modify and 419 | propagate the contributions. In this section, as used in the context 420 | of this License, "patent" also includes any patents that might be 421 | considered to cover extensions of the contributed work by the 422 | contributor, but not necessarily so. 423 | 424 | In the event you institute patent litigation (including a cross- 425 | claim or counterclaim in a lawsuit) alleging that the Program 426 | itself (excluding combinations of it with other software or hardware) 427 | constitutes patent infringement, then your license from each 428 | contributor of the Program terminates, and you 429 | automatically grant a royalty-free license to any party receiving a copy 430 | of the Program from you. 431 | 432 | 11a. Additional Patent Terms for Contributor Submissions. 433 | Notwithstanding any other provision of this License, for any 434 | contribution you may have an obligation to grant, and such work may be 435 | subject to, additional patent licenses. These additional patent 436 | licenses for a contribution extend only to that contribution and are 437 | promulgated separately from the rest of the license. 438 | 439 | 12. No Surrender of Others' Freedom. 440 | 441 | If conditions are imposed on you (whether by court order, agreement or 442 | otherwise) that contradict the conditions of this License, they do not 443 | excuse you from the conditions of this License. If you cannot 444 | distribute a covered work so as to satisfy simultaneously your obligations 445 | under this License and any other pertinent obligations, then as a 446 | consequence you may not distribute the work at all. For example, if you 447 | agree to terms that obligate you to collect a royalty for further 448 | distribution of the work, the only way you could satisfy both those terms 449 | and this License would be to refrain entirely from distribution of the 450 | work. 451 | 452 | 13. Use with the GNU Affero General Public License. 453 | 454 | Notwithstanding any other provision of this License, you have 455 | permission to link or combine any covered work with a work licensed 456 | under the GNU Affero General Public License into a single combined work, 457 | and to convey the resulting work. The terms of this License will continue 458 | to apply to the part which is the covered work, but the special terms of 459 | the GNU Affero General Public License, section 13, concerning 460 | interaction through a network will apply to the combination as such. 461 | 462 | 14. Revised Versions of this License. 463 | 464 | The Free Software Foundation may publish revised and/or new versions of 465 | the GNU General Public License from time to time. Such new versions will 466 | be similar in spirit to the present version, but may differ in detail to 467 | address new problems or concerns. 468 | 469 | Each version is given a distinguishing version number. If the Program 470 | specifies a version number of this License which applies to it and "any 471 | later version", you have the option of following the terms and conditions 472 | of either that version or any later version published by the Free 473 | Software Foundation. If the Program does not specify a version number of 474 | this License, you may choose any version ever published by the Free Software 475 | Foundation. 476 | 477 | 15. Disclaimer of Warranty. 478 | 479 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 480 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 481 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 482 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 483 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 484 | PURPOSE. EACH PARTY'S ENTIRE LIABILITY, IN ANY CASE, SHALL BE LIMITED TO 485 | THE AMOUNT PAID BY YOU FOR THE PROGRAM (IF ANY). 486 | 487 | 16. Limitation of Liability. 488 | 489 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS 490 | LICENSE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL 491 | DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR 492 | PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS 493 | ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS 494 | LICENSE OR THE PROGRAM. 495 | 496 | 17. Interpretation of Sections 15 and 16. 497 | 498 | If the disclaimer of warranty and limitation of liability provided in 499 | this License cannot be given local legal effect according to their terms, 500 | reviewing courts shall apply local law that most closely approximates an 501 | absolute waiver of all civil liability in connection with the Program, 502 | unless a warranty or assumption of liability accompanies a copy of the 503 | Program. 504 | 505 | END OF TERMS AND CONDITIONS 506 | 507 | How to Apply These Terms to Your New Programs 508 | 509 | If you develop a new program, and you want it to be of the greatest 510 | possible use to the public, the best way to achieve this is to make it 511 | free software which everyone can redistribute and change under these terms. 512 | 513 | To do so, attach the following notices to the program. It is safest 514 | to attach them to the start of each source file to most effectively 515 | inform other developers of your freedom to modify and share your 516 | program: 517 | 518 | 519 | Copyright (C) [year] [name of author] 520 | 521 | This program is free software: you can redistribute it and/or modify 522 | it under the terms of the GNU General Public License as published by 523 | the Free Software Foundation, either version 3 of the License, or 524 | (at your option) any later version. 525 | 526 | This program is distributed in the hope that it will be useful, 527 | but WITHOUT ANY WARRANTY; without even the implied warranty of 528 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 529 | GNU General Public License for more details. 530 | 531 | You should have received a copy of the GNU General Public License 532 | along with this program. If not, see . 533 | 534 | Also add information on how to contact you by electronic and paper mail. 535 | 536 | If the program does terminal interaction, make it output a short 537 | notice like this when it starts in an interactive mode: 538 | 539 | [Program Name] [Version] 540 | Copyright (C) [year] [name of author] 541 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 542 | This is free software, and you are welcome to redistribute it 543 | under certain conditions; type `show c' for details. 544 | 545 | You should also get your employer (if you work as a programmer) or your 546 | college, university or other institution, if any, to sign a "copyright 547 | assignment" for any copyright interest in the program, in order to 548 | contribute to the communal effort. 549 | 550 | The precise form of the notices is useful for indicating which 551 | parts of a program are free software and which parts are not. For 552 | example, if your program is a library, you should make it clear that 553 | the library is free software and that users can combine it with other 554 | modules to produce an executable, regardless of the license terms of 555 | these other modules. See GNU General Public License for more details. -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Cerebr 隐私权政策 2 | 3 | *最后更新日期:2024年2月* 4 | 5 | ## 1. 引言 6 | 7 | 欢迎使用 Cerebr("我们","我们的"或"本扩展")。本隐私权政策旨在说明我们如何收集、使用、存储和保护您的信息。使用我们的服务即表示您同意本隐私权政策中描述的数据处理程序。 8 | 9 | ## 2. 信息收集 10 | 11 | ### 2.1 主动提供的信息 12 | - API密钥和配置信息 13 | - 用户偏好设置(如界面主题、快捷键设置等) 14 | - 聊天记录和对话内容 15 | 16 | ### 2.2 自动收集的信息 17 | - 基本的使用统计信息 18 | - 错误日志(用于改进服务质量) 19 | - 当前访问的网页URL(仅在您主动使用网页问答功能时) 20 | 21 | ## 3. 信息使用 22 | 23 | 我们收集的信息将用于: 24 | - 提供和改进我们的AI助手服务 25 | - 保存您的个性化设置 26 | - 维护和优化扩展程序性能 27 | - 排查技术问题 28 | - 改进用户体验 29 | 30 | ## 4. 数据存储 31 | 32 | ### 4.1 本地存储 33 | - 所有用户数据主要存储在您的本地浏览器中 34 | - API密钥和配置信息使用Chrome的安全存储机制 35 | - 聊天历史记录保存在本地存储中 36 | 37 | ### 4.2 第三方服务 38 | - 当您使用AI功能时,您的查询将被发送到您配置的AI服务提供商(如OpenAI、Anthropic等) 39 | - 我们不会存储或转发您的API密钥到除您指定的服务提供商之外的任何第三方 40 | 41 | ## 5. 数据安全 42 | 43 | 我们采取以下措施保护您的数据: 44 | - 所有敏感数据(如API密钥)均使用安全的加密存储 45 | - 仅在必要时访问网页内容 46 | - 不收集或存储任何个人身份信息 47 | - 不与第三方共享用户数据 48 | 49 | ## 6. 用户权利 50 | 51 | 您拥有以下权利: 52 | - 随时查看和删除您的聊天历史 53 | - 清除所有存储的数据 54 | - 修改或删除您的API配置 55 | - 选择是否启用特定功能 56 | 57 | ## 7. 数据删除 58 | 59 | 您可以通过以下方式删除您的数据: 60 | - 使用扩展程序内的"清除数据"功能 61 | - 在Chrome扩展管理页面中删除扩展程序 62 | - 使用快捷键(Windows: Alt+X / Mac: Ctrl+X)清空聊天记录 63 | 64 | ## 8. 政策更新 65 | 66 | 我们可能会不时更新本隐私权政策。当我们进行重大更改时,我们将通过扩展程序通知您。继续使用我们的服务即表示您接受更新后的政策。 67 | 68 | ## 9. 联系我们 69 | 70 | 如果您对本隐私权政策有任何问题或建议,请通过以下方式联系我们: 71 | - GitHub Issues: https://github.com/yym68686/Cerebr/issues 72 | - 电子邮件:yym68686@outlook.com 73 | 74 | ## 10. 合规性声明 75 | 76 | 本扩展程序遵守: 77 | - Chrome网上应用店开发者计划政策 78 | - 通用数据保护条例(GDPR) 79 | - 加州消费者隐私法案(CCPA) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Chrome Web Store 8 | 9 |

10 | 11 | [English](./README.md) | [Simplified Chinese](./README_CN.md) 12 | 13 | # 🧠 Cerebr - Intelligent AI Assistant 14 | 15 | ![screenshot](./statics/image.png) 16 | 17 | The name "Cerebr" comes from a Latin root related to "brain" or "cerebrum". This etymology reflects our vision: to integrate powerful AI capabilities from Claude, OpenAI, and others, making Cerebr your second brain for deep reading and understanding. Cerebr is a powerful Chrome browser AI assistant extension focused on enhancing your work efficiency and learning experience. 18 | 19 | Born from a need for a clean, efficient browser AI assistant, Cerebr stands out with its minimalist design and powerful features. While other solutions often come with limitations or cluttered interfaces, Cerebr focuses on delivering a seamless, distraction-free experience for your web browsing needs. 20 | 21 | ## ✨ Core Features 22 | 23 | - 🎯 **Smart Sidebar** - Quick access via hotkey (Windows: `Alt+Z` / Mac: `Ctrl+Z`) to chat with AI anytime, anywhere 24 | - 🔄 **Multiple API Support** - Configure multiple APIs to flexibly switch between different AI assistants 25 | - 🔁 **Config Sync** - Cross-browser API configuration synchronization for seamless device switching 26 | - 📝 **Comprehensive Q&A** - Support webpage content Q&A, PDF document Q&A, image Q&A and more 27 | - 🎨 **Elegant Rendering** - Perfect support for Markdown text rendering and LaTeX math formula display 28 | - ⚡ **Real-time Response** - Stream output for instant AI replies 29 | - ⏹️ **Flexible Control** - Support stopping generation at any time, sending new messages will stop the current generation 30 | - 🌓 **Theme Switching** - Support light/dark themes to protect your eyes 31 | - 🌐 **Web Version** - Support web version, no installation required, accessable from any browser, support vercel, GitHub Pages and cloudflare pages deployment 32 | 33 | ## 🛠️ Technical Features 34 | 35 | - 💾 **State Persistence** - Automatically save chat history, sidebar status, etc. 36 | - 🔄 **Config Sync** - Cross-device configuration sharing through browser's native sync API 37 | - 🔍 **Smart Extraction** - Automatically identify and extract webpage/PDF content 38 | - ⌨️ **Shortcut Operations** - Support hotkey to clear chat (Windows: `Alt+X` / Mac: `Ctrl+X`), up/down keys for quick history recall 39 | - 🔒 **Secure & Reliable** - Support multiple API key management with local data storage 40 | - 🎭 **High Compatibility** - Support mainstream browsers, adapt to various webpage environments 41 | 42 | ## 🎮 User Guide 43 | 44 | 1. 🔑 **Configure API** 45 | - Click the settings button 46 | - Fill in API Key, Base URL and model name 47 | - Support adding multiple API configurations 48 | 49 | 2. 💬 **Start Chatting** 50 | - Use hotkey Windows: `Alt+Z` / Mac: `Ctrl+Z` to summon sidebar 51 | - Input questions and send 52 | - Support image upload for visual Q&A 53 | 54 | 3. 📚 **Webpage/PDF Q&A** 55 | - Enable webpage Q&A switch 56 | - Automatically identify and extract current page content 57 | - Support intelligent PDF file Q&A 58 | 59 | ## 🔧 Advanced Features 60 | 61 | - 📋 **Right-click Copy** - Support right-click to directly copy message text 62 | - 🔄 **History Records** - Use up/down arrow keys to quickly recall historical questions 63 | - ⏹️ **Stop Generation** - Show stop button when generating messages, can stop generation at any time 64 | - 🖼️ **Image Preview** - Click images to view full size 65 | - ⚙️ **Custom Settings** - Support customizing hotkeys, themes and more 66 | 67 | ## 🚀 Web Version Deploy 68 | 69 | 1. You can quickly deploy the web version of Cerebr to Vercel with one click: 70 | 71 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyym68686%2Fcerebr) 72 | 73 | 2. You can deploy to Cloudflare Pages: 74 | 75 | ```bash 76 | # Install Wrangler CLI 77 | npm install -g wrangler 78 | 79 | # Login to Cloudflare 80 | wrangler login 81 | 82 | # Deploy to Cloudflare Pages with SSL configuration 83 | wrangler pages deploy . --project-name cerebr --branch main 84 | ``` 85 | 86 | 3. You can also deploy to GitHub Pages: 87 | 88 | ```bash 89 | # Fork this repository 90 | # Then go to your repository's Settings -> Pages 91 | # In the "Build and deployment" section: 92 | # - Select "Deploy from a branch" as Source 93 | # - Choose your branch (main/master) and root (/) folder 94 | # - Click Save 95 | ``` 96 | 97 | The deployment will be automatically handled by GitHub Actions. You can access your site at `https://.github.io/cerebr` 98 | 99 | ### Web Version Features 100 | - 🌐 Access Cerebr from any browser without installation 101 | - 💻 Same powerful features as the Chrome extension 102 | - ☁️ Deploy your own instance for better control 103 | - 🔒 Secure and private deployment 104 | 105 | ## 📦 Desktop Application 106 | 107 | After installing the dmg file, you need to execute the following command: 108 | 109 | ```bash 110 | sudo xattr -r -d com.apple.quarantine /Applications/Cerebr.app 111 | ``` 112 | 113 | This project uses Pake to pack the dmg file, the command is as follows: 114 | 115 | ```bash 116 | iconutil -c icns icon.iconset 117 | pake https://xxx/ --name Cerebr --hide-title-bar --icon ./icon.icns 118 | ``` 119 | 120 | https://github.com/tw93/Pake 121 | 122 | ## 🚀 Latest Updates 123 | 124 | - 🆕 Added image Q&A functionality 125 | - 🔄 Optimized webpage content extraction algorithm 126 | - 🐛 Fixed math formula rendering issues 127 | - ⚡ Improved overall performance and stability 128 | 129 | ## 📝 Development Notes 130 | 131 | This project is developed using Chrome Extension Manifest V3, with main tech stack: 132 | 133 | - 🎨 Native JavaScript + CSS 134 | - 📦 Chrome Extension API 135 | - 🔧 PDF.js + KaTeX + Marked.js 136 | 137 | ## 🤝 Contribution Guide 138 | 139 | Welcome to submit Issues and Pull Requests to help improve the project. Before submitting, please ensure: 140 | 141 | - 🔍 You have searched related issues 142 | - ✅ Follow existing code style 143 | - 📝 Provide clear description and reproduction steps 144 | 145 | ## 📄 License 146 | 147 | This project is licensed under the GPLv3 License -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Chrome Web Store 8 | 9 |

10 | 11 | [English](./README.md) | [简体中文](./README_CN.md) 12 | 13 | # 🧠 Cerebr - 智能 AI 助手 14 | 15 | ![screenshot](./statics/image.png) 16 | 17 | Cerebr 是一款强大的 Chrome 浏览器 AI 助手扩展,专注于提升您的工作效率和学习体验。"Cerebr"源自拉丁语词根,与"大脑"或"脑"相关。这个命名体现了我们的愿景:整合 Claude、OpenAI 等 AI 的强大能力,使 Cerebr 成为您的第二大脑,为您提供深度阅读和理解支持。 18 | 19 | 在尝试了市面上现有的浏览器 AI 助手后,我们发现它们要么有使用次数限制,要么界面过于花哨。Cerebr 应运而生,专注于提供一个简洁、高效、无干扰的 AI 助手体验。 20 | 21 | ## ✨ 核心特性 22 | 23 | - 🎯 **智能侧边栏** - 通过快捷键(Windows: `Alt+Z` / Mac: `Ctrl+Z`)快速唤出,随时随地与 AI 对话 24 | - 🔄 **多 API 支持** - 支持配置多个 API,灵活切换不同的 AI 助手 25 | - 🔁 **配置同步** - 支持跨浏览器的 API 配置同步,轻松在不同设备间共享设置 26 | - 📝 **全能问答** - 支持网页内容问答、PDF 文档问答、图片问答等多种场景 27 | - 🎨 **优雅渲染** - 完美支持 Markdown 文本渲染、LaTeX 数学公式显示 28 | - ⚡ **实时响应** - 采用流式输出,即时获取 AI 回复 29 | - ⏹️ **灵活控制** - 支持在生成过程中随时停止,发送新消息自动停止当前生成 30 | - 🌓 **主题切换** - 支持浅色/深色主题,呵护您的眼睛 31 | - 🌐 **网页版** - 支持网页版,无需安装,通过任何浏览器访问,支持 vercel、GitHub Pages 和 cloudflare pages 部署 32 | 33 | ## 🛠️ 技术特性 34 | 35 | - 💾 **状态持久化** - 自动保存对话历史、侧边栏状态等 36 | - 🔄 **配置同步** - 支持通过浏览器原生同步API实现跨设备配置共享 37 | - 🔍 **智能提取** - 自动识别并提取网页/PDF 内容 38 | - ⌨️ **快捷操作** - 支持快捷键清空聊天(Windows: `Alt+X` / Mac: `Ctrl+X`)、上下键快速调用历史问题 39 | - 🔒 **安全可靠** - 支持多 API Key 管理,数据本地存储 40 | - 🎭 **兼容性强** - 支持主流浏览器,适配各类网页环境 41 | 42 | ## 🎮 使用指南 43 | 44 | 1. 🔑 **配置 API** 45 | - 点击设置按钮 46 | - 填写 API Key、Base URL 和模型名称 47 | - 支持添加多个 API 配置 48 | 49 | 2. 💬 **开始对话** 50 | - 使用快捷键 Windows: `Alt+Z` / Mac: `Ctrl+Z` 唤出侧边栏 51 | - 输入问题并发送 52 | - 支持图片上传进行图像问答 53 | 54 | 3. 📚 **网页/PDF 问答** 55 | - 开启网页问答开关 56 | - 自动识别并提取当前页面内容 57 | - 支持 PDF 文件智能问答 58 | 59 | ## 🔧 高级功能 60 | 61 | - 📋 **右键复制** - 支持右键直接复制消息文本 62 | - 🔄 **历史记录** - 使用上下方向键快速调用历史问题 63 | - ⏹️ **停止生成** - 在生成消息时右键显示停止按钮,可随时中断生成 64 | - 🖼️ **图片预览** - 点击图片可查看大图 65 | - ⚙️ **自定义配置** - 支持自定义快捷键、主题等设置 66 | 67 | ## 🚀 网页版部署 68 | 69 | 1. 你可以一键将 Cerebr 的 Web 版本部署到 Vercel: 70 | 71 | [![使用 Vercel 部署](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyym68686%2Fcerebr) 72 | 73 | 2. 你可以部署到 Cloudflare Pages: 74 | 75 | ```bash 76 | # 安装 Wrangler CLI 77 | npm install -g wrangler 78 | 79 | # 登录 Cloudflare 80 | wrangler login 81 | 82 | # 部署到 Cloudflare Pages(带 SSL 配置) 83 | wrangler pages deploy . --project-name cerebr --branch main 84 | ``` 85 | 86 | 3. 你也可以部署到 GitHub Pages: 87 | 88 | ```bash 89 | # Fork 这个仓库 90 | # 然后进入你的仓库的 Settings -> Pages 91 | # 在"构建和部署"部分: 92 | # - 将"Source"选择为"Deploy from a branch" 93 | # - 选择你的分支(main/master)和根目录(/) 94 | # - 点击保存 95 | ``` 96 | 97 | 部署将由 GitHub Actions 自动处理。你可以通过 `https://<你的用户名>.github.io/cerebr` 访问你的站点 98 | 99 | ### Web 版本特点 100 | - 🌐 无需安装,通过任何浏览器访问 101 | - 💻 与 Chrome 扩展版本具有相同的强大功能 102 | - ☁️ 部署自己的实例以获得更好的控制 103 | - 🔒 安全私密的部署方案 104 | 105 | ## mac 桌面应用 106 | 107 | 安装 dmg 后,需要执行以下命令: 108 | 109 | ```bash 110 | sudo xattr -r -d com.apple.quarantine /Applications/Cerebr.app 111 | ``` 112 | 113 | 本项目使用 Pake 打包,打包命令如下: 114 | 115 | ```bash 116 | iconutil -c icns icon.iconset 117 | pake https://xxx/ --name Cerebr --hide-title-bar --icon ./icon.icns 118 | ``` 119 | 120 | https://github.com/tw93/Pake 121 | 122 | ## 🚀 最新更新 123 | 124 | - 🆕 支持图片问答功能 125 | - 🔄 优化网页内容提取算法 126 | - 🐛 修复数学公式渲染问题 127 | - ⚡ 提升整体性能和稳定性 128 | 129 | ## 📝 开发说明 130 | 131 | 本项目采用 Chrome Extension Manifest V3 开发,主要技术栈: 132 | 133 | - 🎨 原生 JavaScript + CSS 134 | - 📦 Chrome Extension API 135 | - 🔧 PDF.js + KaTeX + Marked.js 136 | 137 | ## 🤝 贡献指南 138 | 139 | 欢迎提交 Issue 和 Pull Request 来帮助改进项目。在提交之前,请确保: 140 | 141 | - 🔍 已经搜索过相关的 Issue 142 | - ✅ 遵循现有的代码风格 143 | - 📝 提供清晰的描述和复现步骤 144 | 145 | ## 📄 许可证 146 | 147 | 本项目采用 GPLv3 许可证 -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // 确保 Service Worker 立即激活 2 | self.addEventListener('install', (event) => { 3 | console.log('Service Worker 安装中...', new Date().toISOString()); 4 | event.waitUntil(self.skipWaiting()); 5 | }); 6 | 7 | self.addEventListener('activate', (event) => { 8 | // console.log('Service Worker 已激活', new Date().toISOString()); 9 | event.waitUntil(self.clients.claim()); 10 | }); 11 | 12 | // 添加启动日志 13 | // console.log('Background script loaded at:', new Date().toISOString()); 14 | 15 | function checkCustomShortcut(callback) { 16 | chrome.commands.getAll((commands) => { 17 | const toggleCommand = commands.find(command => command.name === '_execute_action' || command.name === '_execute_browser_action'); 18 | if (toggleCommand && toggleCommand.shortcut) { 19 | console.log('当前设置的快捷键:', toggleCommand.shortcut); 20 | // 直接获取最后一个字符并转换为小写 21 | const lastLetter = toggleCommand.shortcut.charAt(toggleCommand.shortcut.length - 1).toLowerCase(); 22 | callback(lastLetter); 23 | } 24 | }); 25 | } 26 | 27 | // 重新注入 content script 并等待连接 28 | async function reinjectContentScript(tabId) { 29 | console.log('标签页未连接,尝试重新注入 content script...'); 30 | try { 31 | await chrome.scripting.executeScript({ 32 | target: { tabId }, 33 | files: ['content.js'] 34 | }); 35 | console.log('已重新注入 content script'); 36 | // 给脚本一点时间初始化 37 | await new Promise(resolve => setTimeout(resolve, 500)); 38 | const isConnected = await isTabConnected(tabId); 39 | if (!isConnected) { 40 | console.log('重新注入后仍未连接'); 41 | } 42 | return isConnected; 43 | } catch (error) { 44 | console.error('重新注入 content script 失败:', error); 45 | return false; 46 | } 47 | } 48 | 49 | // 处理标签页连接和消息发送的通用函数 50 | async function handleTabCommand(commandType) { 51 | try { 52 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 53 | if (!tab) { 54 | console.log('没有找到活动标签页'); 55 | return; 56 | } 57 | 58 | // 检查标签页是否已连接 59 | const isConnected = await isTabConnected(tab.id); 60 | if (!isConnected && await reinjectContentScript(tab.id)) { 61 | await chrome.tabs.sendMessage(tab.id, { type: commandType }); 62 | return; 63 | } 64 | 65 | if (isConnected) { 66 | await chrome.tabs.sendMessage(tab.id, { type: commandType }); 67 | } 68 | } catch (error) { 69 | console.error(`处理${commandType}命令失败:`, error); 70 | } 71 | } 72 | 73 | // 监听扩展图标点击 74 | chrome.action.onClicked.addListener(async (tab) => { 75 | console.log('扩展图标被点击'); 76 | try { 77 | // 检查标签页是否已连接 78 | const isConnected = await isTabConnected(tab.id); 79 | if (!isConnected && await reinjectContentScript(tab.id)) { 80 | await chrome.tabs.sendMessage(tab.id, { type: 'TOGGLE_SIDEBAR_onClicked' }); 81 | return; 82 | } 83 | 84 | if (isConnected) { 85 | await chrome.tabs.sendMessage(tab.id, { type: 'TOGGLE_SIDEBAR_onClicked' }); 86 | } 87 | } catch (error) { 88 | console.error('处理切换失败:', error); 89 | } 90 | }); 91 | 92 | // 简化后的命令监听器 93 | chrome.commands.onCommand.addListener(async (command) => { 94 | console.log('onCommand:', command); 95 | 96 | if (command === 'toggle_sidebar') { 97 | await handleTabCommand('TOGGLE_SIDEBAR_toggle_sidebar'); 98 | } else if (command === 'new_chat') { 99 | await handleTabCommand('NEW_CHAT'); 100 | } 101 | }); 102 | 103 | // 创建一个持久连接 104 | let port = null; 105 | chrome.runtime.onConnect.addListener((p) => { 106 | // console.log('建立持久连接'); 107 | port = p; 108 | port.onDisconnect.addListener(() => { 109 | // console.log('连接断开,尝试重新连接', p.sender.tab.id, p.sender.tab.url); 110 | port = null; 111 | }); 112 | }); 113 | 114 | // 监听来自 content script 的消息 115 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 116 | // console.log('收到消息:', message, '来自:', sender.tab?.id); 117 | 118 | if (message.type === 'CONTENT_LOADED') { 119 | // console.log('内容脚本已加载:', message.url); 120 | sendResponse({ status: 'ok', timestamp: new Date().toISOString() }); 121 | return false; 122 | } 123 | 124 | // 检查标签页是否活跃 125 | if (message.type === 'CHECK_TAB_ACTIVE') { 126 | (async () => { 127 | try { 128 | const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); 129 | if (!activeTab) { 130 | sendResponse(false); 131 | return; 132 | } 133 | sendResponse(sender.tab && sender.tab.id === activeTab.id); 134 | } catch (error) { 135 | console.error('检查标签页活跃状态失败:', error); 136 | sendResponse(false); 137 | } 138 | })(); 139 | return true; 140 | } 141 | 142 | // 处理来自 sidebar 的网页内容请求 143 | if (message.type === 'GET_PAGE_CONTENT_FROM_SIDEBAR') { 144 | (async () => { 145 | async function tryGetContent() { 146 | try { 147 | const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); 148 | if (!activeTab) { 149 | return null; 150 | } 151 | 152 | if (sender.tab && sender.tab.id !== activeTab.id) { 153 | return null; 154 | } 155 | 156 | if (!sender.url || !sender.url.includes('index.html')) { 157 | return null; 158 | } 159 | 160 | if (await isTabConnected(activeTab.id)) { 161 | return await chrome.tabs.sendMessage(activeTab.id, { 162 | type: 'GET_PAGE_CONTENT_INTERNAL', 163 | skipWaitContent: message.skipWaitContent || false 164 | }); 165 | } 166 | return null; 167 | } catch (error) { 168 | console.warn('获取页面内容失败(可安全忽略):', error); 169 | return null; 170 | } 171 | } 172 | 173 | const content = await tryGetContent(); 174 | sendResponse(content); 175 | })(); 176 | return true; 177 | } 178 | 179 | // 处理PDF下载请求 180 | if (message.action === 'downloadPDF') { 181 | (async () => { 182 | try { 183 | const response = await downloadPDF(message.url); 184 | sendResponse(response); 185 | } catch (error) { 186 | sendResponse({success: false, error: error.message}); 187 | } 188 | })(); 189 | return true; 190 | } 191 | 192 | // 处理获取PDF块的请求 193 | if (message.action === 'getPDFChunk') { 194 | (async () => { 195 | try { 196 | const response = await getPDFChunk(message.url, message.chunkIndex); 197 | sendResponse(response); 198 | } catch (error) { 199 | sendResponse({success: false, error: error.message}); 200 | } 201 | })(); 202 | return true; 203 | } 204 | 205 | return false; 206 | }); 207 | 208 | // 监听存储变化 209 | chrome.storage.onChanged.addListener((changes, areaName) => { 210 | if (areaName === 'local' && changes.webpageSwitchDomains) { 211 | const { newValue = {}, oldValue = {} } = changes.webpageSwitchDomains; 212 | const domains = { ...oldValue, ...newValue }; 213 | chrome.storage.local.set({ webpageSwitchDomains: domains }); 214 | } 215 | }); 216 | 217 | // 简化Service Worker活跃保持 218 | const HEARTBEAT_INTERVAL = 20000; 219 | const keepAliveInterval = setInterval(() => { 220 | // console.log('Service Worker 心跳:', new Date().toISOString()); 221 | }, HEARTBEAT_INTERVAL); 222 | 223 | self.addEventListener('beforeunload', () => clearInterval(keepAliveInterval)); 224 | 225 | // 简化初始化检查 226 | chrome.runtime.onInstalled.addListener(() => { 227 | console.log('扩展已安装/更新:', new Date().toISOString()); 228 | }); 229 | 230 | // 改进标签页连接检查 231 | async function isTabConnected(tabId) { 232 | try { 233 | const response = await chrome.tabs.sendMessage(tabId, { 234 | type: 'PING', 235 | timestamp: Date.now() 236 | }); 237 | // console.log('isTabConnected:', response.type); 238 | return response && response.type === 'PONG'; 239 | } catch { 240 | return false; 241 | } 242 | } 243 | 244 | // 简化消息发送 245 | async function sendMessageToTab(tabId, message) { 246 | if (await isTabConnected(tabId)) { 247 | return chrome.tabs.sendMessage(tabId, message); 248 | } 249 | return null; 250 | } 251 | 252 | // 简化请求跟踪 253 | const tabRequests = new Map(); 254 | 255 | function initTabRequests(tabId) { 256 | if (!tabRequests.has(tabId)) { 257 | tabRequests.set(tabId, { 258 | pending: new Set(), 259 | isInitialRequestsCompleted: false 260 | }); 261 | } 262 | } 263 | 264 | // 简化请求监听器 265 | chrome.webRequest.onBeforeRequest.addListener( 266 | ({ tabId, requestId }) => { 267 | if (tabId !== -1) { 268 | initTabRequests(tabId); 269 | const tabData = tabRequests.get(tabId); 270 | tabData.pending.add(requestId); 271 | // 使用非异步方式发送消息 272 | chrome.tabs.sendMessage(tabId, { 273 | type: 'REQUEST_STARTED', 274 | requestId, 275 | pendingCount: tabData.pending.size 276 | }).catch(() => {}); 277 | } 278 | }, 279 | { urls: [""] } 280 | ); 281 | 282 | chrome.webRequest.onCompleted.addListener( 283 | ({ tabId, requestId }) => { 284 | if (tabId !== -1 && tabRequests.has(tabId)) { 285 | const tabData = tabRequests.get(tabId); 286 | tabData.pending.delete(requestId); 287 | 288 | if (tabData.pending.size === 0) { 289 | tabData.isInitialRequestsCompleted = true; 290 | } 291 | 292 | // 使用非异步方式发送消息 293 | chrome.tabs.sendMessage(tabId, { 294 | type: 'REQUEST_COMPLETED', 295 | requestId, 296 | pendingCount: tabData.pending.size, 297 | isInitialRequestsCompleted: tabData.isInitialRequestsCompleted 298 | }).catch(() => {}); 299 | } 300 | }, 301 | { urls: [""] } 302 | ); 303 | 304 | chrome.webRequest.onErrorOccurred.addListener( 305 | ({ tabId, requestId }) => { 306 | if (tabId !== -1 && tabRequests.has(tabId)) { 307 | const tabData = tabRequests.get(tabId); 308 | tabData.pending.delete(requestId); 309 | 310 | // 使用非异步方式发送消息 311 | chrome.tabs.sendMessage(tabId, { 312 | type: 'REQUEST_FAILED', 313 | requestId, 314 | pendingCount: tabData.pending.size 315 | }).catch(() => {}); 316 | } 317 | }, 318 | { urls: [""] } 319 | ); 320 | 321 | chrome.tabs.onRemoved.addListener(tabId => tabRequests.delete(tabId)); 322 | 323 | // 添加公共的PDF文件获取函数 324 | async function getPDFArrayBuffer(url) { 325 | if (url.startsWith('file://')) { 326 | // 处理本地文件 327 | const response = await fetch(url); 328 | if (!response.ok) { 329 | throw new Error('无法读取本地PDF文件'); 330 | } 331 | return response.arrayBuffer(); 332 | } else { 333 | const headers = { 334 | 'Accept': 'application/pdf,*/*', 335 | 'Cache-Control': 'no-cache', 336 | 'Pragma': 'no-cache' 337 | }; 338 | 339 | // 如果是ScienceDirect的URL,添加特殊处理 340 | if (url.includes('sciencedirectassets.com')) { 341 | // 从原始页面获取必要的cookie和referer 342 | headers['Accept'] = '*/*'; // ScienceDirect需要这个 343 | headers['Referer'] = 'https://www.sciencedirect.com/'; 344 | headers['Origin'] = 'https://www.sciencedirect.com'; 345 | headers['Connection'] = 'keep-alive'; 346 | } 347 | const response = await fetch(url, { 348 | method: 'GET', 349 | headers: headers, 350 | credentials: 'include', 351 | mode: 'cors' 352 | }); 353 | // 处理在线文件 354 | if (!response.ok) { 355 | throw new Error('PDF文件下载失败'); 356 | } 357 | return response.arrayBuffer(); 358 | } 359 | } 360 | 361 | // 修改 downloadPDF 函数 362 | async function downloadPDF(url) { 363 | try { 364 | // console.log('开始下载PDF文件:', url); 365 | const arrayBuffer = await getPDFArrayBuffer(url); 366 | // console.log('PDF文件下载完成,大小:', arrayBuffer.byteLength, 'bytes'); 367 | 368 | // 将ArrayBuffer转换为Uint8Array 369 | const uint8Array = new Uint8Array(arrayBuffer); 370 | 371 | // 分块大小设为4MB 372 | const chunkSize = 4 * 1024 * 1024; 373 | const chunks = Math.ceil(uint8Array.length / chunkSize); 374 | 375 | // 发送第一个消息,包含总块数和文件大小信息 376 | return { 377 | success: true, 378 | type: 'init', 379 | totalChunks: chunks, 380 | totalSize: uint8Array.length 381 | }; 382 | } catch (error) { 383 | console.error('PDF下载失败:', error); 384 | console.error('错误堆栈:', error.stack); 385 | throw new Error('PDF下载失败: ' + error.message); 386 | } 387 | } 388 | 389 | // 修改 getPDFChunk 函数 390 | async function getPDFChunk(url, chunkIndex) { 391 | try { 392 | const arrayBuffer = await getPDFArrayBuffer(url); 393 | const uint8Array = new Uint8Array(arrayBuffer); 394 | const chunkSize = 4 * 1024 * 1024; 395 | const start = chunkIndex * chunkSize; 396 | const end = Math.min(start + chunkSize, uint8Array.length); 397 | 398 | return { 399 | success: true, 400 | type: 'chunk', 401 | chunkIndex: chunkIndex, 402 | data: Array.from(uint8Array.slice(start, end)) 403 | }; 404 | } catch (error) { 405 | console.error('获取PDF块数据失败:', error); 406 | return { 407 | success: false, 408 | error: error.message 409 | }; 410 | } 411 | } -------------------------------------------------------------------------------- /icons/icon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/icons/icon48.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cerebr 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | 复制消息 32 |
33 |
34 | 35 | 36 | 37 | 38 | 复制代码 39 |
40 |
41 | 42 | 43 | 44 | 复制公式 45 |
46 |
47 | 48 | 49 | 50 | 停止更新 51 |
52 |
53 | 54 | 55 | 56 | 57 | 58 | 删除消息 59 |
60 |
61 |
62 | 69 |
70 | 77 | 84 | 90 | 96 | 102 | 108 |
109 |
110 |
111 |
112 |
113 | 118 | API 设置 119 |
120 |
121 | 122 | 168 |
169 |
170 |
171 |
172 | 177 | 对话列表 178 |
179 |
180 | 181 | 194 |
195 |
196 |
197 |
198 | 预览图片 199 | 204 |
205 |
206 | 207 | 208 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Cerebr", 4 | "version": "2.3.34", 5 | "description": "Cerebr - 智能AI聊天助手", 6 | "content_security_policy": { 7 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" 8 | }, 9 | "icons": { 10 | "16": "icons/icon16.png", 11 | "48": "icons/icon48.png", 12 | "128": "icons/icon128.png" 13 | }, 14 | "permissions": [ 15 | "storage", 16 | "commands", 17 | "scripting", 18 | "activeTab", 19 | "tabs", 20 | "webRequest", 21 | "unlimitedStorage" 22 | ], 23 | "host_permissions": ["", "file:///*"], 24 | "action": { 25 | "default_title": "打开 Cerebr 侧边栏", 26 | "default_icon": { 27 | "16": "icons/icon16.png", 28 | "48": "icons/icon48.png", 29 | "128": "icons/icon128.png" 30 | } 31 | }, 32 | "commands": { 33 | "toggle_sidebar": { 34 | "suggested_key": { 35 | "default": "Alt+Z", 36 | "windows": "Alt+Z", 37 | "mac": "MacCtrl+Z" 38 | }, 39 | "description": "打开/关闭 Cerebr 侧边栏" 40 | }, 41 | "new_chat": { 42 | "suggested_key": { 43 | "default": "Alt+X", 44 | "windows": "Alt+X", 45 | "mac": "MacCtrl+X" 46 | }, 47 | "description": "创建新的对话" 48 | } 49 | }, 50 | "background": { 51 | "service_worker": "background.js", 52 | "type": "module" 53 | }, 54 | "content_scripts": [ 55 | { 56 | "matches": [""], 57 | "js": ["lib/pdf.js", "content.js"], 58 | "run_at": "document_start" 59 | } 60 | ], 61 | "web_accessible_resources": [ 62 | { 63 | "resources": [ 64 | "index.html", 65 | "src/main.js", 66 | "styles/main.css", 67 | "htmd/marked.min.js", 68 | "htmd/highlight.min.js", 69 | "htmd/mathjax-config.js", 70 | "htmd/tex-chtml-full.js", 71 | "lib/pdf.js", 72 | "lib/pdf.worker.js", 73 | "htmd/mermaid.min.js", 74 | "htmd/mermaid-init.js", 75 | "htmd/fonts/woff-v2/MathJax_AMS-Regular.woff", 76 | "htmd/fonts/woff-v2/MathJax_Calligraphic-Regular.woff", 77 | "htmd/fonts/woff-v2/MathJax_Fraktur-Bold.woff", 78 | "htmd/fonts/woff-v2/MathJax_Fraktur-Regular.woff", 79 | "htmd/fonts/woff-v2/MathJax_Main-Bold.woff", 80 | "htmd/fonts/woff-v2/MathJax_Main-Regular.woff", 81 | "htmd/fonts/woff-v2/MathJax_Math-BoldItalic.woff", 82 | "htmd/fonts/woff-v2/MathJax_Math-Italic.woff", 83 | "htmd/fonts/woff-v2/MathJax_Size1-Regular.woff", 84 | "htmd/fonts/woff-v2/MathJax_Size2-Regular.woff", 85 | "htmd/fonts/woff-v2/MathJax_Size3-Regular.woff", 86 | "htmd/fonts/woff-v2/MathJax_Size4-Regular.woff", 87 | "htmd/fonts/woff-v2/MathJax_Typewriter-Regular.woff", 88 | "htmd/fonts/woff-v2/MathJax_Vector-Bold.woff", 89 | "htmd/fonts/woff-v2/MathJax_Vector-Regular.woff", 90 | "htmd/fonts/woff-v2/MathJax_Zero.woff", 91 | "statics/image.png" 92 | ], 93 | "matches": [""] 94 | } 95 | ] 96 | } -------------------------------------------------------------------------------- /src/components/api-card.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API卡片配置接口 3 | * @typedef {Object} APIConfig 4 | * @property {string} apiKey - API密钥 5 | * @property {string} baseUrl - API的基础URL 6 | * @property {string} modelName - 模型名称 7 | * @property {Object} advancedSettings - 高级设置 8 | * @property {string} advancedSettings.systemPrompt - 系统提示 9 | * @property {boolean} advancedSettings.isExpanded - 高级设置是否展开 10 | */ 11 | 12 | /** 13 | * 渲染 API 卡片 14 | * @param {Object} params - 渲染参数 15 | * @param {Array} params.apiConfigs - API配置列表 16 | * @param {HTMLElement} params.apiCardsContainer - 卡片容器元素 17 | * @param {HTMLElement} params.templateCard - 模板卡片元素 18 | * @param {function} params.onCardCreate - 卡片创建回调函数 19 | * @param {function} params.onCardSelect - 卡片选择回调函数 20 | * @param {function} params.onCardDuplicate - 卡片复制回调函数 21 | * @param {function} params.onCardDelete - 卡片删除回调函数 22 | * @param {function} params.onCardChange - 卡片内容变更回调函数 23 | * @param {number} params.selectedIndex - 当前选中的卡片索引 24 | */ 25 | export function renderAPICards({ 26 | apiConfigs, 27 | apiCardsContainer, 28 | templateCard, 29 | onCardCreate, 30 | onCardSelect, 31 | onCardDuplicate, 32 | onCardDelete, 33 | onCardChange, 34 | selectedIndex 35 | }) { 36 | if (!templateCard) { 37 | console.error('找不到模板卡片元素'); 38 | return; 39 | } 40 | 41 | // 保存模板的副本 42 | const templateClone = templateCard.cloneNode(true); 43 | 44 | // 清空现有卡片 45 | apiCardsContainer.innerHTML = ''; 46 | 47 | // 先重新添加模板(保持隐藏状态) 48 | apiCardsContainer.appendChild(templateClone); 49 | 50 | // 移除所有卡片的选中状态 51 | document.querySelectorAll('.api-card').forEach(card => { 52 | card.classList.remove('selected'); 53 | }); 54 | 55 | // 渲染实际的卡片 56 | apiConfigs.forEach((config, index) => { 57 | const card = createAPICard({ 58 | config, 59 | index, 60 | templateCard: templateClone, 61 | onSelect: onCardSelect, 62 | onDuplicate: onCardDuplicate, 63 | onDelete: onCardDelete, 64 | onChange: onCardChange, 65 | isSelected: index === selectedIndex 66 | }); 67 | apiCardsContainer.appendChild(card); 68 | if (onCardCreate) { 69 | onCardCreate(card, index); 70 | } 71 | }); 72 | } 73 | 74 | /** 75 | * 创建单个 API 卡片 76 | * @param {Object} params - 创建参数 77 | * @param {APIConfig} params.config - API配置 78 | * @param {number} params.index - 卡片索引 79 | * @param {HTMLElement} params.templateCard - 模板卡片元素 80 | * @param {function} params.onSelect - 选择回调 81 | * @param {function} params.onDuplicate - 复制回调 82 | * @param {function} params.onDelete - 删除回调 83 | * @param {function} params.onChange - 变更回调 84 | * @param {boolean} params.isSelected - 是否选中 85 | * @returns {HTMLElement} 创建的卡片元素 86 | */ 87 | function createAPICard({ 88 | config, 89 | index, 90 | templateCard, 91 | onSelect, 92 | onDuplicate, 93 | onDelete, 94 | onChange, 95 | isSelected 96 | }) { 97 | // 克隆模板 98 | const template = templateCard.cloneNode(true); 99 | template.classList.remove('template'); 100 | template.style.display = ''; 101 | template.setAttribute('tabindex', '0'); 102 | 103 | // 设置选中状态 104 | if (isSelected) { 105 | template.classList.add('selected'); 106 | } else { 107 | template.classList.remove('selected'); 108 | } 109 | 110 | const apiKeyInput = template.querySelector('.api-key'); 111 | const baseUrlInput = template.querySelector('.base-url'); 112 | const modelNameInput = template.querySelector('.model-name'); 113 | const systemPromptInput = template.querySelector('.system-prompt'); 114 | const advancedSettingsHeader = template.querySelector('.advanced-settings-header'); 115 | const advancedSettingsContent = template.querySelector('.advanced-settings-content'); 116 | const toggleIcon = template.querySelector('.toggle-icon'); 117 | 118 | // 设置初始值 119 | apiKeyInput.value = config.apiKey || ''; 120 | baseUrlInput.value = config.baseUrl || 'https://api.openai.com/v1/chat/completions'; 121 | modelNameInput.value = config.modelName || 'gpt-4o'; 122 | 123 | // 设置系统提示的默认值 124 | systemPromptInput.value = config.advancedSettings?.systemPrompt || ''; 125 | 126 | // 设置高级设置的展开/折叠状态 127 | const isExpanded = config.advancedSettings?.isExpanded || false; 128 | advancedSettingsContent.style.display = isExpanded ? 'block' : 'none'; 129 | toggleIcon.style.transform = isExpanded ? 'rotate(180deg)' : ''; 130 | 131 | // 添加高级设置的展开/折叠功能 132 | advancedSettingsHeader.addEventListener('click', (e) => { 133 | e.stopPropagation(); 134 | const isCurrentlyExpanded = advancedSettingsContent.style.display === 'block'; 135 | advancedSettingsContent.style.display = isCurrentlyExpanded ? 'none' : 'block'; 136 | toggleIcon.style.transform = isCurrentlyExpanded ? '' : 'rotate(180deg)'; 137 | 138 | // 更新配置 139 | onChange(index, { 140 | ...config, 141 | advancedSettings: { 142 | ...config.advancedSettings, 143 | isExpanded: !isCurrentlyExpanded 144 | } 145 | }); 146 | }); 147 | 148 | // 监听系统提示的变化 149 | systemPromptInput.addEventListener('change', () => { 150 | onChange(index, { 151 | ...config, 152 | advancedSettings: { 153 | ...config.advancedSettings, 154 | systemPrompt: systemPromptInput.value 155 | } 156 | }); 157 | }); 158 | 159 | // 阻止输入框和按钮点击事件冒泡 160 | const stopPropagation = (e) => { 161 | e.stopPropagation(); 162 | e.preventDefault(); 163 | }; 164 | 165 | // 为输入框添加点击事件阻止冒泡 166 | [apiKeyInput, baseUrlInput, modelNameInput, systemPromptInput].forEach(input => { 167 | input.addEventListener('click', stopPropagation); 168 | input.addEventListener('focus', stopPropagation); 169 | }); 170 | 171 | // 添加输入法状态跟踪 172 | let isComposing = false; 173 | 174 | // 监听输入法开始 175 | [apiKeyInput, baseUrlInput, modelNameInput, systemPromptInput].forEach(input => { 176 | input.addEventListener('compositionstart', () => { 177 | isComposing = true; 178 | }); 179 | 180 | // 监听输入法结束 181 | input.addEventListener('compositionend', () => { 182 | isComposing = false; 183 | }); 184 | 185 | // 修改键盘事件处理 186 | input.addEventListener('keydown', (e) => { 187 | if (e.key === 'Enter' && !e.shiftKey) { 188 | if (isComposing) { 189 | // 如果正在使用输入法,不触发选择 190 | return; 191 | } 192 | e.preventDefault(); 193 | onSelect(template, index); 194 | } 195 | }); 196 | }); 197 | 198 | // 为按钮添加点击事件阻止冒泡 199 | template.querySelectorAll('.card-button').forEach(button => { 200 | button.addEventListener('click', stopPropagation); 201 | }); 202 | 203 | // 添加回车键选择功能 204 | template.addEventListener('keydown', (e) => { 205 | if (e.key === 'Enter' && !isComposing) { 206 | e.preventDefault(); 207 | onSelect(template, index); 208 | } 209 | }); 210 | 211 | // 监听输入框变化 212 | [apiKeyInput, baseUrlInput, modelNameInput].forEach(input => { 213 | input.addEventListener('change', () => { 214 | onChange(index, { 215 | apiKey: apiKeyInput.value, 216 | baseUrl: baseUrlInput.value, 217 | modelName: modelNameInput.value 218 | }); 219 | }); 220 | }); 221 | 222 | // 复制配置 223 | template.querySelector('.duplicate-btn').addEventListener('click', (e) => { 224 | e.stopPropagation(); 225 | e.preventDefault(); 226 | onDuplicate(config, index); 227 | }); 228 | 229 | // 删除配置 230 | template.querySelector('.delete-btn').addEventListener('click', (e) => { 231 | e.stopPropagation(); 232 | e.preventDefault(); 233 | onDelete(index); 234 | }); 235 | 236 | // 选择配置 237 | template.addEventListener('click', (e) => { 238 | // 如果点击的是输入框或按钮,不触发选择 239 | if (e.target.matches('input') || e.target.matches('.card-button') || e.target.closest('.card-button')) { 240 | return; 241 | } 242 | onSelect(template, index); 243 | }); 244 | 245 | return template; 246 | } 247 | 248 | /** 249 | * 创建API卡片回调处理函数 250 | * @param {Object} params - 参数对象 251 | * @param {function} params.selectCard - 选择卡片的函数 252 | * @param {Array} params.apiConfigs - API配置列表 253 | * @param {number} params.selectedConfigIndex - 当前选中的配置索引 254 | * @param {function} params.saveAPIConfigs - 保存API配置的函数 255 | * @param {function} params.renderAPICardsWithCallbacks - 重新渲染卡片的函数 256 | * @returns {Object} 回调函数对象 257 | */ 258 | export function createCardCallbacks({ 259 | selectCard, 260 | apiConfigs, 261 | selectedConfigIndex, 262 | saveAPIConfigs, 263 | renderAPICardsWithCallbacks 264 | }) { 265 | return { 266 | onCardSelect: selectCard, 267 | onCardDuplicate: (config, index) => { 268 | // 在当前选中卡片后面插入新卡片 269 | apiConfigs.splice(index + 1, 0, {...config}); 270 | // 保存配置但不改变选中状态 271 | saveAPIConfigs(); 272 | // 重新渲染所有卡片,保持原来的选中状态 273 | renderAPICardsWithCallbacks(); 274 | }, 275 | onCardDelete: (index) => { 276 | if (apiConfigs.length > 1) { 277 | apiConfigs.splice(index, 1); 278 | if (selectedConfigIndex >= apiConfigs.length) { 279 | selectedConfigIndex = apiConfigs.length - 1; 280 | } 281 | saveAPIConfigs(); 282 | renderAPICardsWithCallbacks(); 283 | } 284 | }, 285 | onCardChange: (index, newConfig) => { 286 | apiConfigs[index] = newConfig; 287 | saveAPIConfigs(); 288 | } 289 | }; 290 | } 291 | 292 | /** 293 | * 选择API卡片的函数 294 | * @param {Object} params - 参数对象 295 | * @param {Object} params.template - 模板对象 296 | * @param {number} params.index - 选中的索引 297 | * @param {function} params.onIndexChange - 索引变更回调函数 298 | * @param {function} params.onSave - 保存配置的回调函数 299 | * @param {string} params.cardSelector - 卡片元素的CSS选择器 300 | * @param {function} params.onSelect - 选中后的回调函数 301 | * @returns {void} 302 | */ 303 | export function selectCard({ 304 | template, 305 | index, 306 | onIndexChange, 307 | onSave, 308 | cardSelector = '.api-card', 309 | onSelect 310 | }) { 311 | // 更新选中索引 312 | onIndexChange(index); 313 | 314 | // 保存配置 315 | onSave(); 316 | 317 | // 更新UI状态 318 | document.querySelectorAll(cardSelector).forEach(card => { 319 | card.classList.remove('selected'); 320 | }); 321 | 322 | // 选中当前卡片 323 | const selectedCard = document.querySelectorAll(cardSelector)[index]; 324 | if (selectedCard) { 325 | selectedCard.classList.add('selected'); 326 | } 327 | 328 | // 执行选中后的回调 329 | if (onSelect) { 330 | onSelect(selectedCard, index); 331 | } 332 | 333 | return selectedCard; 334 | } -------------------------------------------------------------------------------- /src/components/chat-container.js: -------------------------------------------------------------------------------- 1 | import { createImageTag } from '../utils/ui.js'; 2 | import { showContextMenu, hideContextMenu, copyMessageContent } from './context-menu.js'; 3 | import { handleImageDrop } from '../utils/image.js'; 4 | import { updateAIMessage } from '../handlers/message-handler.js'; 5 | 6 | /** 7 | * 初始化聊天容器的所有功能 8 | * @param {Object} params - 初始化参数对象 9 | * @param {HTMLElement} params.chatContainer - 聊天容器元素 10 | * @param {HTMLElement} params.messageInput - 消息输入框元素 11 | * @param {HTMLElement} params.contextMenu - 上下文菜单元素 12 | * @param {Function} params.sendMessage - 发送消息的函数 13 | * @param {AbortController} params.currentController - 当前控制器引用 14 | * @param {Object} params.uiConfig - UI配置对象 15 | * @param {Array} params.userQuestions - 用户问题历史数组 16 | * @param {Object} params.chatManager - 聊天管理器实例 17 | * @returns {Object} 包含更新处理程序的对象 18 | */ 19 | export function initChatContainer({ 20 | chatContainer, 21 | messageInput, 22 | contextMenu, 23 | userQuestions, 24 | chatManager 25 | }) { 26 | // 定义本地变量 27 | let currentMessageElement = null; 28 | let currentCodeElement = null; 29 | 30 | // 初始化 MutationObserver 来监视添加到聊天容器的新用户消息 31 | const observer = new MutationObserver((mutations) => { 32 | mutations.forEach((mutation) => { 33 | mutation.addedNodes.forEach((node) => { 34 | if (node.classList && node.classList.contains('user-message')) { 35 | const question = node.textContent.trim(); 36 | // 只有当问题不在历史记录中时才添加 37 | if (question && !userQuestions.includes(question)) { 38 | userQuestions.push(question); 39 | } 40 | } 41 | }); 42 | }); 43 | }); 44 | 45 | // 开始观察聊天容器的变化 46 | observer.observe(chatContainer, { childList: true }); 47 | 48 | // 添加点击事件监听 49 | chatContainer.addEventListener('click', () => { 50 | // 点击聊天区域时让输入框失去焦点 51 | messageInput.blur(); 52 | }); 53 | 54 | // 监听 AI 消息的右键点击 55 | chatContainer.addEventListener('contextmenu', (e) => { 56 | const messageElement = e.target.closest('.ai-message, .user-message'); 57 | const codeElement = e.target.closest('pre > code'); 58 | 59 | if (messageElement) { 60 | currentMessageElement = messageElement; 61 | currentCodeElement = codeElement; 62 | 63 | // 获取菜单元素 64 | const copyMessageButton = document.getElementById('copy-message'); 65 | const copyCodeButton = document.getElementById('copy-code'); 66 | const copyMathButton = document.getElementById('copy-math'); 67 | const stopUpdateButton = document.getElementById('stop-update'); 68 | const deleteMessageButton = document.getElementById('delete-message'); 69 | 70 | // 根据右键点击的元素类型显示/隐藏相应的菜单项 71 | copyMessageButton.style.display = 'flex'; 72 | deleteMessageButton.style.display = 'flex'; 73 | copyCodeButton.style.display = codeElement ? 'flex' : 'none'; 74 | copyMathButton.style.display = 'none'; // 默认隐藏复制公式按钮 75 | 76 | // 只有AI消息且正在更新时才显示停止更新按钮 77 | stopUpdateButton.style.display = (messageElement.classList.contains('ai-message') && messageElement.classList.contains('updating')) ? 'flex' : 'none'; 78 | 79 | showContextMenu({ 80 | event: e, 81 | messageElement, 82 | contextMenu, 83 | stopUpdateButton, 84 | onMessageElementSelect: (element) => { 85 | currentMessageElement = element; 86 | } 87 | }); 88 | } 89 | }); 90 | 91 | // 添加长按触发右键菜单的支持 92 | let touchTimeout; 93 | let touchStartX; 94 | let touchStartY; 95 | const LONG_PRESS_DURATION = 200; // 长按触发时间为200ms 96 | 97 | chatContainer.addEventListener('touchstart', (e) => { 98 | const messageElement = e.target.closest('.ai-message, .user-message'); 99 | if (!messageElement) return; 100 | 101 | touchStartX = e.touches[0].clientX; 102 | touchStartY = e.touches[0].clientY; 103 | 104 | touchTimeout = setTimeout(() => { 105 | const codeElement = e.target.closest('pre > code'); 106 | currentMessageElement = messageElement; 107 | currentCodeElement = codeElement; 108 | 109 | // 获取菜单元素 110 | const copyMessageButton = document.getElementById('copy-message'); 111 | const copyCodeButton = document.getElementById('copy-code'); 112 | const stopUpdateButton = document.getElementById('stop-update'); 113 | const deleteMessageButton = document.getElementById('delete-message'); 114 | 115 | // 根据长按元素类型显示/隐藏相应的菜单项 116 | copyMessageButton.style.display = 'flex'; 117 | deleteMessageButton.style.display = 'flex'; 118 | copyCodeButton.style.display = codeElement ? 'flex' : 'none'; 119 | stopUpdateButton.style.display = (messageElement.classList.contains('ai-message') && messageElement.classList.contains('updating')) ? 'flex' : 'none'; 120 | 121 | showContextMenu({ 122 | event: { 123 | preventDefault: () => {}, 124 | clientX: touchStartX, 125 | clientY: touchStartY 126 | }, 127 | messageElement, 128 | contextMenu, 129 | stopUpdateButton, 130 | onMessageElementSelect: (element) => { 131 | currentMessageElement = element; 132 | } 133 | }); 134 | }, LONG_PRESS_DURATION); 135 | }, { passive: false }); 136 | 137 | chatContainer.addEventListener('touchmove', (e) => { 138 | // 如果移动超过10px,取消长按 139 | if (touchTimeout && 140 | (Math.abs(e.touches[0].clientX - touchStartX) > 10 || 141 | Math.abs(e.touches[0].clientY - touchStartY) > 10)) { 142 | clearTimeout(touchTimeout); 143 | touchTimeout = null; 144 | } 145 | }, { passive: true }); 146 | 147 | chatContainer.addEventListener('touchend', () => { 148 | if (touchTimeout) { 149 | clearTimeout(touchTimeout); 150 | touchTimeout = null; 151 | } 152 | // 如果用户没有触发长按(即正常的触摸结束),则隐藏菜单 153 | if (!contextMenu.style.display || contextMenu.style.display === 'none') { 154 | hideContextMenu({ 155 | contextMenu, 156 | onMessageElementReset: () => { currentMessageElement = null; } 157 | }); 158 | } 159 | }); 160 | 161 | // 为聊天区域添加拖放事件监听器 162 | chatContainer.addEventListener('dragover', (e) => { 163 | e.preventDefault(); 164 | e.stopPropagation(); 165 | }); 166 | 167 | chatContainer.addEventListener('dragleave', (e) => { 168 | e.preventDefault(); 169 | e.stopPropagation(); 170 | }); 171 | 172 | chatContainer.addEventListener('drop', (e) => { 173 | handleImageDrop(e, { 174 | messageInput, 175 | createImageTag, 176 | onSuccess: () => { 177 | // 可以在这里添加成功处理的回调 178 | }, 179 | onError: (error) => { 180 | console.error('处理拖放事件失败:', error); 181 | } 182 | }); 183 | }); 184 | 185 | // 阻止聊天区域的图片默认行为 186 | chatContainer.addEventListener('click', (e) => { 187 | if (e.target.tagName === 'IMG') { 188 | e.preventDefault(); 189 | e.stopPropagation(); 190 | } 191 | }); 192 | 193 | // 添加一个锁变量和队列用于消息更新 194 | let isUpdating = false; 195 | const updateQueue = []; 196 | 197 | // 创建消息同步函数 198 | const syncMessage = async (updatedChatId, message) => { 199 | const currentChat = chatManager.getCurrentChat(); 200 | // 只有当更新的消息属于当前显示的对话时才更新界面 201 | if (currentChat && currentChat.id === updatedChatId) { 202 | // 将更新任务添加到队列 203 | updateQueue.push(message); 204 | 205 | // 如果当前没有更新在进行,开始处理队列 206 | if (!isUpdating) { 207 | await processUpdateQueue(); 208 | } 209 | } 210 | }; 211 | 212 | // 处理更新队列的函数 213 | const processUpdateQueue = async () => { 214 | if (isUpdating || updateQueue.length === 0) return; 215 | 216 | try { 217 | isUpdating = true; 218 | while (updateQueue.length > 0) { 219 | const message = updateQueue.shift(); 220 | await updateAIMessage({ 221 | text: message, 222 | chatContainer 223 | }); 224 | } 225 | } finally { 226 | isUpdating = false; 227 | // 检查是否在处理过程中有新的更新加入队列 228 | if (updateQueue.length > 0) { 229 | await processUpdateQueue(); 230 | } 231 | } 232 | }; 233 | 234 | // 设置按钮事件处理器 235 | function setupButtonHandlers({ 236 | copyMessageButton, 237 | copyCodeButton, 238 | stopUpdateButton, 239 | deleteMessageButton, 240 | abortController 241 | }) { 242 | // 点击复制按钮 243 | copyMessageButton.addEventListener('click', () => { 244 | copyMessageContent({ 245 | messageElement: currentMessageElement, 246 | onSuccess: () => hideContextMenu({ 247 | contextMenu, 248 | onMessageElementReset: () => { 249 | currentMessageElement = null; 250 | currentCodeElement = null; 251 | } 252 | }), 253 | onError: (err) => console.error('复制失败:', err) 254 | }); 255 | }); 256 | 257 | // 点击复制代码按钮 258 | copyCodeButton.addEventListener('click', () => { 259 | if (currentCodeElement) { 260 | const codeText = currentCodeElement.textContent; 261 | navigator.clipboard.writeText(codeText) 262 | .then(() => { 263 | hideContextMenu({ 264 | contextMenu, 265 | onMessageElementReset: () => { 266 | currentMessageElement = null; 267 | currentCodeElement = null; 268 | } 269 | }); 270 | }) 271 | .catch(err => console.error('复制代码失败:', err)); 272 | } 273 | }); 274 | 275 | // 添加停止更新按钮的点击事件处理 276 | stopUpdateButton.addEventListener('click', () => { 277 | if (abortController.current) { 278 | abortController.current.abort(); // 中止当前请求 279 | abortController.current = null; 280 | hideContextMenu({ 281 | contextMenu, 282 | onMessageElementReset: () => { currentMessageElement = null; } 283 | }); 284 | } 285 | }); 286 | 287 | // 添加删除消息按钮的点击事件处理 288 | deleteMessageButton.addEventListener('click', () => { 289 | if (currentMessageElement) { 290 | // 如果消息正在更新,先中止请求 291 | if (currentMessageElement.classList.contains('updating') && abortController.current) { 292 | abortController.current.abort(); 293 | abortController.current = null; 294 | } 295 | 296 | // 从DOM中移除消息元素 297 | const messageIndex = Array.from(chatContainer.children).indexOf(currentMessageElement); 298 | currentMessageElement.remove(); 299 | 300 | // 从chatManager中删除对应的消息 301 | const currentChat = chatManager.getCurrentChat(); 302 | if (currentChat && messageIndex !== -1) { 303 | currentChat.messages.splice(messageIndex, 1); 304 | chatManager.saveChats(); 305 | } 306 | 307 | // 隐藏右键菜单 308 | hideContextMenu({ 309 | contextMenu, 310 | onMessageElementReset: () => { 311 | currentMessageElement = null; 312 | currentCodeElement = null; 313 | } 314 | }); 315 | } 316 | }); 317 | } 318 | 319 | // 设置数学公式上下文菜单处理 320 | function setupMathContextMenu() { 321 | document.addEventListener('contextmenu', (event) => { 322 | // 检查是否点击了 MathJax 3 的任何元素 323 | const isMathElement = (element) => { 324 | const isMjx = element.tagName && element.tagName.toLowerCase().startsWith('mjx-'); 325 | const hasContainer = element.closest('mjx-container') !== null; 326 | return isMjx || hasContainer; 327 | }; 328 | 329 | if (isMathElement(event.target)) { 330 | event.preventDefault(); 331 | event.stopPropagation(); 332 | 333 | // 获取最外层的 mjx-container 334 | const container = event.target.closest('mjx-container'); 335 | 336 | if (container) { 337 | const mathContextMenu = document.getElementById('copy-math'); 338 | const copyMessageButton = document.getElementById('copy-message'); 339 | const copyCodeButton = document.getElementById('copy-code'); 340 | const stopUpdateButton = document.getElementById('stop-update'); 341 | 342 | if (mathContextMenu) { 343 | // 设置菜单项的显示状态 344 | mathContextMenu.style.display = 'flex'; 345 | copyMessageButton.style.display = 'flex'; // 显示复制消息按钮 346 | copyCodeButton.style.display = 'none'; 347 | stopUpdateButton.style.display = 'none'; 348 | 349 | // 获取包含公式的 AI 消息元素 350 | const aiMessage = container.closest('.ai-message'); 351 | currentMessageElement = aiMessage; // 设置当前消息元素为 AI 消息 352 | 353 | // 调用 showContextMenu 函数 354 | showContextMenu({ 355 | event, 356 | messageElement: aiMessage, // 使用 AI 消息元素 357 | contextMenu, 358 | stopUpdateButton 359 | }); 360 | 361 | // 设置数学公式内容 362 | const assistiveMml = container.querySelector('mjx-assistive-mml'); 363 | let mathContent; 364 | 365 | // 获取原始的 LaTeX 源码 366 | const mjxTexElement = container.querySelector('script[type="math/tex; mode=display"]') || 367 | container.querySelector('script[type="math/tex"]'); 368 | 369 | if (mjxTexElement) { 370 | mathContent = mjxTexElement.textContent; 371 | } else { 372 | // 如果找不到原始 LaTeX,尝试从 MathJax 内部存储获取 373 | const mjxInternal = container.querySelector('mjx-math'); 374 | if (mjxInternal) { 375 | const texAttr = mjxInternal.getAttribute('aria-label'); 376 | if (texAttr) { 377 | // 移除 "TeX:" 前缀(如果有的话) 378 | mathContent = texAttr.replace(/^TeX:\s*/, ''); 379 | } 380 | } 381 | } 382 | 383 | // 如果还是没有找到,尝试其他方法 384 | if (!mathContent) { 385 | if (assistiveMml) { 386 | const texAttr = assistiveMml.getAttribute('aria-label'); 387 | if (texAttr) { 388 | mathContent = texAttr.replace(/^TeX:\s*/, ''); 389 | } 390 | } 391 | } 392 | 393 | mathContextMenu.dataset.mathContent = mathContent || container.textContent; 394 | } 395 | } 396 | } 397 | }, { capture: true, passive: false }); 398 | 399 | // 复制数学公式 400 | document.getElementById('copy-math')?.addEventListener('click', async () => { 401 | try { 402 | // 获取数学公式内容 403 | const mathContent = document.getElementById('copy-math').dataset.mathContent; 404 | 405 | if (mathContent) { 406 | await navigator.clipboard.writeText(mathContent); 407 | console.log('数学公式已复制:', mathContent); 408 | 409 | // 隐藏上下文菜单 410 | hideContextMenu({ 411 | contextMenu, 412 | onMessageElementReset: () => { 413 | currentMessageElement = null; 414 | } 415 | }); 416 | } else { 417 | console.error('没有找到可复制的数学公式内容'); 418 | } 419 | } catch (err) { 420 | console.error('复制公式失败:', err); 421 | } 422 | }); 423 | } 424 | 425 | // 初始化用户问题历史 426 | function initializeUserQuestions() { 427 | const userMessages = document.querySelectorAll('.user-message'); 428 | const questions = Array.from(userMessages).map(msg => msg.textContent.trim()); 429 | 430 | // 清空并添加新问题 431 | userQuestions.length = 0; 432 | userQuestions.push(...questions); 433 | } 434 | 435 | // 设置全局点击和触摸事件,用于隐藏上下文菜单 436 | function setupGlobalEvents() { 437 | // 点击其他地方隐藏菜单 438 | document.addEventListener('click', (e) => { 439 | if (!contextMenu.contains(e.target)) { 440 | hideContextMenu({ 441 | contextMenu, 442 | onMessageElementReset: () => { currentMessageElement = null; } 443 | }); 444 | } 445 | }); 446 | 447 | // 触摸其他地方隐藏菜单 448 | document.addEventListener('touchstart', (e) => { 449 | if (!contextMenu.contains(e.target)) { 450 | hideContextMenu({ 451 | contextMenu, 452 | onMessageElementReset: () => { currentMessageElement = null; } 453 | }); 454 | } 455 | }); 456 | 457 | // 滚动时隐藏菜单 458 | chatContainer.addEventListener('scroll', () => { 459 | hideContextMenu({ 460 | contextMenu, 461 | onMessageElementReset: () => { currentMessageElement = null; } 462 | }); 463 | }); 464 | 465 | // 按下 Esc 键隐藏菜单 466 | document.addEventListener('keydown', (e) => { 467 | if (e.key === 'Escape') { 468 | hideContextMenu({ 469 | contextMenu, 470 | onMessageElementReset: () => { currentMessageElement = null; } 471 | }); 472 | } 473 | }); 474 | } 475 | 476 | // 初始化函数 477 | function initialize() { 478 | setupMathContextMenu(); 479 | setupGlobalEvents(); 480 | initializeUserQuestions(); 481 | } 482 | 483 | // 立即执行初始化 484 | initialize(); 485 | 486 | // 返回包含公共方法的对象 487 | return { 488 | syncMessage, 489 | setupButtonHandlers, 490 | initializeUserQuestions 491 | }; 492 | } -------------------------------------------------------------------------------- /src/components/chat-list.js: -------------------------------------------------------------------------------- 1 | import { appendMessage } from '../handlers/message-handler.js'; 2 | 3 | // 渲染对话列表 4 | export function renderChatList(chatManager, chatCards) { 5 | const template = chatCards.querySelector('.chat-card.template'); 6 | 7 | // 清除现有的卡片(除了模板) 8 | Array.from(chatCards.children).forEach(card => { 9 | if (!card.classList.contains('template')) { 10 | card.remove(); 11 | } 12 | }); 13 | 14 | // 获取当前对话ID 15 | const currentChatId = chatManager.getCurrentChat()?.id; 16 | 17 | // 添加所有对话卡片 18 | chatManager.getAllChats().forEach(chat => { 19 | const card = template.cloneNode(true); 20 | card.classList.remove('template'); 21 | card.style.display = ''; 22 | card.dataset.chatId = chat.id; 23 | 24 | const titleElement = card.querySelector('.chat-title'); 25 | titleElement.textContent = chat.title; 26 | 27 | // 设置选中状态 28 | if (chat.id === currentChatId) { 29 | card.classList.add('selected'); 30 | } else { 31 | card.classList.remove('selected'); 32 | } 33 | 34 | chatCards.appendChild(card); 35 | }); 36 | } 37 | 38 | // 加载对话内容 39 | export async function loadChatContent(chat, chatContainer) { 40 | chatContainer.innerHTML = ''; 41 | // 确定要遍历的消息范围 42 | const messages = chat.messages; 43 | // console.log('loadChatContent', JSON.stringify(messages)); 44 | 45 | for (let i = 0; i < messages.length; i++) { 46 | const message = messages[i]; 47 | if (message.content) { 48 | await appendMessage({ 49 | text: message, 50 | sender: message.role === 'user' ? 'user' : 'ai', 51 | chatContainer, 52 | skipHistory: true, 53 | }); 54 | } 55 | } 56 | } 57 | 58 | // 切换到指定对话 59 | export async function switchToChat(chatId, chatManager) { 60 | // console.log('switchToChat', chatId); 61 | const chat = await chatManager.switchChat(chatId); 62 | if (chat) { 63 | await loadChatContent(chat, document.getElementById('chat-container')); 64 | 65 | // 更新对话列表中的选中状态 66 | document.querySelectorAll('.chat-card').forEach(card => { 67 | if (card.dataset.chatId === chatId) { 68 | card.classList.add('selected'); 69 | } else { 70 | card.classList.remove('selected'); 71 | } 72 | }); 73 | } 74 | } 75 | 76 | // 显示对话列表 77 | export function showChatList(chatListPage, apiSettings, onShow) { 78 | chatListPage.classList.add('show'); 79 | apiSettings.classList.remove('visible'); // 确保API设置页面被隐藏 80 | if (onShow) onShow(); 81 | } 82 | 83 | // 隐藏对话列表 84 | export function hideChatList(chatListPage) { 85 | chatListPage.classList.remove('show'); 86 | } 87 | 88 | // 初始化对话列表事件监听 89 | export function initChatListEvents({ 90 | chatListPage, 91 | chatCards, 92 | chatManager, 93 | onHide 94 | }) { 95 | // 为每个卡片添加点击事件 96 | chatCards.addEventListener('click', async (e) => { 97 | const card = e.target.closest('.chat-card'); 98 | if (!card || card.classList.contains('template')) return; 99 | 100 | if (!e.target.closest('.delete-btn')) { 101 | await switchToChat(card.dataset.chatId, chatManager); 102 | if (onHide) onHide(); 103 | } 104 | }); 105 | 106 | // 为删除按钮添加点击事件 107 | chatCards.addEventListener('click', async (e) => { 108 | const deleteBtn = e.target.closest('.delete-btn'); 109 | if (!deleteBtn) return; 110 | 111 | const card = deleteBtn.closest('.chat-card'); 112 | if (!card || card.classList.contains('template')) return; 113 | 114 | e.stopPropagation(); 115 | await chatManager.deleteChat(card.dataset.chatId); 116 | renderChatList(chatManager, chatCards); 117 | 118 | // 如果删除的是当前对话,重新加载聊天内容 119 | const currentChat = chatManager.getCurrentChat(); 120 | if (currentChat) { 121 | await loadChatContent(currentChat, document.getElementById('chat-container')); 122 | } 123 | }); 124 | 125 | // 返回按钮点击事件 126 | const backButton = chatListPage.querySelector('.back-button'); 127 | if (backButton) { 128 | backButton.addEventListener('click', () => { 129 | if (onHide) onHide(); 130 | }); 131 | } 132 | } 133 | 134 | // 初始化聊天列表功能 135 | export function initializeChatList({ 136 | chatListPage, 137 | chatManager, 138 | newChatButton, 139 | chatListButton, 140 | settingsMenu, 141 | apiSettings 142 | }) { 143 | const messageInput = document.getElementById('message-input'); 144 | // 新建对话按钮点击事件 145 | newChatButton.addEventListener('click', async () => { 146 | const newChat = chatManager.createNewChat(); 147 | await switchToChat(newChat.id, chatManager); 148 | settingsMenu.classList.remove('visible'); 149 | messageInput.focus(); 150 | }); 151 | 152 | // 对话列表按钮点击事件 153 | chatListButton.addEventListener('click', () => { 154 | showChatList(chatListPage, apiSettings, () => { 155 | renderChatList(chatManager, chatListPage.querySelector('.chat-cards')); 156 | }); 157 | settingsMenu.classList.remove('visible'); 158 | }); 159 | 160 | // 对话列表返回按钮点击事件 161 | const chatListBackButton = chatListPage.querySelector('.back-button'); 162 | if (chatListBackButton) { 163 | chatListBackButton.addEventListener('click', () => hideChatList(chatListPage)); 164 | } 165 | } -------------------------------------------------------------------------------- /src/components/context-menu.js: -------------------------------------------------------------------------------- 1 | // 显示上下文菜单 2 | export function showContextMenu({ 3 | event, // 事件对象 4 | messageElement, // 消息元素 5 | contextMenu, // 右键菜单元素 6 | stopUpdateButton, // 停止更新按钮元素 7 | onMessageElementSelect, // 消息元素选择回调 8 | windowDimensions = { // 窗口尺寸(可选) 9 | width: window.innerWidth, 10 | height: window.innerHeight 11 | } 12 | }) { 13 | event.preventDefault(); 14 | 15 | // 调用消息元素选择回调 16 | if (onMessageElementSelect) { 17 | onMessageElementSelect(messageElement); 18 | } 19 | 20 | // 设置菜单位置 21 | contextMenu.style.display = 'block'; 22 | 23 | // 根据消息状态显示或隐藏停止更新按钮 24 | if (messageElement.classList.contains('updating')) { 25 | stopUpdateButton.style.display = 'flex'; 26 | } else { 27 | stopUpdateButton.style.display = 'none'; 28 | } 29 | 30 | const menuWidth = contextMenu.offsetWidth; 31 | const menuHeight = contextMenu.offsetHeight; 32 | 33 | // 确保菜单不超出视口 34 | let x = event.clientX; 35 | let y = event.clientY; 36 | 37 | if (x + menuWidth > windowDimensions.width) { 38 | x = windowDimensions.width - menuWidth; 39 | } 40 | 41 | if (y + menuHeight > windowDimensions.height) { 42 | y = windowDimensions.height - menuHeight; 43 | } 44 | 45 | contextMenu.style.left = x + 'px'; 46 | contextMenu.style.top = y + 'px'; 47 | } 48 | 49 | // 隐藏上下文菜单 50 | export function hideContextMenu({ contextMenu, onMessageElementReset }) { 51 | contextMenu.style.display = 'none'; 52 | if (onMessageElementReset) { 53 | onMessageElementReset(); 54 | } 55 | } 56 | 57 | // 复制消息内容 58 | export function copyMessageContent({ messageElement, onSuccess, onError }) { 59 | if (messageElement) { 60 | // 获取存储的原始文本 61 | const originalText = messageElement.getAttribute('data-original-text'); 62 | navigator.clipboard.writeText(originalText) 63 | .then(onSuccess) 64 | .catch(onError); 65 | } 66 | } -------------------------------------------------------------------------------- /src/components/message-input.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 消息输入组件 3 | * 处理用户输入、粘贴、拖放图片等交互 4 | */ 5 | 6 | import { adjustTextareaHeight, createImageTag, showImagePreview, hideImagePreview } from '../utils/ui.js'; 7 | import { handleImageDrop } from '../utils/image.js'; 8 | 9 | // 跟踪输入法状态 10 | let isComposing = false; 11 | 12 | /** 13 | * 初始化消息输入组件 14 | * @param {Object} config - 配置对象 15 | * @param {HTMLElement} config.messageInput - 消息输入框元素 16 | * @param {Function} config.sendMessage - 发送消息的回调函数 17 | * @param {Array} config.userQuestions - 用户问题历史数组 18 | * @param {Object} config.contextMenu - 上下文菜单对象 19 | * @param {Function} config.hideContextMenu - 隐藏上下文菜单的函数 20 | * @param {Object} config.uiConfig - UI配置对象 21 | * @param {HTMLElement} [config.settingsMenu] - 设置菜单元素(可选) 22 | */ 23 | export function initMessageInput(config) { 24 | const { 25 | messageInput, 26 | sendMessage, 27 | userQuestions, 28 | contextMenu, 29 | hideContextMenu, 30 | uiConfig, 31 | settingsMenu 32 | } = config; 33 | 34 | // 添加点击事件监听 35 | document.body.addEventListener('click', (e) => { 36 | // 如果有文本被选中,不要触发输入框聚焦 37 | if (window.getSelection().toString()) { 38 | return; 39 | } 40 | 41 | // 排除点击设置按钮、设置菜单、上下文菜单的情况 42 | if (!e.target.closest('#settings-button') && 43 | !e.target.closest('#settings-menu') && 44 | !e.target.closest('#context-menu')) { 45 | 46 | // 切换输入框焦点状态 47 | if (document.activeElement === messageInput) { 48 | messageInput.blur(); 49 | } else { 50 | messageInput.focus(); 51 | } 52 | } 53 | }); 54 | 55 | // 监听输入框变化 56 | messageInput.addEventListener('input', function() { 57 | adjustTextareaHeight({ 58 | textarea: this, 59 | config: uiConfig.textarea 60 | }); 61 | 62 | // 处理 placeholder 的显示 63 | if (this.textContent.trim() === '' && !this.querySelector('.image-tag')) { 64 | // 如果内容空且没有图片标签,清空内容以显示 placeholder 65 | while (this.firstChild) { 66 | this.removeChild(this.firstChild); 67 | } 68 | } 69 | }); 70 | 71 | // 监听输入框的焦点状态 72 | messageInput.addEventListener('focus', () => { 73 | // 输入框获得焦点时隐藏右键菜单 74 | if (hideContextMenu) { 75 | hideContextMenu({ 76 | contextMenu, 77 | onMessageElementReset: () => {} 78 | }); 79 | } 80 | 81 | // 如果存在设置菜单,则隐藏它 82 | if (settingsMenu) { 83 | settingsMenu.classList.remove('visible'); 84 | } 85 | 86 | // 输入框获得焦点,阻止事件冒泡 87 | messageInput.addEventListener('click', (e) => e.stopPropagation()); 88 | }); 89 | 90 | messageInput.addEventListener('blur', () => { 91 | // 输入框失去焦点时,移除点击事件监听 92 | messageInput.removeEventListener('click', (e) => e.stopPropagation()); 93 | }); 94 | 95 | // 处理换行和输入 96 | messageInput.addEventListener('compositionstart', () => { 97 | isComposing = true; 98 | }); 99 | 100 | messageInput.addEventListener('compositionend', () => { 101 | isComposing = false; 102 | }); 103 | 104 | messageInput.addEventListener('keydown', function(e) { 105 | if (e.key === 'Enter' && !e.shiftKey) { 106 | if (isComposing) { 107 | // 如果正在使用输入法,不发送消息 108 | return; 109 | } 110 | e.preventDefault(); 111 | const text = this.textContent.trim(); 112 | if (text || this.querySelector('.image-tag')) { // 检查是否有文本或图片 113 | sendMessage(); 114 | } 115 | } else if (e.key === 'Escape') { 116 | // 按 ESC 键时让输入框失去焦点 117 | messageInput.blur(); 118 | } else if (e.key === 'ArrowUp' && e.target.textContent.trim() === '') { 119 | // 处理输入框特定的键盘事件 120 | // 当按下向上键且输入框为空时 121 | e.preventDefault(); // 阻止默认行为 122 | 123 | // 如果有历史记录 124 | if (userQuestions.length > 0) { 125 | // 获取最后一个问题 126 | e.target.textContent = userQuestions[userQuestions.length - 1]; 127 | // 触发入事件以调整高度 128 | e.target.dispatchEvent(new Event('input', { bubbles: true })); 129 | // 移动光标到末尾 130 | moveCaretToEnd(e.target); 131 | } 132 | } else if ((e.key === 'Backspace' || e.key === 'Delete')) { 133 | // 处理图片标签的删除 134 | const selection = window.getSelection(); 135 | if (selection.rangeCount === 0) return; 136 | 137 | const range = selection.getRangeAt(0); 138 | const startContainer = range.startContainer; 139 | 140 | // 检查是否在图片标签旁边 141 | if (startContainer.nodeType === Node.TEXT_NODE && startContainer.textContent === '') { 142 | const previousSibling = startContainer.previousSibling; 143 | if (previousSibling && previousSibling.classList?.contains('image-tag')) { 144 | e.preventDefault(); 145 | previousSibling.remove(); 146 | 147 | // 移除可能存在的多余换行 148 | const brElements = messageInput.getElementsByTagName('br'); 149 | Array.from(brElements).forEach(br => { 150 | if (!br.nextSibling || (br.nextSibling.nodeType === Node.TEXT_NODE && br.nextSibling.textContent.trim() === '')) { 151 | br.remove(); 152 | } 153 | }); 154 | 155 | // 触发输入事件以调整高度 156 | messageInput.dispatchEvent(new Event('input')); 157 | } 158 | } 159 | } 160 | }); 161 | 162 | // 粘贴事件处理 163 | messageInput.addEventListener('paste', async (e) => { 164 | e.preventDefault(); // 阻止默认粘贴行为 165 | 166 | const items = Array.from(e.clipboardData.items); 167 | const imageItem = items.find(item => item.type.startsWith('image/')); 168 | 169 | if (imageItem) { 170 | // 处理图片粘贴 171 | const file = imageItem.getAsFile(); 172 | const reader = new FileReader(); 173 | 174 | reader.onload = async () => { 175 | const base64Data = reader.result; 176 | const imageTag = createImageTag({ 177 | base64Data, 178 | fileName: file.name, 179 | config: uiConfig.imageTag 180 | }); 181 | 182 | // 在光标位置插入图片标签 183 | const selection = window.getSelection(); 184 | const range = selection.getRangeAt(0); 185 | range.deleteContents(); 186 | range.insertNode(imageTag); 187 | 188 | // 移动光标到图片标签后面,并确保不会插入额外的换行 189 | const newRange = document.createRange(); 190 | newRange.setStartAfter(imageTag); 191 | newRange.collapse(true); 192 | selection.removeAllRanges(); 193 | selection.addRange(newRange); 194 | 195 | // 移除可能存在的多余行 196 | const brElements = messageInput.getElementsByTagName('br'); 197 | Array.from(brElements).forEach(br => { 198 | if (br.previousSibling && br.previousSibling.classList && br.previousSibling.classList.contains('image-tag')) { 199 | br.remove(); 200 | } 201 | }); 202 | 203 | // 触发输入事件以调整高度 204 | messageInput.dispatchEvent(new Event('input')); 205 | }; 206 | 207 | reader.readAsDataURL(file); 208 | } else { 209 | // 处理文本粘贴 210 | const text = e.clipboardData.getData('text/plain'); 211 | document.execCommand('insertText', false, text); 212 | } 213 | }); 214 | 215 | // 拖放事件监听器 216 | messageInput.addEventListener('dragover', (e) => { 217 | e.preventDefault(); 218 | e.stopPropagation(); 219 | }); 220 | 221 | messageInput.addEventListener('dragleave', (e) => { 222 | e.preventDefault(); 223 | e.stopPropagation(); 224 | }); 225 | 226 | messageInput.addEventListener('drop', (e) => { 227 | handleImageDrop(e, { 228 | messageInput, 229 | createImageTag, 230 | onSuccess: () => { 231 | // 成功处理后的回调 232 | }, 233 | onError: (error) => { 234 | console.error('处理拖放事件失败:', error); 235 | } 236 | }); 237 | }); 238 | } 239 | 240 | /** 241 | * 设置消息输入框的 placeholder 242 | * @param {Object} params - 参数对象 243 | * @param {HTMLElement} params.messageInput - 消息输入框元素 244 | * @param {string} params.placeholder - placeholder 文本 245 | * @param {number} [params.timeout] - 超时时间(可选),超时后恢复默认 placeholder 246 | */ 247 | export function setPlaceholder({ messageInput, placeholder, timeout }) { 248 | if (messageInput) { 249 | messageInput.setAttribute('placeholder', placeholder); 250 | if (timeout) { 251 | setTimeout(() => { 252 | messageInput.setAttribute('placeholder', '输入消息...'); 253 | }, timeout); 254 | } 255 | } 256 | } 257 | 258 | /** 259 | * 获取格式化后的消息内容(处理HTML转义和图片) 260 | * @param {HTMLElement} messageInput - 消息输入框元素 261 | * @returns {Object} 格式化后的内容和图片标签 262 | */ 263 | export function getFormattedMessageContent(messageInput) { 264 | // 使用innerHTML获取内容,并将
转换为\n 265 | let message = messageInput.innerHTML 266 | .replace(/

<\/div>/g, '\n') // 处理换行后的空行 267 | .replace(/
/g, '\n') // 处理换行后的新行开始 268 | .replace(/<\/div>/g, '') // 处理换行后的新行结束 269 | .replace(//g, '\n') // 处理单个换行 270 | .replace(/ /g, ' '); // 处理空格 271 | 272 | // 将HTML实体转换回实际字符 273 | const tempDiv = document.createElement('div'); 274 | tempDiv.innerHTML = message; 275 | message = tempDiv.textContent; 276 | 277 | // 获取图片标签 278 | const imageTags = messageInput.querySelectorAll('.image-tag'); 279 | 280 | return { message, imageTags }; 281 | } 282 | 283 | /** 284 | * 构建消息内容对象(文本+图片) 285 | * @param {string} message - 消息文本 286 | * @param {NodeList} imageTags - 图片标签节点列表 287 | * @returns {string|Array} 格式化后的消息内容 288 | */ 289 | export function buildMessageContent(message, imageTags) { 290 | if (imageTags.length > 0) { 291 | const content = []; 292 | if (message.trim()) { 293 | content.push({ 294 | type: "text", 295 | text: message 296 | }); 297 | } 298 | imageTags.forEach(tag => { 299 | const base64Data = tag.getAttribute('data-image'); 300 | if (base64Data) { 301 | content.push({ 302 | type: "image_url", 303 | image_url: { 304 | url: base64Data 305 | } 306 | }); 307 | } 308 | }); 309 | return content; 310 | } else { 311 | return message; 312 | } 313 | } 314 | 315 | /** 316 | * 清空输入框 317 | * @param {HTMLElement} messageInput - 消息输入框元素 318 | * @param {Object} config - UI配置 319 | */ 320 | export function clearMessageInput(messageInput, config) { 321 | messageInput.innerHTML = ''; 322 | adjustTextareaHeight({ 323 | textarea: messageInput, 324 | config: config.textarea 325 | }); 326 | } 327 | 328 | /** 329 | * 将光标移动到元素末尾 330 | * @param {HTMLElement} element - 要操作的元素 331 | */ 332 | function moveCaretToEnd(element) { 333 | const range = document.createRange(); 334 | range.selectNodeContents(element); 335 | range.collapse(false); 336 | const selection = window.getSelection(); 337 | selection.removeAllRanges(); 338 | selection.addRange(range); 339 | } 340 | 341 | /** 342 | * 处理消息输入组件的窗口消息 343 | * @param {MessageEvent} event - 消息事件对象 344 | * @param {Object} config - 配置对象 345 | */ 346 | export function handleWindowMessage(event, config) { 347 | const { messageInput, newChatButton, uiConfig } = config; 348 | 349 | if (event.data.type === 'DROP_IMAGE') { 350 | const imageData = event.data.imageData; 351 | if (imageData && imageData.data) { 352 | // 确保base64数据格式正确 353 | const base64Data = imageData.data.startsWith('data:') ? imageData.data : `data:image/png;base64,${imageData.data}`; 354 | const imageTag = createImageTag({ 355 | base64Data: base64Data, 356 | fileName: imageData.name, 357 | config: uiConfig.imageTag 358 | }); 359 | 360 | // 确保输入框有焦点 361 | messageInput.focus(); 362 | 363 | // 获取或创建选区 364 | const selection = window.getSelection(); 365 | let range; 366 | 367 | // 检查是否有现有选区 368 | if (selection.rangeCount > 0) { 369 | range = selection.getRangeAt(0); 370 | } else { 371 | // 创建新的选区 372 | range = document.createRange(); 373 | // 将选区设置到输入框的末尾 374 | range.selectNodeContents(messageInput); 375 | range.collapse(false); 376 | selection.removeAllRanges(); 377 | selection.addRange(range); 378 | } 379 | 380 | // 插入图片标签 381 | range.deleteContents(); 382 | range.insertNode(imageTag); 383 | 384 | // 移动光标到图片标签后面 385 | const newRange = document.createRange(); 386 | newRange.setStartAfter(imageTag); 387 | newRange.collapse(true); 388 | selection.removeAllRanges(); 389 | selection.addRange(newRange); 390 | 391 | // 触发输入事件以调整高度 392 | messageInput.dispatchEvent(new Event('input')); 393 | } 394 | } else if (event.data.type === 'FOCUS_INPUT') { 395 | messageInput.focus(); 396 | const range = document.createRange(); 397 | range.selectNodeContents(messageInput); 398 | range.collapse(false); 399 | const selection = window.getSelection(); 400 | selection.removeAllRanges(); 401 | selection.addRange(range); 402 | } else if (event.data.type === 'UPDATE_PLACEHOLDER') { 403 | setPlaceholder({ 404 | messageInput, 405 | placeholder: event.data.placeholder, 406 | timeout: event.data.timeout 407 | }); 408 | } else if (event.data.type === 'NEW_CHAT') { 409 | // 模拟点击新对话按钮 410 | newChatButton.click(); 411 | messageInput.focus(); 412 | } 413 | } -------------------------------------------------------------------------------- /src/handlers/message-handler.js: -------------------------------------------------------------------------------- 1 | import { chatManager } from '../utils/chat-manager.js'; 2 | import { showImagePreview, createImageTag } from '../utils/ui.js'; 3 | import { processMathAndMarkdown, renderMathInElement } from '../../htmd/latex.js'; 4 | 5 | /** 6 | * 消息接口 7 | * @typedef {Object} Message 8 | * @property {string} role - 消息角色 ("user" | "assistant") 9 | * @property {string | Array<{type: string, text?: string, image_url?: {url: string}}>} content - 消息内容 10 | */ 11 | 12 | /** 13 | * 添加消息到聊天界面 14 | * @param {Object} params - 参数对象 15 | * @param {Object|string} params.text - 消息文本内容,可以是字符串或包含content和reasoning_content的对象 16 | * @param {string} params.sender - 发送者类型 ("user" | "assistant") 17 | * @param {HTMLElement} params.chatContainer - 聊天容器元素 18 | * @param {boolean} [params.skipHistory=false] - 是否跳过历史记录 19 | * @param {DocumentFragment} [params.fragment=null] - 文档片段(用于批量加载) 20 | * @returns {HTMLElement} 创建的消息元素 21 | */ 22 | export async function appendMessage({ 23 | text, 24 | sender, 25 | chatContainer, 26 | skipHistory = false, 27 | fragment = null 28 | }) { 29 | const messageDiv = document.createElement('div'); 30 | messageDiv.className = `message ${sender}-message`; 31 | 32 | // 如果是批量加载,添加特殊类名 33 | if (fragment) { 34 | messageDiv.classList.add('batch-load'); 35 | } 36 | 37 | // 处理文本内容 38 | let textContent = typeof text === 'string' ? text : text.content; 39 | 40 | const previewModal = document.querySelector('.image-preview-modal'); 41 | const previewImage = previewModal.querySelector('img'); 42 | const messageInput = document.getElementById('message-input'); 43 | 44 | let messageHtml = ''; 45 | if (Array.isArray(textContent)) { 46 | textContent.forEach(item => { 47 | if (item.type === "text") { 48 | messageHtml += item.text; 49 | textContent = item.text; 50 | } else if (item.type === "image_url") { 51 | const imageTag = createImageTag({ 52 | base64Data: item.image_url.url, 53 | config: { 54 | onImageClick: (base64Data) => { 55 | showImagePreview({ 56 | base64Data, 57 | config: { 58 | previewModal, 59 | previewImage 60 | } 61 | }); 62 | }, 63 | onDeleteClick: (container) => { 64 | container.remove(); 65 | messageInput.dispatchEvent(new Event('input')); 66 | } 67 | } 68 | }); 69 | messageHtml += imageTag.outerHTML; 70 | } 71 | }); 72 | } else { 73 | messageHtml = textContent; 74 | } 75 | 76 | // 如果是用户消息,且当前对话只有这一条消息,则更新对话标题 77 | if (sender === 'user' && !skipHistory) { 78 | const currentChat = chatManager.getCurrentChat(); 79 | if (currentChat && currentChat.messages.length === 0) { 80 | currentChat.title = textContent; 81 | chatManager.saveChats(); 82 | } 83 | } 84 | 85 | const reasoningContent = typeof text === 'string' ? null : text.reasoning_content; 86 | 87 | // 存储原始文本用于复制 88 | messageDiv.setAttribute('data-original-text', textContent); 89 | 90 | // 如果有思考内容,添加思考模块 91 | if (reasoningContent) { 92 | const reasoningWrapper = document.createElement('div'); 93 | reasoningWrapper.className = 'reasoning-wrapper'; 94 | 95 | const reasoningDiv = document.createElement('div'); 96 | reasoningDiv.className = 'reasoning-content'; 97 | 98 | // 添加占位文本容器 99 | const placeholderDiv = document.createElement('div'); 100 | placeholderDiv.className = 'reasoning-placeholder'; 101 | placeholderDiv.textContent = '深度思考'; 102 | reasoningDiv.appendChild(placeholderDiv); 103 | 104 | // 添加文本容器 105 | const reasoningTextDiv = document.createElement('div'); 106 | reasoningTextDiv.className = 'reasoning-text'; 107 | reasoningTextDiv.innerHTML = processMathAndMarkdown(reasoningContent).trim(); 108 | reasoningDiv.appendChild(reasoningTextDiv); 109 | 110 | // 添加点击事件处理折叠/展开 111 | reasoningDiv.onclick = function() { 112 | this.classList.toggle('collapsed'); 113 | }; 114 | 115 | reasoningWrapper.appendChild(reasoningDiv); 116 | messageDiv.appendChild(reasoningWrapper); 117 | } 118 | 119 | // 添加主要内容 120 | const mainContent = document.createElement('div'); 121 | mainContent.className = 'main-content'; 122 | mainContent.innerHTML = processMathAndMarkdown(messageHtml); 123 | messageDiv.appendChild(mainContent); 124 | 125 | // 渲染 LaTeX 公式 126 | try { 127 | await renderMathInElement(messageDiv); 128 | } catch (err) { 129 | console.error('渲染LaTeX公式失败:', err); 130 | } 131 | 132 | // 处理消息中的链接 133 | messageDiv.querySelectorAll('a').forEach(link => { 134 | link.target = '_blank'; 135 | link.rel = 'noopener noreferrer'; 136 | }); 137 | 138 | // 处理消息中的图片标签 139 | messageDiv.querySelectorAll('.image-tag').forEach(tag => { 140 | const img = tag.querySelector('img'); 141 | const base64Data = tag.getAttribute('data-image'); 142 | if (img && base64Data) { 143 | img.addEventListener('click', (e) => { 144 | e.preventDefault(); 145 | e.stopPropagation(); 146 | showImagePreview({ 147 | base64Data, 148 | config: { 149 | previewModal, 150 | previewImage 151 | } 152 | }); 153 | }); 154 | } 155 | }); 156 | 157 | // 如果提供了文档片段,添加到片段中;否则直接添加到聊天容器 158 | if (fragment) { 159 | fragment.appendChild(messageDiv); 160 | } else { 161 | chatContainer.appendChild(messageDiv); 162 | // 只在发送新消息时自动滚动(不是加载历史记录) 163 | if (sender === 'user' && !skipHistory) { 164 | requestAnimationFrame(() => { 165 | chatContainer.scrollTo({ 166 | top: chatContainer.scrollHeight, 167 | behavior: 'smooth' 168 | }); 169 | }); 170 | } 171 | } 172 | 173 | // 只有在不跳过历史记录时才添加到历史记录 174 | if (!skipHistory) { 175 | if (sender === 'ai') { 176 | messageDiv.classList.add('updating'); 177 | } 178 | } 179 | 180 | return messageDiv; 181 | } 182 | 183 | /** 184 | * 更新AI消息内容 185 | * @param {Object} params - 参数对象 186 | * @param {Object} params.text - 新的消息文本对象,包含content和reasoningContent 187 | * @param {string} params.text.content - 主要消息内容 188 | * @param {string|null} params.text.reasoning_content - 深度思考内容 189 | * @param {HTMLElement} params.chatContainer - 聊天容器元素 190 | * @returns {Promise} 返回是否成功更新了消息 191 | */ 192 | export async function updateAIMessage({ 193 | text, 194 | chatContainer 195 | }) { 196 | let lastMessage = chatContainer.querySelector('.message:last-child'); 197 | const currentText = lastMessage.getAttribute('data-original-text') || ''; 198 | 199 | 200 | // 处理文本内容 201 | const textContent = typeof text === 'string' ? text : text.content; 202 | const reasoningContent = typeof text === 'string' ? null : text.reasoning_content; 203 | 204 | // 如果新文本的开头与当前文本不一致,则认为消息不连续,置空lastMessage 205 | if (!textContent.startsWith(currentText) && currentText !== '') { 206 | lastMessage = null; 207 | } 208 | 209 | if (lastMessage && lastMessage.classList.contains('ai-message')) { 210 | // 获取当前显示的文本 211 | // 如果新文本比当前文本长,说有新内容需要更新 212 | if (textContent.length > currentText.length || reasoningContent) { 213 | // 更新原始文本属性 214 | lastMessage.setAttribute('data-original-text', textContent); 215 | 216 | // 处理深度思考内容 217 | let reasoningDiv = lastMessage.querySelector('.reasoning-content'); 218 | if (reasoningContent) { 219 | if (!reasoningDiv) { 220 | const reasoningWrapper = document.createElement('div'); 221 | reasoningWrapper.className = 'reasoning-wrapper'; 222 | 223 | reasoningDiv = document.createElement('div'); 224 | reasoningDiv.className = 'reasoning-content'; 225 | 226 | // 添加占位文本容器 227 | const placeholderDiv = document.createElement('div'); 228 | placeholderDiv.className = 'reasoning-placeholder'; 229 | placeholderDiv.textContent = '深度思考'; 230 | reasoningDiv.appendChild(placeholderDiv); 231 | 232 | // 添加文本容器 233 | const reasoningTextDiv = document.createElement('div'); 234 | reasoningTextDiv.className = 'reasoning-text'; 235 | reasoningDiv.appendChild(reasoningTextDiv); 236 | 237 | // 添加点击事件处理折叠/展开 238 | reasoningDiv.onclick = function() { 239 | this.classList.toggle('collapsed'); 240 | }; 241 | 242 | reasoningWrapper.appendChild(reasoningDiv); 243 | 244 | // 确保深度思考模块在最上方 245 | if (lastMessage.firstChild) { 246 | lastMessage.insertBefore(reasoningWrapper, lastMessage.firstChild); 247 | } else { 248 | lastMessage.appendChild(reasoningWrapper); 249 | } 250 | } 251 | 252 | // 获取或创建文本容器 253 | let reasoningTextDiv = reasoningDiv.querySelector('.reasoning-text'); 254 | if (!reasoningTextDiv) { 255 | reasoningTextDiv = document.createElement('div'); 256 | reasoningTextDiv.className = 'reasoning-text'; 257 | reasoningDiv.appendChild(reasoningTextDiv); 258 | } 259 | 260 | // 获取当前显示的文本 261 | const currentReasoningText = reasoningTextDiv.getAttribute('data-original-text') || ''; 262 | 263 | // 如果新文本比当前文本长,说明有新内容需要更新 264 | if (reasoningContent.length > currentReasoningText.length) { 265 | // 更新原始文本属性 266 | reasoningTextDiv.setAttribute('data-original-text', reasoningContent); 267 | // 更新显示内容 268 | reasoningTextDiv.innerHTML = processMathAndMarkdown(reasoningContent).trim(); 269 | await renderMathInElement(reasoningTextDiv); 270 | } 271 | } 272 | 273 | // 处理主要内容 274 | const mainContent = document.createElement('div'); 275 | mainContent.className = 'main-content'; 276 | mainContent.innerHTML = processMathAndMarkdown(textContent); 277 | 278 | // 清除原有的主要内容 279 | Array.from(lastMessage.children).forEach(child => { 280 | if (!child.classList.contains('reasoning-wrapper')) { 281 | child.remove(); 282 | } 283 | }); 284 | 285 | // 将主要内容添加到深度思考模块之后 286 | const reasoningWrapper = lastMessage.querySelector('.reasoning-wrapper'); 287 | if (reasoningWrapper) { 288 | lastMessage.insertBefore(mainContent, reasoningWrapper.nextSibling); 289 | } else { 290 | lastMessage.appendChild(mainContent); 291 | } 292 | 293 | // 渲染LaTeX公式 294 | await renderMathInElement(mainContent); 295 | 296 | // 处理新染的链接 297 | lastMessage.querySelectorAll('a').forEach(link => { 298 | link.target = '_blank'; 299 | link.rel = 'noopener noreferrer'; 300 | }); 301 | 302 | return true; 303 | } 304 | return true; // 如果文本没有变长,也认为是成功的 305 | } else { 306 | // 创建新消息时也需要包含思考内容 307 | // console.log('updateAIMessage'); 308 | await appendMessage({ 309 | text: { 310 | content: textContent, 311 | reasoning_content: reasoningContent 312 | }, 313 | sender: 'ai', 314 | chatContainer 315 | }); 316 | return true; 317 | } 318 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { setTheme } from './utils/theme.js'; 2 | import { callAPI } from './services/chat.js'; 3 | import { chatManager } from './utils/chat-manager.js'; 4 | import { appendMessage } from './handlers/message-handler.js'; 5 | import { hideContextMenu } from './components/context-menu.js'; 6 | import { initChatContainer } from './components/chat-container.js'; 7 | import { showImagePreview, hideImagePreview } from './utils/ui.js'; 8 | import { renderAPICards, createCardCallbacks, selectCard } from './components/api-card.js'; 9 | import { storageAdapter, syncStorageAdapter, browserAdapter, isExtensionEnvironment } from './utils/storage-adapter.js'; 10 | import { initMessageInput, getFormattedMessageContent, buildMessageContent, clearMessageInput, handleWindowMessage } from './components/message-input.js'; 11 | import './utils/viewport.js'; 12 | import { 13 | hideChatList, 14 | initChatListEvents, 15 | loadChatContent, 16 | initializeChatList, 17 | renderChatList 18 | } from './components/chat-list.js'; 19 | 20 | // 存储用户的问题历史 21 | let userQuestions = []; 22 | 23 | document.addEventListener('DOMContentLoaded', async () => { 24 | const chatContainer = document.getElementById('chat-container'); 25 | const messageInput = document.getElementById('message-input'); 26 | const contextMenu = document.getElementById('context-menu'); 27 | const copyMessageButton = document.getElementById('copy-message'); 28 | const copyCodeButton = document.getElementById('copy-code'); 29 | const stopUpdateButton = document.getElementById('stop-update'); 30 | const settingsButton = document.getElementById('settings-button'); 31 | const settingsMenu = document.getElementById('settings-menu'); 32 | const feedbackButton = document.getElementById('feedback-button'); 33 | const previewModal = document.querySelector('.image-preview-modal'); 34 | const previewImage = previewModal.querySelector('img'); 35 | const chatListPage = document.getElementById('chat-list-page'); 36 | const newChatButton = document.getElementById('new-chat'); 37 | const chatListButton = document.getElementById('chat-list'); 38 | const apiSettings = document.getElementById('api-settings'); 39 | const deleteMessageButton = document.getElementById('delete-message'); 40 | const webpageSwitch = document.getElementById('webpage-switch'); 41 | 42 | // 修改: 创建一个对象引用来保存当前控制器 43 | const abortControllerRef = { current: null }; 44 | let currentController = null; 45 | 46 | // 创建UI工具配置 47 | const uiConfig = { 48 | textarea: { 49 | maxHeight: 200 50 | }, 51 | imagePreview: { 52 | previewModal, 53 | previewImage 54 | }, 55 | imageTag: { 56 | onImageClick: (base64Data) => { 57 | showImagePreview({ 58 | base64Data, 59 | config: uiConfig.imagePreview 60 | }); 61 | }, 62 | onDeleteClick: (container) => { 63 | container.remove(); 64 | messageInput.dispatchEvent(new Event('input')); 65 | } 66 | } 67 | }; 68 | 69 | // 添加反馈按钮点击事件 70 | feedbackButton.addEventListener('click', () => { 71 | const newIssueUrl = 'https://github.com/yym68686/Cerebr/issues/new'; 72 | window.open(newIssueUrl, '_blank'); 73 | settingsMenu.classList.remove('visible'); // 使用 classList 来正确切换菜单状态 74 | }); 75 | 76 | // 初始化聊天容器 77 | const chatContainerManager = initChatContainer({ 78 | chatContainer, 79 | messageInput, 80 | contextMenu, 81 | userQuestions, 82 | chatManager 83 | }); 84 | 85 | // 设置按钮事件处理 86 | chatContainerManager.setupButtonHandlers({ 87 | copyMessageButton, 88 | copyCodeButton, 89 | stopUpdateButton, 90 | deleteMessageButton, 91 | abortController: abortControllerRef 92 | }); 93 | 94 | // 初始化消息输入组件 95 | initMessageInput({ 96 | messageInput, 97 | sendMessage, 98 | userQuestions, 99 | contextMenu, 100 | hideContextMenu: hideContextMenu.bind(null, { 101 | contextMenu, 102 | onMessageElementReset: () => { /* 清空引用 */ } 103 | }), 104 | uiConfig, 105 | settingsMenu 106 | }); 107 | 108 | // 初始化ChatManager 109 | await chatManager.initialize(); 110 | 111 | // 初始化用户问题历史 112 | chatContainerManager.initializeUserQuestions(); 113 | 114 | // 初始化对话列表组件 115 | initChatListEvents({ 116 | chatListPage, 117 | chatCards: chatListPage.querySelector('.chat-cards'), 118 | chatManager, 119 | loadChatContent: (chat) => loadChatContent(chat, chatContainer), 120 | onHide: hideChatList.bind(null, chatListPage) 121 | }); 122 | 123 | // 初始化聊天列表功能 124 | initializeChatList({ 125 | chatListPage, 126 | chatManager, 127 | newChatButton, 128 | chatListButton, 129 | settingsMenu, 130 | apiSettings, 131 | loadChatContent: (chat) => loadChatContent(chat, chatContainer) 132 | }); 133 | 134 | // 加载当前对话内容 135 | const currentChat = chatManager.getCurrentChat(); 136 | if (currentChat) { 137 | await loadChatContent(currentChat, chatContainer); 138 | } 139 | 140 | // 网答功能 141 | const webpageQAContainer = document.getElementById('webpage-qa'); 142 | 143 | // 如果不是扩展环境,隐藏网页问答功能 144 | if (!isExtensionEnvironment) { 145 | webpageQAContainer.style.display = 'none'; 146 | } 147 | 148 | let pageContent = null; 149 | 150 | // 获取网页内容 151 | async function getPageContent(skipWaitContent = false) { 152 | try { 153 | // console.log('getPageContent 发送获取网页内容请求'); 154 | const response = await browserAdapter.sendMessage({ 155 | type: 'GET_PAGE_CONTENT_FROM_SIDEBAR', 156 | skipWaitContent: skipWaitContent // 传递是否跳过等待内容加载的参数 157 | }); 158 | return response; 159 | } catch (error) { 160 | console.error('获取网页内容失败:', error); 161 | return null; 162 | } 163 | } 164 | 165 | // 修改 saveWebpageSwitch 函数,改进存储和错误处理 166 | async function saveWebpageSwitch(domain, enabled) { 167 | console.log('开始保存网页问答开关状态:', domain, enabled); 168 | 169 | try { 170 | const result = await storageAdapter.get('webpageSwitchDomains'); 171 | let domains = result.webpageSwitchDomains || {}; 172 | 173 | // 只在状态发生变化时才更新 174 | if (domains[domain] !== enabled) { 175 | domains[domain] = enabled; 176 | await storageAdapter.set({ webpageSwitchDomains: domains }); 177 | console.log('网页问答状态已保存:', domain, enabled); 178 | } 179 | } catch (error) { 180 | console.error('保存网页问答状态失败:', error, domain, enabled); 181 | } 182 | } 183 | 184 | // 获取当前域名 185 | async function getCurrentDomain() { 186 | try { 187 | const tab = await browserAdapter.getCurrentTab(); 188 | if (!tab) return null; 189 | 190 | // 如果是本地文件,直接返回hostname 191 | if (tab.hostname === 'local_pdf') { 192 | return tab.hostname; 193 | } 194 | 195 | // 处理普通URL 196 | const hostname = tab.hostname; 197 | // 规范化域名 198 | const normalizedDomain = hostname 199 | .replace(/^www\./, '') // 移除www前缀 200 | .toLowerCase(); // 转换为小写 201 | 202 | // console.log('规范化域名:', hostname, '->', normalizedDomain); 203 | return normalizedDomain; 204 | } catch (error) { 205 | // console.error('获取当前域名失败:', error); 206 | return null; 207 | } 208 | } 209 | 210 | // 修改网页问答开关监听器 211 | webpageSwitch.addEventListener('change', async () => { 212 | try { 213 | const domain = await getCurrentDomain(); 214 | console.log('网页问答开关状态改变后,获取当前域名:', domain); 215 | 216 | if (!domain) { 217 | console.log('无法获取域名,保持开关状态不变'); 218 | webpageSwitch.checked = !webpageSwitch.checked; // 恢复开关状态 219 | return; 220 | } 221 | 222 | console.log('网页问答开关状态改变后,获取网页问答开关状态:', webpageSwitch.checked); 223 | 224 | if (webpageSwitch.checked) { 225 | document.body.classList.add('loading-content'); 226 | 227 | try { 228 | const content = await getPageContent(); 229 | if (content) { 230 | pageContent = content; 231 | await saveWebpageSwitch(domain, true); 232 | console.log('修改网页问答为已开启'); 233 | } else { 234 | console.error('获取网页内容失败。'); 235 | } 236 | } catch (error) { 237 | console.error('获取网页内容失败:', error); 238 | } finally { 239 | document.body.classList.remove('loading-content'); 240 | } 241 | } else { 242 | pageContent = null; 243 | await saveWebpageSwitch(domain, false); 244 | console.log('修改网页问答为已关闭'); 245 | } 246 | } catch (error) { 247 | console.error('处理网页问答开关变化失败:', error); 248 | webpageSwitch.checked = !webpageSwitch.checked; // 恢复开关状态 249 | } 250 | }); 251 | 252 | // 监听来自 content script 的消息 253 | window.addEventListener('message', (event) => { 254 | // 使用消息输入组件的窗口消息处理函数 255 | handleWindowMessage(event, { 256 | messageInput, 257 | newChatButton, 258 | uiConfig 259 | }); 260 | 261 | // 处理URL变化事件,因为这涉及到网页问答功能,保留在main.js中 262 | if (event.data.type === 'URL_CHANGED') { 263 | console.log('sidebar.js [收到URL变化]', event.data.url); 264 | if (webpageSwitch.checked) { 265 | console.log('[网页问答] URL变化,重新获取页面内容'); 266 | document.body.classList.add('loading-content'); 267 | 268 | getPageContent() 269 | .then(async content => { 270 | if (content) { 271 | pageContent = content; 272 | const domain = await getCurrentDomain(); 273 | if (domain) { 274 | await saveWebpageSwitch(domain, true); 275 | } 276 | } else { 277 | console.error('URL_CHANGED 无法获取网页内容'); 278 | } 279 | }) 280 | .catch(async error => { 281 | console.error('URL_CHANGED 获取网页内容失败:', error); 282 | }) 283 | .finally(() => { 284 | document.body.classList.remove('loading-content'); 285 | }); 286 | } 287 | } 288 | }); 289 | 290 | // 修改 loadWebpageSwitch 函数 291 | async function loadWebpageSwitch(call_name = 'loadWebpageSwitch') { 292 | // console.log(`loadWebpageSwitch 从 ${call_name} 调用`); 293 | 294 | try { 295 | const domain = await getCurrentDomain(); 296 | // console.log('刷新后 网页问答 获取当前域名:', domain); 297 | if (!domain) return; 298 | 299 | const result = await storageAdapter.get('webpageSwitchDomains'); 300 | const domains = result.webpageSwitchDomains || {}; 301 | // console.log('刷新后 网页问答存储中获取域名:', domains); 302 | 303 | // 只在开关状态不一致时才更新 304 | if (domains[domain]) { 305 | webpageSwitch.checked = domains[domain]; 306 | // 检查当前标签页是否活跃 307 | const isTabActive = await browserAdapter.sendMessage({ 308 | type: 'CHECK_TAB_ACTIVE' 309 | }); 310 | 311 | if (isTabActive) { 312 | setTimeout(async () => { 313 | try { 314 | const content = await getPageContent(); 315 | if (content) { 316 | pageContent = content; 317 | } 318 | } catch (error) { 319 | console.error('loadWebpageSwitch 获取网页内容失败:', error); 320 | } 321 | }, 0); 322 | } 323 | } else { 324 | webpageSwitch.checked = false; 325 | // console.log('loadWebpageSwitch 域名不在存储中:', domain); 326 | } 327 | } catch (error) { 328 | console.error('加载网页问答状态失败:', error); 329 | } 330 | } 331 | 332 | // 在 DOMContentLoaded 事件处理程序中添加加载网页问答状态 333 | await loadWebpageSwitch(); 334 | 335 | async function sendMessage() { 336 | // 如果有正在更新的AI消息,停止它 337 | const updatingMessage = chatContainer.querySelector('.ai-message.updating'); 338 | if (updatingMessage && currentController) { 339 | currentController.abort(); 340 | currentController = null; 341 | abortControllerRef.current = null; // 同步更新引用对象 342 | updatingMessage.classList.remove('updating'); 343 | } 344 | 345 | // 获取格式化后的消息内容 346 | const { message, imageTags } = getFormattedMessageContent(messageInput); 347 | 348 | if (!message.trim() && imageTags.length === 0) return; 349 | 350 | try { 351 | // 如果网页问答功能开启,重新获取页面内容,不等待内容加载 352 | if (webpageSwitch.checked) { 353 | // console.log('发送消息时网页问答已打开,重新获取页面内容'); 354 | try { 355 | const content = await getPageContent(true); // 跳过等待内容加载 356 | if (content) { 357 | pageContent = content; 358 | console.log('成功更新 pageContent 内容'); 359 | } 360 | } catch (error) { 361 | console.error('发送消息时获取页面内容失败:', error); 362 | } 363 | } 364 | 365 | // 构建消息内容 366 | const content = buildMessageContent(message, imageTags); 367 | 368 | // 构建用户消息 369 | const userMessage = { 370 | role: "user", 371 | content: content 372 | }; 373 | 374 | // 先添加用户消息到界面和历史记录 375 | appendMessage({ 376 | text: userMessage, 377 | sender: 'user', 378 | chatContainer, 379 | }); 380 | 381 | // 清空输入框并调整高度 382 | clearMessageInput(messageInput, uiConfig); 383 | 384 | // 构建消息数组 385 | const currentChat = chatManager.getCurrentChat(); 386 | const messages = currentChat ? [...currentChat.messages] : []; // 从chatManager获取消息历史 387 | messages.push(userMessage); 388 | chatManager.addMessageToCurrentChat(userMessage); 389 | 390 | // 准备API调用参数 391 | const apiParams = { 392 | messages, 393 | apiConfig: apiConfigs[selectedConfigIndex], 394 | userLanguage: navigator.language, 395 | webpageInfo: webpageSwitch.checked ? pageContent : null 396 | }; 397 | 398 | // 调用 API 399 | const { processStream, controller } = await callAPI(apiParams, chatManager, currentChat.id, chatContainerManager.syncMessage); 400 | currentController = controller; 401 | abortControllerRef.current = controller; // 同步更新引用对象 402 | 403 | // 处理流式响应 404 | await processStream(); 405 | 406 | } catch (error) { 407 | if (error.name === 'AbortError') { 408 | console.log('用户手动停止更新'); 409 | return; 410 | } 411 | console.error('发送消息失败:', error); 412 | appendMessage({ 413 | text: '发送失败: ' + error.message, 414 | sender: 'ai', 415 | chatContainer, 416 | skipHistory: true, 417 | }); 418 | // 从 chatHistory 中移除最后一条记录(用户的问题) 419 | const currentChat = chatManager.getCurrentChat(); 420 | const messages = currentChat ? [...currentChat.messages] : []; 421 | if (messages.length > 0) { 422 | if (messages[messages.length - 1].role === 'assistant') { 423 | chatManager.popMessage(); 424 | chatManager.popMessage(); 425 | } else { 426 | chatManager.popMessage(); 427 | } 428 | } 429 | } finally { 430 | const lastMessage = chatContainer.querySelector('.ai-message:last-child'); 431 | if (lastMessage) { 432 | lastMessage.classList.remove('updating'); 433 | } 434 | } 435 | } 436 | 437 | // 修改点击事件监听器 438 | document.addEventListener('click', (e) => { 439 | // 如果点击的不是设置按钮本身和设置菜单,就关闭菜单 440 | if (!settingsButton.contains(e.target) && !settingsMenu.contains(e.target)) { 441 | settingsMenu.classList.remove('visible'); 442 | } 443 | }); 444 | 445 | // 确保设置按钮的点击事件在文档点击事件之前处理 446 | settingsButton.addEventListener('click', (e) => { 447 | e.stopPropagation(); 448 | settingsMenu.classList.toggle('visible'); 449 | }); 450 | 451 | // 主题切换 452 | const themeSwitch = document.getElementById('theme-switch'); 453 | 454 | // 创建主题配置对象 455 | const themeConfig = { 456 | root: document.documentElement, 457 | themeSwitch, 458 | saveTheme: async (theme) => await syncStorageAdapter.set({ theme }) 459 | }; 460 | 461 | // 初始化主题 462 | async function initTheme() { 463 | try { 464 | const result = await syncStorageAdapter.get('theme'); 465 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 466 | const isDark = result.theme === 'dark' || (!result.theme && prefersDark); 467 | setTheme(isDark, themeConfig); 468 | } catch (error) { 469 | console.error('初始化主题失败:', error); 470 | // 如果出错,使用系统主题 471 | setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches, themeConfig); 472 | } 473 | } 474 | 475 | // 监听主题切换 476 | themeSwitch.addEventListener('change', () => { 477 | setTheme(themeSwitch.checked, themeConfig); 478 | }); 479 | 480 | // 监听系统主题变化 481 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async (e) => { 482 | const data = await syncStorageAdapter.get('theme'); 483 | if (!data.theme) { // 只有在用户没有手动设置主题时才跟随系统 484 | setTheme(e.matches, themeConfig); 485 | } 486 | }); 487 | 488 | // 初始化主题 489 | await initTheme(); 490 | 491 | // API 设置功能 492 | const apiSettingsToggle = document.getElementById('api-settings-toggle'); 493 | const backButton = document.querySelector('.back-button'); 494 | const apiCards = document.querySelector('.api-cards'); 495 | 496 | // 加载保存的 API 配置 497 | let apiConfigs = []; 498 | let selectedConfigIndex = 0; 499 | 500 | // 使用新的selectCard函数 501 | const handleCardSelect = (template, index) => { 502 | selectCard({ 503 | template, 504 | index, 505 | onIndexChange: (newIndex) => { 506 | selectedConfigIndex = newIndex; 507 | }, 508 | onSave: saveAPIConfigs, 509 | cardSelector: '.api-card', 510 | onSelect: () => { 511 | // 关闭API设置面板 512 | apiSettings.classList.remove('visible'); 513 | } 514 | }); 515 | }; 516 | 517 | // 创建渲染API卡片的辅助函数 518 | const renderAPICardsWithCallbacks = () => { 519 | renderAPICards({ 520 | apiConfigs, 521 | apiCardsContainer: apiCards, 522 | templateCard: document.querySelector('.api-card.template'), 523 | ...createCardCallbacks({ 524 | selectCard: handleCardSelect, 525 | apiConfigs, 526 | selectedConfigIndex, 527 | saveAPIConfigs, 528 | renderAPICardsWithCallbacks 529 | }), 530 | selectedIndex: selectedConfigIndex 531 | }); 532 | }; 533 | 534 | // 从存储加载配置 535 | async function loadAPIConfigs() { 536 | try { 537 | // 统一使用 syncStorageAdapter 来实现配置同步 538 | const result = await syncStorageAdapter.get(['apiConfigs', 'selectedConfigIndex']); 539 | 540 | // 分别检查每个配置项 541 | if (result.apiConfigs) { 542 | apiConfigs = result.apiConfigs; 543 | } else { 544 | apiConfigs = [{ 545 | apiKey: '', 546 | baseUrl: 'https://api.openai.com/v1/chat/completions', 547 | modelName: 'gpt-4o' 548 | }]; 549 | // 只有在没有任何配置的情况下才保存默认配置 550 | await saveAPIConfigs(); 551 | } 552 | 553 | // 只有当 selectedConfigIndex 为 undefined 或 null 时才使用默认值 0 554 | selectedConfigIndex = result.selectedConfigIndex ?? 0; 555 | 556 | // 确保一定会渲染卡片 557 | renderAPICardsWithCallbacks(); 558 | } catch (error) { 559 | console.error('加载 API 配置失败:', error); 560 | // 只有在出错的情况下才使用默认值 561 | apiConfigs = [{ 562 | apiKey: '', 563 | baseUrl: 'https://api.openai.com/v1/chat/completions', 564 | modelName: 'gpt-4o' 565 | }]; 566 | selectedConfigIndex = 0; 567 | renderAPICardsWithCallbacks(); 568 | } 569 | } 570 | 571 | // 监听标签页切换 572 | browserAdapter.onTabActivated(async () => { 573 | // console.log('标签页切换,重新加载API配置'); 574 | await loadWebpageSwitch(); 575 | // 同步API配置 576 | await loadAPIConfigs(); 577 | renderAPICardsWithCallbacks(); 578 | 579 | // 同步对话列表 580 | await chatManager.initialize(); 581 | await renderChatList( 582 | chatManager, 583 | chatListPage.querySelector('.chat-cards') 584 | ); 585 | }); 586 | // 保存配置到存储 587 | async function saveAPIConfigs() { 588 | try { 589 | // 统一使用 syncStorageAdapter 来实现配置同步 590 | await syncStorageAdapter.set({ 591 | apiConfigs, 592 | selectedConfigIndex 593 | }); 594 | } catch (error) { 595 | console.error('保存 API 配置失败:', error); 596 | } 597 | } 598 | 599 | // 等待 DOM 加载完成后再初始化 600 | await loadAPIConfigs(); 601 | 602 | // 显示/隐藏 API 设置 603 | apiSettingsToggle.addEventListener('click', () => { 604 | apiSettings.classList.add('visible'); 605 | settingsMenu.classList.remove('visible'); 606 | // 确保每次打开设置时都重新渲染卡片 607 | renderAPICardsWithCallbacks(); 608 | }); 609 | 610 | // 返回聊天界面 611 | backButton.addEventListener('click', () => { 612 | apiSettings.classList.remove('visible'); 613 | }); 614 | 615 | // 图片预览功能 616 | const closeButton = previewModal.querySelector('.image-preview-close'); 617 | 618 | closeButton.addEventListener('click', () => { 619 | hideImagePreview({ config: uiConfig.imagePreview }); 620 | }); 621 | 622 | previewModal.addEventListener('click', (e) => { 623 | if (e.target === previewModal) { 624 | hideImagePreview({ config: uiConfig.imagePreview }); 625 | } 626 | }); 627 | }); -------------------------------------------------------------------------------- /src/services/chat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API配置接口 3 | * @typedef {Object} APIConfig 4 | * @property {string} baseUrl - API的基础URL 5 | * @property {string} apiKey - API密钥 6 | * @property {string} [modelName] - 模型名称,默认为 "gpt-4o" 7 | */ 8 | 9 | /** 10 | * 网页信息接口 11 | * @typedef {Object} WebpageInfo 12 | * @property {string} title - 网页标题 13 | * @property {string} url - 网页URL 14 | * @property {string} content - 网页内容 15 | */ 16 | 17 | /** 18 | * 消息接口 19 | * @typedef {Object} Message 20 | * @property {string} role - 消息角色 ("system" | "user" | "assistant") 21 | * @property {string | Array<{type: string, text?: string, image_url?: {url: string}}>} content - 消息内容 22 | */ 23 | 24 | /** 25 | * API调用参数接口 26 | * @typedef {Object} APIParams 27 | * @property {Array} messages - 消息历史 28 | * @property {APIConfig} apiConfig - API配置 29 | * @property {string} userLanguage - 用户语言 30 | * @property {WebpageInfo} [webpageInfo] - 网页信息(可选) 31 | */ 32 | 33 | /** 34 | * 调用API发送消息并处理响应 35 | * @param {APIParams} params - API调用参数 36 | * @param {Object} chatManager - 聊天管理器实例 37 | * @param {string} chatId - 当前聊天ID 38 | * @param {Function} onMessageUpdate - 消息更新回调函数 39 | * @returns {Promise<{processStream: () => Promise<{content: string, reasoning_content: string}>, controller: AbortController}>} 40 | */ 41 | export async function callAPI({ 42 | messages, 43 | apiConfig, 44 | userLanguage, 45 | webpageInfo = null, 46 | }, chatManager, chatId, onMessageUpdate) { 47 | if (!apiConfig?.baseUrl || !apiConfig?.apiKey) { 48 | throw new Error('API 配置不完整'); 49 | } 50 | 51 | // 构建系统消息 52 | let systemPrompt = apiConfig.advancedSettings?.systemPrompt || ''; 53 | systemPrompt = systemPrompt.replace(/\{\{userLanguage\}\}/gm, userLanguage) 54 | 55 | const systemMessage = { 56 | role: "system", 57 | content: `${systemPrompt}${ 58 | webpageInfo ? 59 | `\n当前网页内容:\n标题:${webpageInfo.title}\nURL:${webpageInfo.url}\n内容:${webpageInfo.content}` : 60 | '' 61 | }` 62 | }; 63 | 64 | // 确保消息数组中有系统消息 65 | // 删除消息列表中的reasoning_content字段 66 | const processedMessages = messages.map(msg => { 67 | const { reasoning_content, updating, ...rest } = msg; 68 | return rest; 69 | }); 70 | 71 | if (systemMessage.content.trim() && (processedMessages.length === 0 || processedMessages[0].role !== "system")) { 72 | processedMessages.unshift(systemMessage); 73 | } 74 | 75 | const controller = new AbortController(); 76 | const signal = controller.signal; 77 | 78 | const response = await fetch(apiConfig.baseUrl, { 79 | method: 'POST', 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | 'Authorization': `Bearer ${apiConfig.apiKey}` 83 | }, 84 | body: JSON.stringify({ 85 | model: apiConfig.modelName || "gpt-4o", 86 | messages: processedMessages, 87 | stream: true, 88 | }), 89 | signal 90 | }); 91 | 92 | if (!response.ok) { 93 | const error = await response.text(); 94 | throw new Error(error); 95 | } 96 | 97 | // 处理流式响应 98 | const reader = response.body.getReader(); 99 | 100 | const processStream = async () => { 101 | try { 102 | let buffer = ''; 103 | let currentMessage = { 104 | content: '', 105 | reasoning_content: '' 106 | }; 107 | 108 | while (true) { 109 | const { done, value } = await reader.read(); 110 | if (done) break; 111 | 112 | const chunk = new TextDecoder().decode(value); 113 | buffer += chunk; 114 | 115 | let newlineIndex; 116 | while ((newlineIndex = buffer.indexOf('\n')) !== -1) { 117 | const line = buffer.slice(0, newlineIndex); 118 | buffer = buffer.slice(newlineIndex + 1); 119 | 120 | if (line.startsWith('data: ')) { 121 | const data = line.slice(6); 122 | if (data === '[DONE]') { 123 | continue; 124 | } 125 | 126 | try { 127 | const delta = JSON.parse(data).choices[0]?.delta; 128 | if (delta?.content) { 129 | currentMessage.content += delta.content; 130 | } 131 | if (delta?.reasoning_content) { 132 | currentMessage.reasoning_content += delta.reasoning_content; 133 | } 134 | 135 | // 直接更新 chatManager 136 | if (chatManager && chatId && (delta?.content || delta?.reasoning_content)) { 137 | // console.log('callAPI', chatId); 138 | chatManager.updateLastMessage(chatId, currentMessage); 139 | // 通知消息更新 140 | onMessageUpdate(chatId, currentMessage); 141 | } 142 | } catch (e) { 143 | console.error('解析数据时出错:', e); 144 | } 145 | } 146 | } 147 | } 148 | 149 | return currentMessage; 150 | } catch (error) { 151 | if (error.name === 'AbortError') { 152 | return; 153 | } 154 | throw error; 155 | } 156 | }; 157 | 158 | return { 159 | processStream, 160 | controller 161 | }; 162 | } -------------------------------------------------------------------------------- /src/utils/chat-manager.js: -------------------------------------------------------------------------------- 1 | import { storageAdapter } from './storage-adapter.js'; 2 | 3 | const CHATS_KEY = 'cerebr_chats'; 4 | const CURRENT_CHAT_ID_KEY = 'cerebr_current_chat_id'; 5 | 6 | export class ChatManager { 7 | constructor() { 8 | this.storage = storageAdapter; 9 | this.currentChatId = null; 10 | this.chats = new Map(); 11 | this.initialize(); 12 | } 13 | 14 | async initialize() { 15 | // 加载所有对话 16 | const result = await this.storage.get(CHATS_KEY); 17 | const savedChats = result[CHATS_KEY] || []; 18 | if (Array.isArray(savedChats)) { 19 | savedChats.forEach(chat => { 20 | this.chats.set(chat.id, chat); 21 | }); 22 | } 23 | 24 | // 获取当前对话ID 25 | const currentChatResult = await this.storage.get(CURRENT_CHAT_ID_KEY); 26 | this.currentChatId = currentChatResult[CURRENT_CHAT_ID_KEY]; 27 | 28 | // 如果没有当前对话,创建一个默认对话 29 | if (!this.currentChatId || !this.chats.has(this.currentChatId)) { 30 | const defaultChat = this.createNewChat('默认对话'); 31 | this.currentChatId = defaultChat.id; 32 | await this.storage.set({ [CURRENT_CHAT_ID_KEY]: this.currentChatId }); 33 | } 34 | } 35 | 36 | createNewChat(title = '新对话') { 37 | const chatId = Date.now().toString(); 38 | const chat = { 39 | id: chatId, 40 | title: title, 41 | messages: [], 42 | createdAt: new Date().toISOString() 43 | }; 44 | this.chats.set(chatId, chat); 45 | this.saveChats(); 46 | return chat; 47 | } 48 | 49 | async switchChat(chatId) { 50 | if (!this.chats.has(chatId)) { 51 | throw new Error('对话不存在'); 52 | } 53 | this.currentChatId = chatId; 54 | await this.storage.set({ [CURRENT_CHAT_ID_KEY]: chatId }); 55 | return this.chats.get(chatId); 56 | } 57 | 58 | async deleteChat(chatId) { 59 | if (!this.chats.has(chatId)) { 60 | throw new Error('对话不存在'); 61 | } 62 | this.chats.delete(chatId); 63 | await this.saveChats(); 64 | 65 | // 如果删除的是当前对话,切换到其他对话 66 | if (chatId === this.currentChatId) { 67 | const nextChat = Array.from(this.chats.values()).pop(); 68 | if (nextChat) { 69 | await this.switchChat(nextChat.id); 70 | this.currentChatId = nextChat.id; 71 | } else { 72 | const newChat = this.createNewChat('默认对话'); 73 | await this.switchChat(newChat.id); 74 | this.currentChatId = newChat.id; 75 | } 76 | } 77 | } 78 | 79 | getCurrentChat() { 80 | return this.chats.get(this.currentChatId); 81 | } 82 | 83 | getAllChats() { 84 | return Array.from(this.chats.values()).sort((a, b) => 85 | new Date(b.createdAt) - new Date(a.createdAt) 86 | ); 87 | } 88 | 89 | async addMessageToCurrentChat(message) { 90 | const currentChat = this.getCurrentChat(); 91 | if (!currentChat) { 92 | throw new Error('当前没有活动的对话'); 93 | } 94 | currentChat.messages.push(message); 95 | await this.saveChats(); 96 | } 97 | 98 | async updateLastMessage(chatId, message) { 99 | const currentChat = this.chats.get(chatId); 100 | if (!currentChat || currentChat.messages.length === 0) { 101 | // throw new Error('当前没有消息可以更新'); 102 | return; 103 | } 104 | // console.log("updateLastMessage", JSON.stringify(currentChat.messages), JSON.stringify(message)); 105 | if (currentChat.messages[currentChat.messages.length - 1].role === 'user') { 106 | currentChat.messages.push({ 107 | role: 'assistant', 108 | updating: true 109 | }); 110 | } 111 | if (message.content) { 112 | currentChat.messages[currentChat.messages.length - 1].content = message.content; 113 | } 114 | if (message.reasoning_content) { 115 | currentChat.messages[currentChat.messages.length - 1].reasoning_content = message.reasoning_content; 116 | } 117 | await this.saveChats(); 118 | } 119 | 120 | async popMessage() { 121 | const currentChat = this.getCurrentChat(); 122 | if (!currentChat) { 123 | throw new Error('对话不存在'); 124 | } 125 | currentChat.messages.pop(); 126 | await this.saveChats(); 127 | } 128 | 129 | async saveChats() { 130 | await this.storage.set({ [CHATS_KEY]: Array.from(this.chats.values()) }); 131 | } 132 | 133 | async clearCurrentChat() { 134 | const currentChat = this.getCurrentChat(); 135 | if (currentChat) { 136 | currentChat.messages = []; 137 | await this.saveChats(); 138 | } 139 | } 140 | } 141 | 142 | // 创建并导出单例实例 143 | export const chatManager = new ChatManager(); -------------------------------------------------------------------------------- /src/utils/image.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 处理图片拖放的通用函数 3 | * @param {DragEvent} e - 拖放事件对象 4 | * @param {Object} config - 配置对象 5 | * @param {HTMLElement} config.messageInput - 消息输入框元素 6 | * @param {Function} config.createImageTag - 创建图片标签的函数 7 | * @param {Function} config.onSuccess - 成功处理后的回调函数 8 | * @param {Function} config.onError - 错误处理的回调函数 9 | */ 10 | export function handleImageDrop(e, config) { 11 | const { 12 | messageInput, 13 | createImageTag, 14 | onSuccess = () => {}, 15 | onError = (error) => console.error('处理拖放事件失败:', error) 16 | } = config; 17 | 18 | e.preventDefault(); 19 | e.stopPropagation(); 20 | 21 | try { 22 | // 处理文件拖放 23 | if (e.dataTransfer.files.length > 0) { 24 | const file = e.dataTransfer.files[0]; 25 | if (file.type.startsWith('image/')) { 26 | const reader = new FileReader(); 27 | reader.onload = () => { 28 | try { 29 | insertImageToInput({ 30 | messageInput, 31 | createImageTag, 32 | imageData: { 33 | base64Data: reader.result, 34 | fileName: file.name 35 | } 36 | }); 37 | onSuccess(); 38 | } catch (error) { 39 | onError(error); 40 | } 41 | }; 42 | reader.readAsDataURL(file); 43 | return; 44 | } 45 | } 46 | 47 | // 处理网页图片拖放 48 | const data = e.dataTransfer.getData('text/plain'); 49 | if (data) { 50 | try { 51 | const imageData = JSON.parse(data); 52 | if (imageData.type === 'image') { 53 | insertImageToInput({ 54 | messageInput, 55 | createImageTag, 56 | imageData: { 57 | base64Data: imageData.data, 58 | fileName: imageData.name 59 | } 60 | }); 61 | onSuccess(); 62 | } 63 | } catch (error) { 64 | onError(error); 65 | } 66 | } 67 | } catch (error) { 68 | onError(error); 69 | } 70 | } 71 | 72 | /** 73 | * 在输入框中插入图片 74 | * @param {Object} params - 参数对象 75 | * @param {HTMLElement} params.messageInput - 消息输入框元素 76 | * @param {Function} params.createImageTag - 创建图片标签的函数 77 | * @param {Object} params.imageData - 图片数据 78 | * @param {string} params.imageData.base64Data - 图片的base64数据 79 | * @param {string} params.imageData.fileName - 图片文件名 80 | */ 81 | function insertImageToInput({ messageInput, createImageTag, imageData }) { 82 | const imageTag = createImageTag({ 83 | base64Data: imageData.base64Data, 84 | fileName: imageData.fileName 85 | }); 86 | 87 | // 确保输入框有焦点 88 | messageInput.focus(); 89 | 90 | // 获取或创建选区 91 | const selection = window.getSelection(); 92 | let range; 93 | 94 | // 检查是否有现有选区 95 | if (selection.rangeCount > 0) { 96 | range = selection.getRangeAt(0); 97 | } else { 98 | // 创建新的选区 99 | range = document.createRange(); 100 | // 将选区设置到输入框的末尾 101 | range.selectNodeContents(messageInput); 102 | range.collapse(false); 103 | selection.removeAllRanges(); 104 | selection.addRange(range); 105 | } 106 | 107 | // 插入图片标签 108 | range.deleteContents(); 109 | range.insertNode(imageTag); 110 | 111 | // 移动光标到图片标签后面 112 | const newRange = document.createRange(); 113 | newRange.setStartAfter(imageTag); 114 | newRange.collapse(true); 115 | selection.removeAllRanges(); 116 | selection.addRange(newRange); 117 | 118 | // 触发输入事件以调整高度 119 | messageInput.dispatchEvent(new Event('input')); 120 | } -------------------------------------------------------------------------------- /src/utils/storage-adapter.js: -------------------------------------------------------------------------------- 1 | // 检测是否在Chrome扩展环境中 2 | export const isExtensionEnvironment = typeof chrome !== 'undefined' && chrome.runtime; 3 | 4 | const IDB_DB_NAME = 'CerebrData'; 5 | const IDB_DB_VERSION = 1; 6 | const IDB_STORE_NAME = 'keyValueStore'; 7 | 8 | let dbPromise = null; 9 | 10 | function getDb() { 11 | if (!isExtensionEnvironment && !dbPromise) { //仅在非插件环境且dbPromise未初始化时创建 12 | dbPromise = new Promise((resolve, reject) => { 13 | const request = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); 14 | 15 | request.onupgradeneeded = (event) => { 16 | const db = event.target.result; 17 | if (!db.objectStoreNames.contains(IDB_STORE_NAME)) { 18 | db.createObjectStore(IDB_STORE_NAME); 19 | } 20 | }; 21 | 22 | request.onsuccess = (event) => { 23 | resolve(event.target.result); 24 | }; 25 | 26 | request.onerror = (event) => { 27 | console.error('IndexedDB database error:', event.target.error); 28 | reject(event.target.error); 29 | }; 30 | }); 31 | } 32 | return dbPromise; 33 | } 34 | 35 | // 存储适配器 36 | export const storageAdapter = { 37 | // 获取存储的数据 38 | async get(key) { 39 | if (isExtensionEnvironment) { 40 | return await chrome.storage.local.get(key); 41 | } else { 42 | try { 43 | const db = await getDb(); 44 | if (!db) return { [key]: undefined }; // 如果数据库打开失败 45 | 46 | return new Promise((resolve, reject) => { 47 | const transaction = db.transaction([IDB_STORE_NAME], 'readonly'); 48 | const store = transaction.objectStore(IDB_STORE_NAME); 49 | const request = store.get(key); 50 | 51 | request.onsuccess = () => { 52 | resolve({ [key]: request.result }); 53 | }; 54 | request.onerror = (event) => { 55 | console.error(`IndexedDB get error for key ${key}:`, event.target.error); 56 | reject(event.target.error); 57 | }; 58 | }); 59 | } catch (error) { 60 | console.error('Failed to get data from IndexedDB for key ' + key + ':', error); 61 | return { [key]: undefined }; 62 | } 63 | } 64 | }, 65 | 66 | // 设置存储的数据 67 | async set(data) { 68 | if (isExtensionEnvironment) { 69 | await chrome.storage.local.set(data); 70 | } else { 71 | try { 72 | const db = await getDb(); 73 | if (!db) throw new Error("IndexedDB not available"); 74 | 75 | // 假设 data 是一个对象,我们需要迭代它来存储每个键值对 76 | // 或者,如果 ChatManager 总是用一个固定的主键(如 'cerebr_chats')来保存所有聊天, 77 | // 那么这里的逻辑可以简化。 78 | // 当前 ChatManager 的 saveChats 是 this.storage.set({ [CHATS_KEY]: Array.from(this.chats.values()) }); 79 | // 所以 data 是 { 'cerebr_chats': [...] } 80 | 81 | const entries = Object.entries(data); 82 | if (entries.length === 0) return Promise.resolve(); 83 | 84 | return new Promise((resolve, reject) => { 85 | const transaction = db.transaction([IDB_STORE_NAME], 'readwrite'); 86 | const store = transaction.objectStore(IDB_STORE_NAME); 87 | let completedOperations = 0; 88 | 89 | entries.forEach(([key, value]) => { 90 | const request = store.put(value, key); 91 | request.onsuccess = () => { 92 | completedOperations++; 93 | if (completedOperations === entries.length) { 94 | // resolve(); // 事务完成后再 resolve 95 | } 96 | }; 97 | request.onerror = (event) => { 98 | // 如果任何一个 put 失败,我们应该中止事务并 reject 99 | console.error(`IndexedDB set error for key ${key}:`, event.target.error); 100 | transaction.abort(); // 中止事务 101 | reject(event.target.error); 102 | }; 103 | }); 104 | 105 | transaction.oncomplete = () => { 106 | resolve(); 107 | }; 108 | transaction.onerror = (event) => { 109 | console.error('IndexedDB set transaction error:', event.target.error); 110 | reject(event.target.error); 111 | }; 112 | transaction.onabort = (event) => { 113 | console.error('IndexedDB set transaction aborted:', event.target.error); 114 | reject(new Error('Transaction aborted, possibly due to an earlier error.')); 115 | }; 116 | }); 117 | 118 | } catch (error) { 119 | console.error('Failed to set data in IndexedDB:', error); 120 | // 根据应用的需要决定如何处理这个错误,例如向上抛出 121 | throw error; 122 | } 123 | } 124 | } 125 | }; 126 | 127 | // 同步存储适配器 128 | export const syncStorageAdapter = { 129 | // 获取存储的数据 130 | async get(key) { 131 | if (isExtensionEnvironment) { 132 | return await chrome.storage.sync.get(key); 133 | } else { 134 | // 对于 sync,localStorage 可能是个更简单的回退,因为它本身容量就小 135 | // 或者您也可以为 sync 实现单独的 IndexedDB 存储(例如不同的 object store) 136 | // 这里暂时保持 localStorage 作为示例,但请注意其容量限制 137 | console.warn("Sync storage in web environment is using localStorage fallback, which has size limitations."); 138 | if (Array.isArray(key)) { 139 | const result = {}; 140 | for (const k of key) { 141 | const value = localStorage.getItem(`sync_${k}`); 142 | if (value) { 143 | try { 144 | result[k] = JSON.parse(value); 145 | } catch (e) { 146 | console.error(`Error parsing sync_ ${k} from localStorage`, e); 147 | } 148 | } 149 | } 150 | return result; 151 | } else { 152 | const value = localStorage.getItem(`sync_${key}`); 153 | if (value) { 154 | try { 155 | return { [key]: JSON.parse(value) }; 156 | } catch (e) { 157 | console.error(`Error parsing sync_ ${key} from localStorage`, e); 158 | } 159 | } 160 | return {}; 161 | } 162 | } 163 | }, 164 | 165 | // 设置存储的数据 166 | async set(data) { 167 | if (isExtensionEnvironment) { 168 | await chrome.storage.sync.set(data); 169 | } else { 170 | console.warn("Sync storage in web environment is using localStorage fallback, which has size limitations."); 171 | for (const [key, value] of Object.entries(data)) { 172 | try { 173 | localStorage.setItem(`sync_${key}`, JSON.stringify(value)); 174 | } catch (e) { 175 | console.error(`Error setting sync_ ${key} to localStorage`, e); 176 | // 如果 localStorage 也满了,这里可能会抛出 QuotaExceededError 177 | throw e; 178 | } 179 | } 180 | } 181 | } 182 | }; 183 | 184 | // 浏览器API适配器 185 | export const browserAdapter = { 186 | // 获取当前标签页信息 187 | async getCurrentTab() { 188 | if (isExtensionEnvironment) { 189 | const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); 190 | if (!tab?.url) return null; 191 | 192 | // 处理本地文件 193 | if (tab.url.startsWith('file://')) { 194 | return { 195 | url: 'file://', 196 | title: 'Local PDF', 197 | hostname: 'local_pdf' 198 | }; 199 | } 200 | 201 | const url = new URL(tab.url); 202 | return { 203 | url: tab.url, 204 | title: tab.title, 205 | hostname: url.hostname 206 | }; 207 | } else { 208 | const url = window.location.href; 209 | // 处理本地文件 210 | if (url.startsWith('file://')) { 211 | return { 212 | url: 'file://', 213 | title: 'Local PDF', 214 | hostname: 'local_pdf' 215 | }; 216 | } 217 | return { 218 | url: url, 219 | title: document.title, 220 | hostname: window.location.hostname 221 | }; 222 | } 223 | }, 224 | 225 | // 发送消息 226 | async sendMessage(message) { 227 | if (isExtensionEnvironment) { 228 | return await chrome.runtime.sendMessage(message); 229 | } else { 230 | console.warn('Message passing is not supported in web environment:', message); 231 | return null; 232 | } 233 | }, 234 | 235 | // 添加标签页变化监听器 236 | onTabActivated(callback) { 237 | if (isExtensionEnvironment) { 238 | chrome.tabs.onActivated.addListener(callback); 239 | } else { 240 | // Web环境下不需要监听标签页变化 241 | console.info('Tab activation listening is not supported in web environment'); 242 | } 243 | } 244 | }; 245 | 246 | // 记录存储空间占用的函数 247 | function logStorageUsage() { 248 | if (isExtensionEnvironment) { 249 | if (chrome && chrome.storage && chrome.storage.local && typeof chrome.storage.local.getBytesInUse === 'function') { 250 | chrome.storage.local.getBytesInUse(null).then((bytesInUse) => { 251 | console.log("[Cerebr] 插件占用的本地存储空间: " + (bytesInUse / (1024 * 1024)).toFixed(2) + " MB"); 252 | }).catch(error => { 253 | console.error("[Cerebr] 获取插件本地存储空间失败:", error); 254 | }); 255 | } else { 256 | console.warn("[Cerebr] chrome.storage.local.getBytesInUse API 在插件环境中不可用或未正确初始化。"); 257 | } 258 | } else { 259 | // 网页环境 - IndexedDB 260 | if (navigator.storage && navigator.storage.estimate) { 261 | navigator.storage.estimate().then(estimate => { 262 | console.log(`[Cerebr] 网页预估存储使用 (IndexedDB等): ${(estimate.usage / (1024 * 1024)).toFixed(2)} MB / 配额: ${(estimate.quota / (1024 * 1024)).toFixed(2)} MB`); 263 | }).catch(error => { 264 | console.warn("[Cerebr] 无法通过 navigator.storage.estimate() 获取网页存储信息:", error); 265 | console.log("[Cerebr] 网页环境使用 IndexedDB。具体大小请通过浏览器开发者工具查看。"); 266 | }); 267 | } else { 268 | console.log("[Cerebr] 网页环境使用 IndexedDB。具体大小请通过浏览器开发者工具查看。"); 269 | } 270 | } 271 | } 272 | 273 | // 在模块加载时执行日志记录 274 | logStorageUsage(); -------------------------------------------------------------------------------- /src/utils/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 设置主题的工具函数 3 | * @param {boolean} isDark - 是否为深色主题 4 | * @param {Object} config - 配置对象 5 | * @param {HTMLElement} config.root - 根元素(通常是document.documentElement) 6 | * @param {HTMLInputElement} config.themeSwitch - 主题切换开关元素 7 | * @param {Function} config.saveTheme - 保存主题的回调函数 8 | */ 9 | export function setTheme(isDark, { root, themeSwitch, saveTheme }) { 10 | // 移除现有的主题类 11 | root.classList.remove('dark-theme', 'light-theme'); 12 | 13 | // 添加新的主题类 14 | root.classList.add(isDark ? 'dark-theme' : 'light-theme'); 15 | 16 | // 更新开关状态 17 | if (themeSwitch) { 18 | themeSwitch.checked = isDark; 19 | } 20 | 21 | // 保存主题设置 22 | if (saveTheme) { 23 | saveTheme(isDark ? 'dark' : 'light'); 24 | } 25 | 26 | // 更新 Mermaid 主题并重新渲染 27 | if (window.mermaid) { 28 | window.mermaid.initialize({ 29 | theme: isDark ? 'dark' : 'default' 30 | }); 31 | 32 | // 重新渲染所有图表 33 | if (window.renderMermaidDiagrams) { 34 | window.renderMermaidDiagrams(); 35 | } 36 | } 37 | 38 | // // 更新浏览器 UI 颜色 39 | // updateThemeColor(isDark); 40 | } 41 | 42 | // 更新主题颜色 43 | function updateThemeColor(isDark) { 44 | const themeColorMeta = document.getElementById('theme-color-meta'); 45 | if (themeColorMeta) { 46 | if (isDark) { 47 | // 深色模式:移除 meta 标签的 content 属性,使用浏览器默认颜色 48 | themeColorMeta.removeAttribute('content'); 49 | } else { 50 | // 浅色模式:设置自定义颜色 51 | themeColorMeta.content = '#ffffff'; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/utils/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 输入框配置接口 3 | * @typedef {Object} TextareaConfig 4 | * @property {number} maxHeight - 输入框最大高度 5 | */ 6 | 7 | /** 8 | * 图片预览配置接口 9 | * @property {HTMLElement} previewModal - 预览模态框元素 10 | * @property {HTMLElement} previewImage - 预览图片元素 11 | */ 12 | 13 | /** 14 | * 图片标签配置接口 15 | * @typedef {Object} ImageTagConfig 16 | * @property {function} onImageClick - 图片点击回调 17 | * @property {function} onDeleteClick - 删除按钮点击回调 18 | */ 19 | 20 | /** 21 | * 调整输入框高度 22 | * @param {Object} params - 参数对象 23 | * @param {HTMLElement} params.textarea - 输入框元素 24 | * @param {TextareaConfig} params.config - 输入框配置 25 | */ 26 | export function adjustTextareaHeight({ 27 | textarea, 28 | config = { maxHeight: 200 } 29 | }) { 30 | textarea.style.height = 'auto'; 31 | textarea.style.height = Math.min(textarea.scrollHeight, config.maxHeight) + 'px'; 32 | if (textarea.scrollHeight > config.maxHeight) { 33 | textarea.style.overflowY = 'auto'; 34 | } else { 35 | textarea.style.overflowY = 'hidden'; 36 | } 37 | } 38 | 39 | /** 40 | * 显示图片预览 41 | * @param {Object} params - 参数对象 42 | * @param {string} params.base64Data - 图片base64数据 43 | */ 44 | export function showImagePreview({ 45 | base64Data, 46 | config 47 | }) { 48 | config.previewImage.src = base64Data; 49 | config.previewModal.classList.add('visible'); 50 | } 51 | 52 | /** 53 | * 隐藏图片预览 54 | * @param {Object} params - 参数对象 55 | */ 56 | export function hideImagePreview({ 57 | config 58 | }) { 59 | config.previewModal.classList.remove('visible'); 60 | config.previewImage.src = ''; 61 | } 62 | 63 | /** 64 | * 创建图片标签 65 | * @param {Object} params - 参数对象 66 | * @param {string} params.base64Data - 图片base64数据 67 | * @param {string} [params.fileName] - 文件名(可选) 68 | * @param {ImageTagConfig} params.config - 图片标签配置 69 | * @returns {HTMLElement} 创建的图片标签元素 70 | */ 71 | export function createImageTag({ 72 | base64Data, 73 | fileName = '图片', 74 | config 75 | }) { 76 | const container = document.createElement('span'); 77 | container.className = 'image-tag'; 78 | container.contentEditable = false; 79 | container.setAttribute('data-image', base64Data); 80 | container.title = fileName; 81 | 82 | const thumbnail = document.createElement('img'); 83 | thumbnail.src = base64Data.startsWith('data:') ? base64Data : `data:image/png;base64,${base64Data}`; 84 | thumbnail.alt = fileName; 85 | 86 | const deleteBtn = document.createElement('button'); 87 | deleteBtn.className = 'delete-btn'; 88 | deleteBtn.innerHTML = ''; 89 | deleteBtn.title = '删除图片'; 90 | 91 | // 点击删除按钮时删除整个标签 92 | deleteBtn.addEventListener('click', (e) => { 93 | e.preventDefault(); 94 | e.stopPropagation(); 95 | if (config.onDeleteClick) { 96 | config.onDeleteClick(container); 97 | } 98 | }); 99 | 100 | container.appendChild(thumbnail); 101 | container.appendChild(deleteBtn); 102 | 103 | // 点击图片区域预览图片 104 | thumbnail.addEventListener('click', (e) => { 105 | e.preventDefault(); 106 | e.stopPropagation(); 107 | if (config.onImageClick) { 108 | config.onImageClick(base64Data); 109 | } 110 | }); 111 | 112 | return container; 113 | } -------------------------------------------------------------------------------- /src/utils/viewport.js: -------------------------------------------------------------------------------- 1 | // 用于存储原始视口高度 2 | let originalViewportHeight = window.innerHeight; 3 | 4 | // 设置视口高度变量 5 | function setViewportHeight() { 6 | // 获取实际视口高度 7 | const vh = window.innerHeight * 0.01; 8 | // 设置CSS变量 9 | document.documentElement.style.setProperty('--vh', `${vh}px`); 10 | 11 | // 计算输入法是否弹出 12 | const isKeyboardVisible = window.innerHeight < originalViewportHeight * 0.8; 13 | 14 | if (isKeyboardVisible) { 15 | // 输入法弹出时,调整聊天容器的高度和上边距 16 | const keyboardHeight = originalViewportHeight - window.innerHeight; 17 | document.documentElement.style.setProperty('--keyboard-height', `${keyboardHeight}px`); 18 | document.documentElement.style.setProperty('--chat-top-margin', `${keyboardHeight}px`); 19 | document.body.classList.add('keyboard-visible'); 20 | } else { 21 | document.documentElement.style.setProperty('--keyboard-height', '0px'); 22 | document.documentElement.style.setProperty('--chat-top-margin', '0px'); 23 | document.body.classList.remove('keyboard-visible'); 24 | // 更新原始视口高度 25 | originalViewportHeight = window.innerHeight; 26 | } 27 | } 28 | 29 | // 初始设置 30 | setViewportHeight(); 31 | 32 | // 监听视口大小变化(包括输入法弹出) 33 | let resizeTimeout; 34 | window.addEventListener('resize', () => { 35 | // 使用防抖来优化性能 36 | clearTimeout(resizeTimeout); 37 | resizeTimeout = setTimeout(() => { 38 | setViewportHeight(); 39 | 40 | // // 获取聊天容器 41 | // const chatContainer = document.getElementById('chat-container'); 42 | // if (chatContainer) { 43 | // // 重新计算滚动位置 44 | // const scrollPosition = chatContainer.scrollTop; 45 | // const scrollHeight = chatContainer.scrollHeight; 46 | // const clientHeight = chatContainer.clientHeight; 47 | 48 | // // 如果之前滚动到底部,保持在底部 49 | // if (scrollHeight - scrollPosition <= clientHeight + 50) { 50 | // chatContainer.scrollTop = chatContainer.scrollHeight; 51 | // } 52 | // } 53 | }, 100); 54 | }); 55 | 56 | // 监听输入框焦点事件 57 | document.addEventListener('DOMContentLoaded', () => { 58 | const input = document.getElementById('message-input'); 59 | if (input) { 60 | input.addEventListener('focus', () => { 61 | // 给一点延迟,等待输入法完全展开 62 | setTimeout(() => { 63 | setViewportHeight(); 64 | // 滚动到底部 65 | // const chatContainer = document.getElementById('chat-container'); 66 | // if (chatContainer) { 67 | // chatContainer.scrollTop = chatContainer.scrollHeight; 68 | // } 69 | }, 300); 70 | }); 71 | 72 | input.addEventListener('blur', () => { 73 | // 输入框失去焦点时,重置视口高度 74 | setTimeout(() => { 75 | setViewportHeight(); 76 | }, 100); 77 | }); 78 | } 79 | }); -------------------------------------------------------------------------------- /statics/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/ca1a3760e9ec0fdf441c2b051b4a3589dbfe693c/statics/image.png -------------------------------------------------------------------------------- /styles/base/reset.css: -------------------------------------------------------------------------------- 1 | /* 基础样式 */ 2 | body { 3 | margin: 0; 4 | padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) 0; 5 | height: 100vh; 6 | height: -webkit-fill-available; 7 | display: flex; 8 | flex-direction: column; 9 | background-color: var(--cerebr-bg-color); 10 | color: var(--cerebr-text-color); 11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 12 | overflow: hidden; 13 | backdrop-filter: blur(var(--cerebr-blur-radius)); 14 | -webkit-backdrop-filter: blur(var(--cerebr-blur-radius)); 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | right: 0; 19 | bottom: 0; 20 | } -------------------------------------------------------------------------------- /styles/base/variables.css: -------------------------------------------------------------------------------- 1 | /* 根变量(默认浅色主题) */ 2 | :root { 3 | --cerebr-bg-color: #ffffff; 4 | --cerebr-text-color: #333333; 5 | --cerebr-message-user-bg: #e6eaf0; 6 | --cerebr-message-ai-bg: #f8fafc; 7 | --cerebr-input-bg: #f8f8f8; 8 | --cerebr-icon-color: #666666; 9 | --cerebr-blur-radius: 12px; 10 | --cerebr-card-border-color: rgba(0, 0, 0, 0.15); 11 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 12 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.1); 13 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 14 | --cerebr-inline-code-bg: rgba(175, 184, 193, 0.2); 15 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.2); 16 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 17 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 18 | --cerebr-image-tag-bg: rgba(0, 122, 255, 0.08); 19 | --cerebr-image-tag-border-color: rgba(0, 122, 255, 0.15); 20 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.05); 21 | --cerebr-image-tag-hover-bg: rgba(0, 122, 255, 0.12); 22 | --cerebr-blockquote-text-color: rgba(0, 0, 0, 0.7); 23 | --cerebr-blockquote-border-color: rgba(0, 0, 0, 0.2); 24 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.15); 25 | --cerebr-message-shadow: rgba(0, 0, 0, 0.1); 26 | --cerebr-link-color: #0366d6; 27 | --cerebr-toggle-hover-bg: rgba(0, 0, 0, 0.06); 28 | --cerebr-reasoning-bg: rgba(0, 0, 0, 0.03); 29 | --cerebr-reasoning-text-color: #666; 30 | --cerebr-reasoning-hover-bg: rgba(0, 0, 0, 0.05); 31 | --cerebr-toggle-bg-off: rgba(128, 128, 128, 0.3); 32 | --cerebr-toggle-bg-on: #34c759; 33 | --cerebr-input-border-color: rgba(0, 0, 0, 0.15); 34 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.1); 35 | } 36 | 37 | /* 深色主题变量 */ 38 | :root.dark-theme { 39 | --cerebr-bg-color: #262B33; 40 | --cerebr-text-color: #d8dde6; 41 | --cerebr-message-user-bg: #3E4451; 42 | --cerebr-message-ai-bg: #2c313c; 43 | --cerebr-input-bg: #21252b; 44 | --cerebr-icon-color: #abb2bf; 45 | --cerebr-card-border-color: rgba(255, 255, 255, 0.1); 46 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 47 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.25); 48 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 49 | --cerebr-inline-code-bg: rgba(99, 110, 123, 0.4); 50 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.3); 51 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 52 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 53 | --cerebr-image-tag-bg: rgba(10, 132, 255, 0.12); 54 | --cerebr-image-tag-border-color: rgba(10, 132, 255, 0.2); 55 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.2); 56 | --cerebr-image-tag-hover-bg: rgba(10, 132, 255, 0.18); 57 | --cerebr-blockquote-text-color: rgba(255, 255, 255, 0.7); 58 | --cerebr-blockquote-border-color: rgba(255, 255, 255, 0.2); 59 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.2); 60 | --cerebr-message-shadow: rgba(0, 0, 0, 0.15); 61 | --cerebr-link-color: #58a6ff; 62 | --cerebr-toggle-hover-bg: rgba(255, 255, 255, 0.08); 63 | --cerebr-reasoning-bg: rgba(255, 255, 255, 0.03); 64 | --cerebr-reasoning-text-color: #b3b3b3; 65 | --cerebr-reasoning-hover-bg: rgba(255, 255, 255, 0.05); 66 | --cerebr-toggle-bg-off: rgba(255, 255, 255, 0.15); 67 | --cerebr-toggle-bg-on: #32d74b; 68 | --cerebr-input-border-color: rgba(255, 255, 255, 0.15); 69 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.2); 70 | } 71 | 72 | /* 系统深色主题 */ 73 | @media (prefers-color-scheme: dark) { 74 | :root:not(.light-theme) { 75 | --cerebr-bg-color: #262B33; 76 | --cerebr-text-color: #d8dde6; 77 | --cerebr-message-user-bg: #3E4451; 78 | --cerebr-message-ai-bg: #2c313c; 79 | --cerebr-input-bg: #21252b; 80 | --cerebr-icon-color: #abb2bf; 81 | --cerebr-card-border-color: rgba(255, 255, 255, 0.1); 82 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 83 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.25); 84 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 85 | --cerebr-inline-code-bg: rgba(99, 110, 123, 0.4); 86 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.3); 87 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 88 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 89 | --cerebr-image-tag-bg: rgba(10, 132, 255, 0.12); 90 | --cerebr-image-tag-border-color: rgba(10, 132, 255, 0.2); 91 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.2); 92 | --cerebr-image-tag-hover-bg: rgba(10, 132, 255, 0.18); 93 | --cerebr-blockquote-text-color: rgba(255, 255, 255, 0.7); 94 | --cerebr-blockquote-border-color: rgba(255, 255, 255, 0.2); 95 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.2); 96 | --cerebr-message-shadow: rgba(0, 0, 0, 0.15); 97 | --cerebr-link-color: #58a6ff; 98 | --cerebr-toggle-hover-bg: rgba(255, 255, 255, 0.08); 99 | --cerebr-reasoning-bg: rgba(255, 255, 255, 0.03); 100 | --cerebr-reasoning-text-color: #b3b3b3; 101 | --cerebr-reasoning-hover-bg: rgba(255, 255, 255, 0.05); 102 | --cerebr-toggle-bg-off: rgba(255, 255, 255, 0.15); 103 | --cerebr-toggle-bg-on: #32d74b; 104 | --cerebr-input-border-color: rgba(255, 255, 255, 0.15); 105 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.2); 106 | } 107 | } -------------------------------------------------------------------------------- /styles/components/api-settings.css: -------------------------------------------------------------------------------- 1 | /* API设置页面样式 */ 2 | #api-settings { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 1000; 9 | display: none; 10 | overflow-y: auto; 11 | flex-direction: column; 12 | background: var(--cerebr-bg-color); 13 | } 14 | 15 | #api-settings.visible { 16 | display: flex; 17 | } 18 | 19 | .api-cards { 20 | padding: 16px; 21 | flex: 1; 22 | overflow-y: auto; 23 | } 24 | 25 | /* API卡片基础样式 */ 26 | .api-card { 27 | outline: none; 28 | cursor: pointer; 29 | border-radius: 8px; 30 | position: relative; 31 | margin-bottom: 12px; 32 | background: var(--cerebr-message-ai-bg); 33 | border: 1px solid var(--cerebr-card-border-color); 34 | transition: transform 0.2s ease, box-shadow 0.2s ease; 35 | padding: 15px; 36 | } 37 | 38 | .settings-header { 39 | padding: 16px; 40 | display: flex; 41 | align-items: center; 42 | border-bottom: 1px solid var(--border-color); 43 | background-color: var(--cerebr-bg-color); 44 | position: sticky; 45 | top: 0; 46 | z-index: 1; 47 | } 48 | 49 | .back-button { 50 | background: none; 51 | border: none; 52 | padding: 8px; 53 | margin-right: 12px; 54 | cursor: pointer; 55 | color: var(--cerebr-text-color); 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | border-radius: 6px; 60 | transition: background-color 0.2s ease; 61 | } 62 | 63 | .back-button:hover { 64 | background-color: var(--cerebr-message-user-bg); 65 | } 66 | 67 | .settings-title { 68 | font-size: 16px; 69 | font-weight: 500; 70 | color: var(--cerebr-text-color); 71 | } 72 | 73 | .api-card:hover, 74 | .api-card:focus { 75 | transform: translateY(-1px); 76 | box-shadow: 0 4px 12px var(--cerebr-card-border-color); 77 | } 78 | 79 | .api-card.selected { 80 | border-color: var(--cerebr-highlight-border-color); 81 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 82 | } 83 | 84 | .api-card:focus:not(.selected) { 85 | border-color: var(--cerebr-focus-border-color); 86 | box-shadow: 0 0 0 1px var(--cerebr-focus-border-color); 87 | } 88 | 89 | .card-actions { 90 | display: flex; 91 | gap: 8px; 92 | z-index: 3; 93 | } 94 | 95 | .card-button { 96 | background: none; 97 | border: none; 98 | padding: 8px; 99 | cursor: pointer; 100 | color: var(--cerebr-text-color); 101 | opacity: 0.6; 102 | transition: opacity 0.2s, background-color 0.2s; 103 | display: flex; 104 | align-items: center; 105 | justify-content: center; 106 | width: 32px; 107 | height: 32px; 108 | border-radius: 4px; 109 | position: relative; 110 | } 111 | 112 | .card-button:hover { 113 | opacity: 1; 114 | background-color: var(--cerebr-button-hover-bg); 115 | } 116 | 117 | .card-button svg { 118 | width: 16px; 119 | height: 16px; 120 | pointer-events: none; 121 | stroke: currentColor; 122 | fill: none; 123 | stroke-width: 1.5; 124 | } 125 | 126 | .api-form { 127 | display: flex; 128 | flex-direction: column; 129 | gap: 12px; 130 | width: 100%; 131 | } 132 | 133 | .form-group { 134 | display: flex; 135 | flex-direction: column; 136 | gap: 4px; 137 | width: 100%; 138 | } 139 | 140 | .form-group input, 141 | .system-prompt { 142 | width: 100%; 143 | box-sizing: border-box; 144 | } 145 | 146 | .form-group-header { 147 | display: flex; 148 | justify-content: space-between; 149 | align-items: center; 150 | } 151 | 152 | .form-group label { 153 | font-size: 12px; 154 | opacity: 0.8; 155 | } 156 | 157 | .form-group input { 158 | background: var(--cerebr-message-ai-bg); 159 | border: 1px solid var(--cerebr-card-border-color); 160 | padding: 8px; 161 | border-radius: 4px; 162 | color: var(--cerebr-text-color); 163 | font-size: 14px; 164 | transition: border-color 0.2s ease; 165 | } 166 | 167 | .form-group input:focus { 168 | outline: none; 169 | border-color: var(--cerebr-highlight-border-color); 170 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 171 | } -------------------------------------------------------------------------------- /styles/components/chat-container.css: -------------------------------------------------------------------------------- 1 | /* 聊天容器样式 */ 2 | #chat-container { 3 | flex: 1; 4 | overflow-y: scroll; 5 | padding: 15px; 6 | padding-top: calc(15px + env(safe-area-inset-top)); 7 | padding-bottom: calc(60px + env(safe-area-inset-bottom)); 8 | scrollbar-width: none; 9 | -ms-overflow-style: none; 10 | min-height: 100%; 11 | -webkit-overflow-scrolling: touch; 12 | transform: translateZ(0); 13 | scroll-behavior: smooth; 14 | overscroll-behavior-y: contain; 15 | position: relative; 16 | height: 100%; 17 | box-sizing: border-box; 18 | margin-top: var(--chat-top-margin, 0px); 19 | transition: margin-top 0.3s ease; 20 | } 21 | 22 | #chat-container::-webkit-scrollbar { 23 | display: none; 24 | } 25 | 26 | .keyboard-visible #chat-container { 27 | padding-bottom: calc(60px + env(safe-area-inset-bottom) + var(--keyboard-height, 0px)); 28 | } -------------------------------------------------------------------------------- /styles/components/chat-list.css: -------------------------------------------------------------------------------- 1 | /* 聊天列表页面样式 */ 2 | #chat-list-page { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 1000; 9 | display: none; 10 | flex-direction: column; 11 | background: var(--cerebr-bg-color); 12 | } 13 | 14 | #chat-list-page.show { 15 | display: flex; 16 | } 17 | 18 | .chat-cards { 19 | padding: 16px; 20 | flex: 1; 21 | overflow-y: auto; 22 | } 23 | 24 | .chat-card { 25 | outline: none; 26 | cursor: pointer; 27 | border-radius: 8px; 28 | position: relative; 29 | margin-bottom: 12px; 30 | background: var(--cerebr-message-ai-bg); 31 | border: 1px solid var(--cerebr-card-border-color); 32 | transition: transform 0.2s ease, box-shadow 0.2s ease; 33 | } 34 | 35 | .chat-card:hover, 36 | .chat-card:focus { 37 | transform: translateY(-1px); 38 | box-shadow: 0 4px 12px var(--cerebr-card-border-color); 39 | } 40 | 41 | .chat-card.selected { 42 | border-color: var(--cerebr-highlight-border-color); 43 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 44 | } 45 | 46 | .chat-card .card-content { 47 | display: flex; 48 | justify-content: space-between; 49 | align-items: center; 50 | padding: 15px; 51 | } 52 | 53 | .chat-card .chat-title { 54 | font-size: 14px; 55 | color: var(--cerebr-text-color); 56 | flex-grow: 1; 57 | margin-right: 12px; 58 | white-space: nowrap; 59 | overflow: hidden; 60 | text-overflow: ellipsis; 61 | } 62 | 63 | .chat-card .card-actions { 64 | display: flex; 65 | gap: 8px; 66 | z-index: 3; 67 | } 68 | 69 | .chat-card .card-button { 70 | background: none; 71 | border: none; 72 | padding: 8px; 73 | cursor: pointer; 74 | color: var(--cerebr-text-color); 75 | opacity: 0.6; 76 | transition: opacity 0.2s, background-color 0.2s; 77 | display: flex; 78 | align-items: center; 79 | justify-content: center; 80 | width: 32px; 81 | height: 32px; 82 | border-radius: 4px; 83 | position: relative; 84 | } 85 | 86 | .chat-card .card-button:hover { 87 | opacity: 1; 88 | background-color: var(--cerebr-button-hover-bg); 89 | } 90 | 91 | .chat-card .card-button svg { 92 | width: 16px; 93 | height: 16px; 94 | pointer-events: none; 95 | stroke: currentColor; 96 | fill: none; 97 | stroke-width: 1.5; 98 | } -------------------------------------------------------------------------------- /styles/components/code.css: -------------------------------------------------------------------------------- 1 | @import '../../htmd/styles/code.css'; 2 | 3 | /* 允许代码块内的文本选择 */ 4 | .ai-message pre, 5 | .ai-message code { 6 | -webkit-touch-callout: text; 7 | -webkit-user-select: text; 8 | -khtml-user-select: text; 9 | -moz-user-select: text; 10 | -ms-user-select: text; 11 | user-select: text; 12 | } -------------------------------------------------------------------------------- /styles/components/context-menu.css: -------------------------------------------------------------------------------- 1 | /* 右键菜单样式 */ 2 | #context-menu { 3 | position: fixed; 4 | background: var(--cerebr-bg-color); 5 | border-radius: 8px; 6 | padding: 6px; 7 | min-width: 140px; 8 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 9 | z-index: 2147483647; 10 | display: none; 11 | backdrop-filter: blur(var(--cerebr-blur-radius)); 12 | -webkit-backdrop-filter: blur(var(--cerebr-blur-radius)); 13 | border: 1px solid var(--cerebr-card-border-color); 14 | touch-action: none; 15 | } 16 | 17 | #context-menu.visible { 18 | display: block; 19 | } 20 | 21 | .context-menu-item { 22 | padding: 8px 12px; 23 | cursor: pointer; 24 | display: flex; 25 | align-items: center; 26 | gap: 8px; 27 | color: var(--cerebr-text-color); 28 | font-size: 13px; 29 | border-radius: 6px; 30 | margin: 2px 0; 31 | transition: background-color 0.2s ease; 32 | white-space: nowrap; 33 | } 34 | 35 | .context-menu-item:hover { 36 | background-color: var(--cerebr-message-user-bg); 37 | } 38 | 39 | .context-menu-item svg { 40 | width: 14px; 41 | height: 14px; 42 | fill: none; 43 | stroke: currentColor; 44 | stroke-width: 2; 45 | flex-shrink: 0; 46 | } 47 | 48 | #stop-update svg { 49 | fill: currentColor; 50 | stroke: none; 51 | } 52 | 53 | #stop-update:hover { 54 | background-color: var(--cerebr-message-user-bg); 55 | color: #ff4d4d; 56 | } 57 | 58 | #delete-message:hover { 59 | background-color: var(--cerebr-message-user-bg); 60 | color: #ff4d4d; 61 | } -------------------------------------------------------------------------------- /styles/components/image-preview.css: -------------------------------------------------------------------------------- 1 | /* 图片预览模态框样式 */ 2 | .image-preview-modal { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background: var(--cerebr-modal-overlay-bg); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | z-index: 10000; 13 | opacity: 0; 14 | visibility: hidden; 15 | transition: opacity 0.3s ease; 16 | backdrop-filter: blur(20px); 17 | -webkit-backdrop-filter: blur(20px); 18 | } 19 | 20 | .image-preview-modal.visible { 21 | opacity: 1; 22 | visibility: visible; 23 | } 24 | 25 | .image-preview-content { 26 | max-width: 90%; 27 | max-height: 90%; 28 | position: relative; 29 | border-radius: 8px; 30 | overflow: hidden; 31 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 32 | } 33 | 34 | .image-preview-content img { 35 | max-width: 100%; 36 | max-height: 90vh; 37 | display: block; 38 | } 39 | 40 | .image-preview-close { 41 | position: absolute; 42 | top: 16px; 43 | right: 16px; 44 | width: 32px; 45 | height: 32px; 46 | background: var(--cerebr-close-button-bg); 47 | border: none; 48 | border-radius: 50%; 49 | color: white; 50 | cursor: pointer; 51 | display: flex; 52 | align-items: center; 53 | justify-content: center; 54 | transition: background-color 0.2s ease; 55 | } 56 | 57 | .image-preview-close:hover { 58 | background: var(--cerebr-modal-overlay-bg); 59 | } 60 | 61 | .image-preview-close svg { 62 | width: 20px; 63 | height: 20px; 64 | stroke: currentColor; 65 | stroke-width: 2; 66 | } -------------------------------------------------------------------------------- /styles/components/image-tag.css: -------------------------------------------------------------------------------- 1 | /* 图片组件样式 */ 2 | .image-tag { 3 | display: inline-flex; 4 | align-items: center; 5 | background: var(--cerebr-image-tag-bg); 6 | border: 1px solid var(--cerebr-image-tag-border-color); 7 | border-radius: 6px; 8 | padding: 4px 6px; 9 | margin: 0 4px; 10 | height: 24px; 11 | cursor: pointer; 12 | user-select: none; 13 | vertical-align: middle; 14 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 15 | gap: 6px; 16 | line-height: 1; 17 | box-sizing: border-box; 18 | box-shadow: 0 1px 2px var(--cerebr-image-tag-shadow); 19 | } 20 | 21 | .image-tag:hover { 22 | background: var(--cerebr-image-tag-hover-bg); 23 | transform: translateY(-1px); 24 | box-shadow: 0 2px 4px var(--cerebr-button-hover-bg); 25 | } 26 | 27 | .image-tag:active { 28 | transform: translateY(0); 29 | box-shadow: 0 1px 2px var(--cerebr-image-tag-shadow); 30 | } 31 | 32 | .image-tag img { 33 | width: 16px; 34 | height: 16px; 35 | object-fit: cover; 36 | border-radius: 4px; 37 | margin: 0; 38 | } 39 | 40 | .image-tag .delete-btn { 41 | width: 16px; 42 | height: 16px; 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | border: none; 47 | background: none; 48 | padding: 0; 49 | margin: 0; 50 | cursor: pointer; 51 | color: var(--cerebr-text-color); 52 | opacity: 0.6; 53 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 54 | } 55 | 56 | .image-tag .delete-btn:hover { 57 | opacity: 1; 58 | transform: scale(1.1); 59 | } 60 | 61 | .image-tag .delete-btn:active { 62 | transform: scale(0.95); 63 | } 64 | 65 | .image-tag .delete-btn svg { 66 | width: 12px; 67 | height: 12px; 68 | stroke: currentColor; 69 | stroke-width: 2; 70 | } -------------------------------------------------------------------------------- /styles/components/input.css: -------------------------------------------------------------------------------- 1 | /* 输入区域样式 */ 2 | #input-container { 3 | padding: 0; 4 | background-color: var(--cerebr-input-bg); 5 | display: flex; 6 | align-items: flex-start; 7 | position: fixed; 8 | bottom: 0; 9 | left: 0; 10 | right: 0; 11 | flex-shrink: 0; 12 | padding-bottom: env(safe-area-inset-bottom); 13 | z-index: 100; 14 | min-height: 48px; 15 | } 16 | 17 | #message-input { 18 | flex: 1; 19 | padding: 12px; 20 | border: none; 21 | background-color: transparent; 22 | color: var(--cerebr-text-color); 23 | font-size: 14px; 24 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 25 | outline: none; 26 | resize: none; 27 | box-sizing: border-box; 28 | min-height: 24px; 29 | max-height: 200px; 30 | line-height: 1.5; 31 | white-space: pre-wrap; 32 | word-break: break-word; 33 | overflow-wrap: break-word; 34 | overflow-y: auto; 35 | } 36 | 37 | #message-input:empty::before { 38 | content: attr(placeholder); 39 | color: var(--cerebr-text-color); 40 | opacity: 0.5; 41 | cursor: text; 42 | font-family: 'Menlo', 'Monaco', 'Courier New', monospace; 43 | } 44 | 45 | #message-input:focus { 46 | outline: none; 47 | } 48 | 49 | #message-input br { 50 | display: none; 51 | } 52 | 53 | #message-input br:first-child { 54 | display: block; 55 | } -------------------------------------------------------------------------------- /styles/components/message.css: -------------------------------------------------------------------------------- 1 | /* 消息基础样式 */ 2 | .message { 3 | margin: 8px 0; 4 | padding: 12px 16px; 5 | border-radius: 8px; 6 | width: fit-content; 7 | max-width: calc(100% - 32px); 8 | word-wrap: break-word; 9 | font-size: 14px; 10 | line-height: 1.5; 11 | position: relative; 12 | opacity: 0; 13 | transform: translateY(8px) scale(0.98) translateZ(0); 14 | animation: messageAppear 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; 15 | will-change: transform, opacity; 16 | box-shadow: 0 2px 6px var(--cerebr-message-shadow); 17 | transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), 18 | box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1); 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | backface-visibility: hidden; 22 | perspective: 1000px; 23 | } 24 | 25 | /* 消息中的图片样式 */ 26 | .message img { 27 | display: block; 28 | max-width: 100%; 29 | height: auto; 30 | margin: 8px 0; 31 | border-radius: 6px; 32 | } 33 | 34 | /* 添加更新中的消息样式 */ 35 | .message.updating { 36 | backface-visibility: hidden; 37 | -webkit-backface-visibility: hidden; 38 | -webkit-transform-style: preserve-3d; 39 | transform-style: preserve-3d; 40 | } 41 | 42 | .user-message { 43 | background-color: var(--cerebr-message-user-bg); 44 | margin-left: auto; 45 | margin-right: 0; 46 | border-bottom-right-radius: 4px; 47 | } 48 | 49 | .ai-message { 50 | background-color: var(--cerebr-message-ai-bg); 51 | margin-right: auto; 52 | margin-left: 0; 53 | border-bottom-left-radius: 4px; 54 | -webkit-touch-callout: none; /* iOS Safari */ 55 | } 56 | 57 | .message p { 58 | margin: 0; 59 | line-height: 1.5; 60 | } 61 | 62 | .message p + p { 63 | margin-top: 0.5em; 64 | } 65 | 66 | .message ul, .message ol { 67 | margin: 0.5em 0; 68 | padding-left: 24px; 69 | } 70 | 71 | /* 链接样式 */ 72 | .message a { 73 | color: var(--cerebr-link-color); 74 | text-decoration: none; 75 | } 76 | 77 | .message a:hover { 78 | text-decoration: underline; 79 | } 80 | 81 | .message blockquote { 82 | margin: 0.5em 0; 83 | padding-left: 12px; 84 | border-left: 4px solid var(--cerebr-blockquote-border-color); 85 | color: var(--cerebr-blockquote-text-color); 86 | } 87 | 88 | .message:hover { 89 | transform: translateY(-1px) translateZ(0); 90 | box-shadow: 0 4px 12px var(--cerebr-message-hover-shadow); 91 | } 92 | 93 | /* 批量加载时的消息样式 */ 94 | .message.batch-load { 95 | animation: none; 96 | opacity: 0; 97 | transform: translateY(16px) scale(0.98); 98 | } 99 | 100 | .message.batch-load.show { 101 | opacity: 1; 102 | transform: translateY(0) scale(1); 103 | transition: opacity 0.3s ease, transform 0.3s ease; 104 | } -------------------------------------------------------------------------------- /styles/components/reasoning.css: -------------------------------------------------------------------------------- 1 | /* 深度思考模块样式 */ 2 | .reasoning-wrapper { 3 | margin: 0 0 12px 0; 4 | padding: 0; 5 | border: none; 6 | } 7 | 8 | .reasoning-toggle { 9 | cursor: pointer; 10 | background: transparent; 11 | color: var(--cerebr-reasoning-text-color); 12 | font-size: 0.9em; 13 | padding: 6px 12px; 14 | border: none; 15 | border-radius: 4px; 16 | user-select: none; 17 | transition: all 0.2s ease; 18 | width: auto; 19 | display: inline-block; 20 | } 21 | 22 | .reasoning-toggle:hover { 23 | background: var(--cerebr-toggle-hover-bg); 24 | color: #333; 25 | } 26 | 27 | .reasoning-content { 28 | cursor: pointer; 29 | background-color: var(--cerebr-reasoning-bg); 30 | border-radius: 6px; 31 | font-size: 0.95em; 32 | color: var(--cerebr-reasoning-text-color); 33 | line-height: 1.5; 34 | overflow: hidden; 35 | transition: all 0.2s ease; 36 | padding: 8px 12px; 37 | } 38 | 39 | .reasoning-content:hover { 40 | background-color: var(--cerebr-reasoning-hover-bg); 41 | } 42 | 43 | .reasoning-placeholder { 44 | display: none; 45 | font-size: 0.9em; 46 | color: var(--cerebr-reasoning-text-color); 47 | } 48 | 49 | .reasoning-text { 50 | word-break: break-word; 51 | transition: all 0.2s ease; 52 | } 53 | 54 | /* 折叠状态 */ 55 | .reasoning-content.collapsed { 56 | padding: 6px 12px; 57 | } 58 | 59 | .reasoning-content.collapsed .reasoning-placeholder { 60 | display: block; 61 | } 62 | 63 | .reasoning-content.collapsed .reasoning-text { 64 | display: none; 65 | } -------------------------------------------------------------------------------- /styles/components/settings.css: -------------------------------------------------------------------------------- 1 | /* 设置按钮和菜单样式 */ 2 | #settings-button { 3 | padding: 12px; 4 | background: none; 5 | border: none; 6 | cursor: pointer; 7 | color: var(--cerebr-icon-color); 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | #settings-button svg { 14 | width: 18px; 15 | height: 18px; 16 | fill: currentColor; 17 | opacity: 0.6; 18 | transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55), 19 | opacity 0.2s ease; 20 | } 21 | 22 | #settings-button:hover svg { 23 | transform: scale(1.25); 24 | opacity: 1; 25 | } 26 | 27 | #settings-button:active svg { 28 | transform: scale(0.95); 29 | opacity: 0.8; 30 | } 31 | 32 | #settings-menu { 33 | position: absolute; 34 | bottom: 100%; 35 | left: 8px; 36 | background-color: var(--cerebr-bg-color); 37 | border-radius: 8px; 38 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 39 | padding: 8px; 40 | display: none; 41 | min-width: 180px; 42 | margin-bottom: 8px; 43 | transform-origin: bottom left; 44 | backdrop-filter: blur(var(--cerebr-blur-radius)); 45 | -webkit-backdrop-filter: blur(var(--cerebr-blur-radius)); 46 | } 47 | 48 | #settings-menu.visible { 49 | display: block; 50 | animation: menuAppear 0.2s ease; 51 | } 52 | 53 | .menu-item { 54 | padding: 8px 12px; 55 | cursor: pointer; 56 | display: flex; 57 | align-items: center; 58 | justify-content: space-between; 59 | color: var(--cerebr-text-color); 60 | border-radius: 6px; 61 | margin: 2px 0; 62 | } 63 | 64 | .menu-item:hover { 65 | background-color: var(--cerebr-message-user-bg); 66 | } 67 | 68 | /* 开关样式 */ 69 | .switch { 70 | position: relative; 71 | display: inline-block; 72 | width: 36px; 73 | height: 20px; 74 | } 75 | 76 | .switch input { 77 | opacity: 0; 78 | width: 0; 79 | height: 0; 80 | } 81 | 82 | .slider { 83 | position: absolute; 84 | cursor: pointer; 85 | top: 0; 86 | left: 0; 87 | right: 0; 88 | bottom: 0; 89 | background-color: var(--cerebr-toggle-bg-off); 90 | transition: .3s; 91 | border-radius: 20px; 92 | } 93 | 94 | .slider:before { 95 | position: absolute; 96 | content: ""; 97 | height: 16px; 98 | width: 16px; 99 | left: 2px; 100 | bottom: 2px; 101 | background-color: #fff; 102 | transition: .3s; 103 | border-radius: 50%; 104 | box-shadow: 0 1px 3px var(--cerebr-popup-shadow); 105 | } 106 | 107 | input:checked + .slider { 108 | background-color: var(--cerebr-toggle-bg-on); 109 | } 110 | 111 | input:checked + .slider:before { 112 | transform: translateX(16px); 113 | } 114 | 115 | /* 高级设置区域样式 */ 116 | .advanced-settings { 117 | margin-top: 0; 118 | padding-top: 4px; 119 | } 120 | 121 | .advanced-settings-header { 122 | display: flex; 123 | justify-content: space-between; 124 | align-items: center; 125 | cursor: pointer; 126 | padding: 8px 12px; 127 | user-select: none; 128 | background: var(--cerebr-message-ai-bg); 129 | border-radius: 6px; 130 | transition: background-color 0.2s ease; 131 | } 132 | 133 | .advanced-settings-header:hover { 134 | opacity: 1; 135 | background: var(--cerebr-message-user-bg); 136 | } 137 | 138 | .toggle-icon { 139 | transition: transform 0.3s ease; 140 | font-size: 12px; 141 | color: var(--cerebr-text-color); 142 | opacity: 0.6; 143 | } 144 | 145 | .advanced-settings-content { 146 | overflow: hidden; 147 | transition: height 0.3s ease; 148 | width: 100%; 149 | box-sizing: border-box; 150 | } 151 | 152 | .setting-item { 153 | margin-top: 12px; 154 | } 155 | 156 | .setting-item label { 157 | display: block; 158 | font-size: 12px; 159 | margin-bottom: 4px; 160 | color: var(--cerebr-text-color); 161 | opacity: 0.8; 162 | } 163 | 164 | .system-prompt { 165 | width: 100%; 166 | min-height: 60px; 167 | padding: 8px; 168 | border: 1px solid var(--cerebr-input-border-color); 169 | border-radius: 4px; 170 | background: var(--cerebr-message-ai-bg); 171 | color: var(--cerebr-text-color); 172 | font-size: 14px; 173 | line-height: 1.5; 174 | resize: vertical; 175 | font-family: inherit; 176 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 177 | } 178 | 179 | .system-prompt:focus { 180 | outline: none; 181 | border-color: var(--cerebr-highlight-border-color); 182 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 183 | } -------------------------------------------------------------------------------- /styles/components/sidebar.css: -------------------------------------------------------------------------------- 1 | /* 侧边栏基础样式 */ 2 | .cerebr-sidebar { 3 | position: fixed; 4 | top: 20px; 5 | right: -450px; 6 | width: 430px; 7 | height: calc(100vh - 40px); 8 | background: var(--cerebr-bg-color); 9 | color: var(--cerebr-text-color); 10 | box-shadow: var(--cerebr-sidebar-box-shadow); 11 | z-index: 2147483647; 12 | border-radius: 12px; 13 | margin-right: 20px; 14 | overflow: hidden; 15 | visibility: hidden; 16 | transform: translateX(0); 17 | pointer-events: none; 18 | contain: style layout size; 19 | isolation: isolate; 20 | } 21 | 22 | .cerebr-sidebar.initialized { 23 | visibility: visible; 24 | transition: transform 0.3s ease; 25 | pointer-events: auto; 26 | } 27 | 28 | .cerebr-sidebar.visible { 29 | transform: translateX(-450px); 30 | } 31 | 32 | /* 侧边栏组件样式 */ 33 | .cerebr-sidebar__header { 34 | height: 40px; 35 | background: #f5f5f5; 36 | border-bottom: 1px solid #ddd; 37 | display: flex; 38 | align-items: center; 39 | padding: 0 15px; 40 | } 41 | 42 | .cerebr-sidebar__resizer { 43 | position: absolute; 44 | left: 0; 45 | top: 0; 46 | width: 5px; 47 | height: 100%; 48 | cursor: ew-resize; 49 | } 50 | 51 | .cerebr-sidebar__content { 52 | height: 100%; 53 | overflow: hidden; 54 | border-radius: 12px; 55 | contain: style layout size; 56 | } 57 | 58 | .cerebr-sidebar__iframe { 59 | width: 100%; 60 | height: 100%; 61 | border: none; 62 | background: var(--cerebr-bg-color); 63 | contain: strict; 64 | } -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | @import './base/variables.css'; 3 | @import './base/reset.css'; 4 | 5 | /* Utils */ 6 | @import './utils/animations.css'; 7 | 8 | /* Components */ 9 | @import './components/image-tag.css'; 10 | @import './components/settings.css'; 11 | @import './components/reasoning.css'; 12 | @import './components/chat-list.css'; 13 | @import './components/message.css'; 14 | @import './components/code.css'; 15 | @import './components/sidebar.css'; 16 | @import './components/context-menu.css'; 17 | @import './components/input.css'; 18 | @import './components/image-preview.css'; 19 | @import './components/api-settings.css'; 20 | @import './components/chat-container.css'; 21 | 22 | @import '../htmd/styles/math.css'; -------------------------------------------------------------------------------- /styles/utils/animations.css: -------------------------------------------------------------------------------- 1 | /* 动画 */ 2 | @keyframes messageAppear { 3 | 0% { 4 | opacity: 0; 5 | transform: translateY(16px) scale(0.98); 6 | } 7 | 40% { 8 | opacity: 1; 9 | } 10 | 100% { 11 | opacity: 1; 12 | transform: translateY(0) scale(1); 13 | } 14 | } 15 | 16 | @keyframes menuAppear { 17 | from { 18 | opacity: 0; 19 | transform: translateY(4px); 20 | } 21 | to { 22 | opacity: 1; 23 | transform: translateY(0); 24 | } 25 | } 26 | 27 | /* 加载状态样式 */ 28 | .loading-content #webpage-switch:checked { 29 | opacity: 0; 30 | } 31 | 32 | .loading-content #webpage-switch:checked + .slider { 33 | background-color: var(--cerebr-toggle-bg-off); 34 | } 35 | 36 | .loading-content #webpage-switch:checked + .slider:before { 37 | display: none; 38 | } 39 | 40 | .loading-content #webpage-switch:checked + .slider:after { 41 | content: ''; 42 | position: absolute; 43 | width: 16px; 44 | height: 16px; 45 | border: 2px solid transparent; 46 | border-top-color: var(--cerebr-text-color); 47 | border-radius: 50%; 48 | animation: loading-spinner 0.8s linear infinite; 49 | left: 50%; 50 | top: 50%; 51 | transform: translate(-50%, -50%); 52 | } 53 | 54 | @keyframes loading-spinner { 55 | 0% { transform: translate(-50%, -50%) rotate(0deg); } 56 | 100% { transform: translate(-50%, -50%) rotate(360deg); } 57 | } 58 | 59 | /* 确保加载状态下的UI响应性 */ 60 | .loading-content { 61 | pointer-events: auto !important; 62 | } 63 | 64 | .loading-content #webpage-switch { 65 | pointer-events: none; 66 | } 67 | 68 | .loading-content #chat-container, 69 | .loading-content #input-container, 70 | .loading-content #settings-button, 71 | .loading-content #settings-menu, 72 | .loading-content .menu-item:not(#webpage-qa) { 73 | pointer-events: auto !important; 74 | } 75 | 76 | /* 加载状态下的菜单项样式 */ 77 | .loading-content #webpage-qa { 78 | opacity: 0.7; 79 | cursor: wait; 80 | } 81 | 82 | /* 深色模式适配加载动画 */ 83 | @media (prefers-color-scheme: dark) { 84 | .loading-content #webpage-switch:checked + .slider:after { 85 | border-top-color: var(--cerebr-text-color); 86 | } 87 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "**/*", 6 | "use": "@vercel/static" 7 | } 8 | ], 9 | "cleanUrls": true, 10 | "trailingSlash": false 11 | } --------------------------------------------------------------------------------