diff --git a/.gitignore b/.gitignore index 5c29dff0..3fb19310 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,30 @@ config/lpmm_config.toml.bak template/compare/bot_config_template.toml template/compare/model_config_template.toml CLAUDE.md -MaiBot-Dashboard/ cloudflare-workers/ log_viewer/ +dev/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +node_modules/ +dist/ +dist-ssr/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? result.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1 @@ + diff --git a/dashboard/.prettierrc b/dashboard/.prettierrc new file mode 100644 index 00000000..e999e95b --- /dev/null +++ b/dashboard/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..b2a260f8 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,377 @@ +# MaiBot Dashboard + +> MaiBot 的现代化 Web 管理面板 - 基于 React 19 + TypeScript + Vite 构建 + +
+ +[![React](https://img.shields.io/badge/React-19.2-61DAFB?logo=react&logoColor=white)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-7.2-646CFF?logo=vite&logoColor=white)](https://vitejs.dev/) +[![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3.4-38B2AC?logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) + +
+ +## 📖 项目简介 + +MaiBot Dashboard 是 MaiBot 聊天机器人的 Web 管理界面,提供了直观的配置管理、实时监控、插件管理、资源管理等功能。通过自动解析后端配置类,动态生成表单,实现了配置的可视化编辑。 + +
+ MaiBot Dashboard 界面预览 +
+ +### ✨ 核心特性 + +- 🎨 **现代化 UI** - 基于 shadcn/ui 组件库,支持亮色/暗色主题切换 +- ⚡ **高性能** - 使用 Vite 7.2 构建,React 19 最新特性 +- 🔐 **安全认证** - Token 认证机制,支持自定义和自动生成 Token +- 📝 **智能配置** - 自动解析 Python dataclass,生成配置表单 +- 🎯 **类型安全** - 完整的 TypeScript 类型定义 +- 🔄 **实时更新** - WebSocket 实时日志流、配置自动保存 +- 📱 **响应式设计** - 完美适配桌面和移动设备 +- 💬 **本地对话** - 直接在 WebUI 与麦麦对话,无需外部平台 + +## 🎯 功能模块 + +### 📊 仪表盘(首页) +- **实时统计** - 总请求数、Token 消耗、费用统计、在线时长 +- **模型统计** - 各模型的使用次数、费用、平均响应时间 +- **趋势图表** - 每小时请求量、Token 消耗、费用趋势折线图 +- **模型分布** - 饼图展示模型使用占比 +- **最近活动** - 实时刷新的请求活动列表 + +### 💬 本地聊天室 +- **WebSocket 实时通信** - 与麦麦直接对话 +- **消息历史** - 自动加载 SQLite 存储的历史消息 +- **连接状态** - 实时显示 WebSocket 连接状态 +- **自定义昵称** - 可自定义用户身份 +- **移动端适配** - 完整的响应式聊天界面 + +### ⚙️ 配置管理 + +#### 麦麦主程序配置 +- **分组展示** - 配置项按功能分组(基础设置、功能开关等) +- **智能表单** - 根据配置类型自动生成对应控件 +- **自动保存** - 2秒防抖自动保存,无需手动操作 +- **一键重启** - 保存并重启麦麦,使配置生效 + +#### AI 模型厂商配置 +- **提供商管理** - 添加、编辑、删除 API 提供商 +- **模板选择** - 预设常用厂商模板(OpenAI、DeepSeek、硅基流动等) +- **连接测试** - ⚡ 测试提供商连接状态和 API Key 有效性 +- **批量操作** - 批量删除、批量测试所有提供商 +- **搜索过滤** - 按名称、URL、类型快速筛选 + +#### 模型管理与分配 +- **模型列表** - 管理可用的模型配置 +- **使用状态** - 显示模型是否被任务使用 +- **任务分配** - 为不同功能分配模型(回复、工具调用、VLM 等) +- **参数调整** - 温度、最大 Token 等参数配置 +- **新手引导** - 交互式引导教程 + +#### 适配器配置 +- **NapCat 配置** - 管理 QQ 机器人适配器 +- **Docker 支持** - 支持容器模式配置 +- **配置导入导出** - 跨环境迁移配置 + +### 📋 实时日志 +- **WebSocket 流式传输** - 实时接收后端日志 +- **虚拟滚动** - 高性能处理大量日志 +- **多级过滤** - 按日志级别(DEBUG/INFO/WARNING/ERROR)过滤 +- **模块过滤** - 按日志来源模块筛选 +- **时间范围** - 日期选择器筛选日志 +- **搜索高亮** - 关键字搜索并高亮显示 +- **字号调整** - 自定义日志显示字号和行间距 +- **日志导出** - 导出过滤后的日志 + +### 🔌 插件管理 +- **插件市场** - 浏览和搜索可用插件 +- **分类筛选** - 按类别、状态筛选插件 +- **一键安装** - 自动处理依赖并安装插件 +- **版本兼容** - 检查插件与 MaiBot 版本兼容性 +- **进度显示** - WebSocket 实时显示安装进度 +- **插件统计** - 下载量、更新时间等信息 +- **卸载更新** - 管理已安装插件 + +### 👤 人物关系管理 +- **人物列表** - 查看所有已知用户信息 +- **详情编辑** - 编辑用户昵称、备注等信息 +- **关系统计** - 查看消息数、互动频率等统计 +- **批量操作** - 批量删除用户记录 + +### 📦 资源管理 + +#### 表情包管理 +- **预览管理** - 图片/GIF 预览 +- **分类过滤** - 按注册状态、描述筛选 +- **编辑标签** - 修改表情包描述和属性 +- **批量禁用** - 启用/禁用表情包 + +#### 表达方式管理 +- **表达列表** - 查看麦麦学习的表达方式 +- **来源追踪** - 记录表达来源群组和用户 +- **编辑创建** - 手动添加或编辑表达 + +#### 知识图谱 +- **可视化展示** - ReactFlow 交互式图谱 +- **节点搜索** - 搜索实体和关系 +- **布局算法** - 自动布局优化 +- **详情查看** - 点击节点查看详细信息 + +### ⚙️ 系统设置 +- **主题切换** - 亮色/暗色/跟随系统 +- **动画控制** - 开启/关闭界面动画 +- **Token 管理** - 查看、复制、重新生成认证 Token +- **版本信息** - 查看前端和后端版本 + +## 🏗️ 技术架构 + +### 前端技术栈 + +``` +React 19.2.0 # UI 框架 +├── TypeScript 5.9 # 类型系统 +├── Vite 7.2 # 构建工具 +├── TanStack Router # 路由管理 +├── TanStack Virtual # 虚拟滚动 +├── Jotai # 状态管理 +├── Tailwind CSS 3.4 # 样式框架 +├── ReactFlow # 知识图谱可视化 +├── Recharts # 数据图表 +└── shadcn/ui # 组件库 + ├── Radix UI # 无障碍组件 + └── lucide-react # 图标库 +``` + +### 后端集成 + +``` +FastAPI # Python 后端框架 +├── WebSocket # 实时日志、聊天 +├── config_schema.py # 配置架构生成器 +├── config_routes.py # 配置管理 API +├── model_routes.py # 模型管理 API +├── chat_routes.py # 本地聊天 API +├── plugin_routes.py # 插件管理 API +├── person_routes.py # 人物管理 API +├── emoji_routes.py # 表情包管理 API +├── expression_routes.py # 表达管理 API +├── knowledge_routes.py # 知识图谱 API +├── logs_routes.py # 日志 API +└── tomlkit # TOML 文件处理 +``` + +## 📁 项目结构 + +``` +MaiBot-Dashboard/ +├── src/ +│ ├── components/ # 组件目录 +│ │ ├── ui/ # shadcn/ui 组件 +│ │ ├── layout.tsx # 布局组件(侧边栏+导航) +│ │ ├── tour/ # 新手引导组件 +│ │ ├── plugin-stats.tsx # 插件统计组件 +│ │ ├── RestartingOverlay.tsx # 重启遮罩 +│ │ └── use-theme.tsx # 主题管理 +│ ├── routes/ # 路由页面 +│ │ ├── index.tsx # 仪表盘首页 +│ │ ├── auth.tsx # 登录页 +│ │ ├── chat.tsx # 本地聊天室 +│ │ ├── logs.tsx # 日志查看 +│ │ ├── plugins.tsx # 插件管理 +│ │ ├── person.tsx # 人物管理 +│ │ ├── settings.tsx # 系统设置 +│ │ ├── config/ # 配置管理页面 +│ │ │ ├── bot.tsx # 麦麦主程序配置 +│ │ │ ├── modelProvider.tsx # 模型提供商 +│ │ │ ├── model.tsx # 模型管理 +│ │ │ └── adapter.tsx # 适配器配置 +│ │ └── resource/ # 资源管理页面 +│ │ ├── emoji.tsx # 表情包管理 +│ │ ├── expression.tsx # 表达方式管理 +│ │ └── knowledge-graph.tsx # 知识图谱 +│ ├── lib/ # 工具库 +│ │ ├── config-api.ts # 配置 API 客户端 +│ │ ├── plugin-api.ts # 插件 API 客户端 +│ │ ├── person-api.ts # 人物 API 客户端 +│ │ ├── expression-api.ts # 表达 API 客户端 +│ │ ├── log-websocket.ts # 日志 WebSocket +│ │ ├── fetch-with-auth.ts # 认证请求封装 +│ │ └── utils.ts # 通用工具函数 +│ ├── types/ # 类型定义 +│ │ ├── config-schema.ts # 配置架构类型 +│ │ ├── plugin.ts # 插件类型 +│ │ ├── person.ts # 人物类型 +│ │ └── expression.ts # 表达类型 +│ ├── hooks/ # React Hooks +│ │ ├── use-auth.ts # 认证逻辑 +│ │ ├── use-animation.ts # 动画控制 +│ │ └── use-toast.ts # 消息提示 +│ ├── store/ # 全局状态 +│ │ └── auth.ts # 认证状态 +│ ├── router.tsx # 路由配置 +│ ├── main.tsx # 应用入口 +│ └── index.css # 全局样式 +├── public/ # 静态资源 +├── vite.config.ts # Vite 配置 +├── tailwind.config.js # Tailwind 配置 +├── tsconfig.json # TypeScript 配置 +└── package.json # 依赖管理 +``` + +## 🚀 快速开始 + +### 环境要求 + +- Node.js >= 18.0.0 +- Bun >= 1.0.0 (推荐) 或 npm/yarn/pnpm + +### 安装依赖 + +```bash +# 使用 Bun(推荐) +bun install + +# 或使用 npm +npm install +``` + +### 开发模式 + +```bash +# 启动开发服务器 (默认端口: 7999) +bun run dev + +# 或 +npm run dev +``` + +访问 http://localhost:7999 查看应用。 + +### 生产构建 + +```bash +# 构建生产版本 +bun run build + +# 预览生产构建 +bun run preview +``` + +构建产物会输出到 `dist/` 目录,由 MaiBot 后端静态服务。 + +### 代码格式化 + +```bash +# 格式化代码 +bun run format +``` + +## 🔧 开发配置 + +### Vite 代理配置 + +开发模式下,Vite 会将 API 请求代理到后端: + +```typescript +// vite.config.ts +proxy: { + '/api': { + target: 'http://127.0.0.1:8001', + changeOrigin: true, + ws: true, // WebSocket 支持 + }, +}, +``` + +### 环境变量 + +开发环境默认使用 `http://localhost:7999`,生产环境使用相对路径。 + +## 📸 界面预览 + +### 仪表盘 +实时统计、模型使用分布、趋势图表 + +### 本地聊天 +直接与麦麦对话,消息实时同步 + +### 配置管理 +分组配置项,自动生成表单,自动保存 + +### 模型提供商 +一键测试连接状态,模板快速添加 + +### 日志查看 +实时日志流,多级过滤,虚拟滚动 + +## 📦 依赖说明 + +### 核心依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| react | ^19.2.0 | UI 框架 | +| react-dom | ^19.2.0 | React DOM 渲染 | +| typescript | ~5.9.3 | 类型系统 | +| vite | ^7.2.2 | 构建工具 | +| @tanstack/react-router | ^1.136.1 | 路由管理 | +| @tanstack/react-virtual | ^3.x | 虚拟滚动 | +| jotai | ^2.15.1 | 状态管理 | +| axios | ^1.13.2 | HTTP 客户端 | +| recharts | ^2.x | 数据图表 | +| reactflow | ^11.x | 知识图谱可视化 | +| dagre | ^0.8.x | 图布局算法 | + +### UI 组件库 + +| 包名 | 版本 | 用途 | +|------|------|------| +| @radix-ui/react-* | ^1.x | 无障碍组件基础 | +| lucide-react | ^0.553.0 | 图标库 | +| tailwindcss | ^3.4 | CSS 框架 | +| class-variance-authority | ^0.7.1 | 类名管理 | +| tailwind-merge | ^3.4.0 | Tailwind 类合并 | +| date-fns | ^3.x | 日期处理 | + + +## 🤝 贡献指南 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +### 代码规范 + +- 使用 TypeScript 严格模式 +- 遵循 ESLint 规则 +- 使用 Prettier 格式化代码 +- 组件使用函数式编写 +- 优先使用 Hooks +- 响应式设计优先(移动端适配) + +## 📄 开源协议 + +本项目基于 GPLv3 协议开源,详见 [LICENSE](./LICENSE) 文件。 + +## 👥 作者 + +**MotricSeven** - [GitHub](https://github.com/DrSmoothl) + +## 🙏 致谢 + +- [React](https://react.dev/) - UI 框架 +- [shadcn/ui](https://ui.shadcn.com/) - 组件库 +- [Radix UI](https://www.radix-ui.com/) - 无障碍组件 +- [TanStack Router](https://tanstack.com/router) - 路由解决方案 +- [TanStack Virtual](https://tanstack.com/virtual) - 虚拟滚动 +- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架 +- [ReactFlow](https://reactflow.dev/) - 流程图/知识图谱 +- [Recharts](https://recharts.org/) - React 图表库 + +--- + +
+Made with ❤️ by MotricSeven and Mai-with-u +
diff --git a/dashboard/components.json b/dashboard/components.json new file mode 100644 index 00000000..106236b1 --- /dev/null +++ b/dashboard/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "src/components", + "utils": "src/lib/utils", + "ui": "src/components/ui", + "lib": "src/lib", + "hooks": "src/hooks" + } +} diff --git a/dashboard/docs/main.png b/dashboard/docs/main.png new file mode 100644 index 00000000..5a0feed3 Binary files /dev/null and b/dashboard/docs/main.png differ diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js new file mode 100644 index 00000000..61baa885 --- /dev/null +++ b/dashboard/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + // 将所有 React Hooks 推荐规则降级为警告 + ...Object.keys(reactHooks.configs.recommended.rules).reduce((acc, key) => { + acc[key] = 'warn' + return acc + }, {}), + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + // 关闭或降级其他规则 + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + }, + }, +) diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000..98489824 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + MaiBot Dashboard + + +
+ + + diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..4266f5d4 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,95 @@ +{ + "name": "maibot-dashboard", + "private": true, + "version": "0.11.6", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-router": "^1.140.0", + "@tanstack/react-virtual": "^3.13.13", + "@tanstack/router-devtools": "^1.140.0", + "@types/dagre": "^0.7.53", + "@uiw/react-codemirror": "^4.25.3", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.0", + "@uppy/react": "^5.1.1", + "@uppy/xhr-upload": "^5.1.1", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "dagre": "^0.8.5", + "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", + "jotai": "^2.16.0", + "katex": "^0.16.27", + "lucide-react": "^0.556.0", + "react": "^19.2.1", + "react-day-picker": "^9.12.0", + "react-dom": "^19.2.1", + "react-joyride": "^2.9.3", + "react-markdown": "^10.1.0", + "reactflow": "^11.11.4", + "recharts": "3.5.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "smol-toml": "^1.5.2", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7" + } +} diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/dashboard/public/fonts/JetBrainsMono-Medium.ttf b/dashboard/public/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 00000000..97671156 Binary files /dev/null and b/dashboard/public/fonts/JetBrainsMono-Medium.ttf differ diff --git a/dashboard/public/maimai.ico b/dashboard/public/maimai.ico new file mode 100644 index 00000000..3c1b131e Binary files /dev/null and b/dashboard/public/maimai.ico differ diff --git a/dashboard/src/assets/maimai.ico b/dashboard/src/assets/maimai.ico new file mode 100644 index 00000000..578b11cd Binary files /dev/null and b/dashboard/src/assets/maimai.ico differ diff --git a/dashboard/src/assets/react.svg b/dashboard/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/dashboard/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/src/components/CodeEditor.tsx b/dashboard/src/components/CodeEditor.tsx new file mode 100644 index 00000000..52b22d53 --- /dev/null +++ b/dashboard/src/components/CodeEditor.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import CodeMirror from '@uiw/react-codemirror' +import { python } from '@codemirror/lang-python' +import { json, jsonParseLinter } from '@codemirror/lang-json' +import { oneDark } from '@codemirror/theme-one-dark' +import { EditorView } from '@codemirror/view' +import { StreamLanguage } from '@codemirror/language' +import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml' + +export type Language = 'python' | 'json' | 'toml' | 'text' + +interface CodeEditorProps { + value: string + onChange?: (value: string) => void + language?: Language + readOnly?: boolean + height?: string + minHeight?: string + maxHeight?: string + placeholder?: string + theme?: 'light' | 'dark' + className?: string +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const languageExtensions: Record = { + python: [python()], + json: [json(), jsonParseLinter()], + toml: [StreamLanguage.define(tomlMode)], + text: [], +} + +export function CodeEditor({ + value, + onChange, + language = 'text', + readOnly = false, + height = '400px', + minHeight, + maxHeight, + placeholder, + theme = 'dark', + className = '', +}: CodeEditorProps) { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return ( +
+ ) + } + + const extensions = [ + ...(languageExtensions[language] || []), + EditorView.lineWrapping, + // 应用 JetBrains Mono 字体 + EditorView.theme({ + '&': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-content': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-gutters': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-scroller': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + }), + ] + + if (readOnly) { + extensions.push(EditorView.editable.of(false)) + } + + return ( +
+ +
+ ) +} + +export default CodeEditor diff --git a/dashboard/src/components/ListFieldEditor.tsx b/dashboard/src/components/ListFieldEditor.tsx new file mode 100644 index 00000000..ac3373f9 --- /dev/null +++ b/dashboard/src/components/ListFieldEditor.tsx @@ -0,0 +1,525 @@ +/** + * ListFieldEditor - 动态数组字段编辑器 + * + * 支持功能: + * - 字符串数组 (string[]) + * - 数字数组 (number[]) + * - 对象数组 (object[]) - 根据 item_fields 定义渲染 + * - 拖拽排序 + * - 动态增删项 + */ + +import { useState, useCallback, useMemo } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' +import { Slider } from '@/components/ui/slider' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +// ============ 类型定义 ============ + +export interface ItemFieldDefinition { + /** 字段类型: "string" | "number" | "boolean" | "select" */ + type: string + label?: string + placeholder?: string + default?: unknown + /** select 类型的选项 */ + choices?: unknown[] + /** slider 类型的最小值 */ + min?: number + /** slider 类型的最大值 */ + max?: number + /** slider 类型的步进 */ + step?: number +} + +export interface ListFieldEditorProps { + /** 当前值 */ + value: unknown[] | unknown + /** 值变化回调 */ + onChange: (value: unknown[]) => void + /** 数组元素类型: "string" | "number" | "object" */ + itemType?: string + /** 当 itemType="object" 时的字段定义 */ + itemFields?: Record + /** 最小元素数量 */ + minItems?: number + /** 最大元素数量 */ + maxItems?: number + /** 是否禁用 */ + disabled?: boolean + /** 新项的占位符文字 */ + placeholder?: string +} + +// ============ 可排序项组件 ============ + +interface SortableItemProps { + id: string + index: number + itemType: string + itemFields?: Record + value: unknown + onChange: (value: unknown) => void + onRemove: () => void + disabled?: boolean + canRemove: boolean + placeholder?: string +} + +function SortableItem({ + id, + index, + itemType, + itemFields, + value, + onChange, + onRemove, + disabled, + canRemove, + placeholder, +}: SortableItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {/* 拖拽手柄 */} + + + {/* 内容区域 */} +
+ {itemType === 'object' && itemFields ? ( + } + onChange={onChange} + fields={itemFields} + disabled={disabled} + /> + ) : itemType === 'number' ? ( + onChange(parseFloat(e.target.value) || 0)} + placeholder={placeholder ?? `第 ${index + 1} 项`} + disabled={disabled} + className="font-mono" + /> + ) : ( + onChange(e.target.value)} + placeholder={placeholder ?? `第 ${index + 1} 项`} + disabled={disabled} + /> + )} +
+ + {/* 删除按钮 */} + +
+ ) +} + +// ============ 对象项编辑器 ============ + +interface ObjectItemEditorProps { + value: Record + onChange: (value: Record) => void + fields: Record + disabled?: boolean +} + +function ObjectItemEditor({ + value, + onChange, + fields, + disabled, +}: ObjectItemEditorProps) { + const handleFieldChange = useCallback( + (fieldName: string, fieldValue: unknown) => { + onChange({ + ...value, + [fieldName]: fieldValue, + }) + }, + [value, onChange] + ) + + const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => { + const fieldValue = value?.[fieldName] + + // boolean / switch + if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') { + return ( +
+ + handleFieldChange(fieldName, checked)} + disabled={disabled} + /> +
+ ) + } + + // slider (number with min/max) + if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) { + const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0 + return ( +
+
+ + {numValue} +
+ handleFieldChange(fieldName, v[0])} + min={fieldDef.min ?? 0} + max={fieldDef.max ?? 100} + step={fieldDef.step ?? 1} + disabled={disabled} + className="py-1" + /> +
+ ) + } + + // select + if (fieldDef.type === 'select' && fieldDef.choices) { + return ( +
+ + +
+ ) + } + + // number + if (fieldDef.type === 'number') { + return ( +
+ + + handleFieldChange(fieldName, parseFloat(e.target.value) || 0) + } + placeholder={fieldDef.placeholder} + disabled={disabled} + className="h-8 text-sm" + /> +
+ ) + } + + // string (default) + return ( +
+ + handleFieldChange(fieldName, e.target.value)} + placeholder={fieldDef.placeholder} + disabled={disabled} + className="h-8 text-sm" + /> +
+ ) + } + + return ( + + {Object.entries(fields).map(([fieldName, fieldDef]) => ( +
+ {renderField(fieldName, fieldDef)} +
+ ))} +
+ ) +} + +// ============ 主组件 ============ + +export function ListFieldEditor({ + value, + onChange, + itemType = 'string', + itemFields, + minItems, + maxItems, + disabled, + placeholder, +}: ListFieldEditorProps) { + // 确保 value 是数组 + const items: unknown[] = useMemo(() => { + if (Array.isArray(value)) return value + if (typeof value === 'string' && value.trim()) { + // 尝试解析逗号分隔的字符串 + return value.split(',').map((s: string) => s.trim()) + } + return [] + }, [value]) + + // 为每个项生成稳定的 ID + const [itemIds] = useState(() => new Map()) + const getItemId = useCallback( + (index: number) => { + if (!itemIds.has(index)) { + itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`) + } + return itemIds.get(index)! + }, + [itemIds] + ) + + // 同步 itemIds + const sortableIds = useMemo(() => { + // 清理多余的 ID + const newIds: string[] = [] + for (let i = 0; i < items.length; i++) { + newIds.push(getItemId(i)) + } + return newIds + }, [items.length, getItemId]) + + // DnD 传感器配置 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // 拖拽结束处理 + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + const oldIndex = sortableIds.indexOf(active.id as string) + const newIndex = sortableIds.indexOf(over.id as string) + const newItems = arrayMove(items, oldIndex, newIndex) + onChange(newItems) + } + }, + [items, sortableIds, onChange] + ) + + // 添加新项 + const handleAddItem = useCallback(() => { + if (maxItems != null && items.length >= maxItems) return + + let newItem: unknown + if (itemType === 'object' && itemFields) { + // 创建包含默认值的对象 + newItem = Object.fromEntries( + Object.entries(itemFields).map(([k, v]) => [k, v.default ?? '']) + ) + } else if (itemType === 'number') { + newItem = 0 + } else { + newItem = '' + } + + onChange([...items, newItem]) + }, [items, maxItems, itemType, itemFields, onChange]) + + // 修改项 + const handleItemChange = useCallback( + (index: number, newValue: unknown) => { + const newItems = [...items] + newItems[index] = newValue + onChange(newItems) + }, + [items, onChange] + ) + + // 删除项 + const handleRemoveItem = useCallback( + (index: number) => { + if (minItems != null && items.length <= minItems) return + const newItems = items.filter((_: unknown, i: number) => i !== index) + // 清理 itemIds 映射 + itemIds.delete(index) + onChange(newItems) + }, + [items, minItems, itemIds, onChange] + ) + + const canAdd = maxItems == null || items.length < maxItems + const canRemove = minItems == null || items.length > minItems + + return ( +
+ {/* 列表项 */} + {items.length === 0 ? ( +
+ + 暂无数据,点击下方按钮添加 +
+ ) : ( + + +
+ {items.map((item: unknown, index: number) => ( + handleItemChange(index, newValue)} + onRemove={() => handleRemoveItem(index)} + disabled={disabled} + canRemove={canRemove} + placeholder={placeholder} + /> + ))} +
+
+
+ )} + + {/* 添加按钮 */} + + + {/* 限制提示 */} + {(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && ( +

+ {minItems != null && maxItems != null + ? `允许 ${minItems} - ${maxItems} 项` + : minItems != null + ? `至少 ${minItems} 项` + : `最多 ${maxItems} 项`} +

+ )} +
+ ) +} + +export default ListFieldEditor diff --git a/dashboard/src/components/RestartingOverlay.legacy.tsx b/dashboard/src/components/RestartingOverlay.legacy.tsx new file mode 100644 index 00000000..aa368f1b --- /dev/null +++ b/dashboard/src/components/RestartingOverlay.legacy.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from 'react' +import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react' +import { Progress } from '@/components/ui/progress' + +/** + * @deprecated 请使用新的 RestartOverlay 组件 + * import { RestartOverlay } from '@/components/restart-overlay' + */ +interface RestartingOverlayProps { + onRestartComplete?: () => void + onRestartFailed?: () => void +} + +/** + * @deprecated 请使用新的 RestartOverlay 组件 + * import { RestartOverlay } from '@/components/restart-overlay' + */ +export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) { + const [progress, setProgress] = useState(0) + const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting') + const [elapsedTime, setElapsedTime] = useState(0) + const [checkAttempts, setCheckAttempts] = useState(0) + + useEffect(() => { + // 进度条动画 + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) return prev + return prev + 1 + }) + }, 200) + + // 计时器 + const timerInterval = setInterval(() => { + setElapsedTime((prev) => prev + 1) + }, 1000) + + // 等待3秒后开始检查状态(给后端重启时间) + const initialDelay = setTimeout(() => { + setStatus('checking') + startHealthCheck() + }, 3000) + + return () => { + clearInterval(progressInterval) + clearInterval(timerInterval) + clearTimeout(initialDelay) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const startHealthCheck = () => { + const maxAttempts = 60 // 最多尝试60次(约2分钟) + + const checkHealth = async () => { + try { + setCheckAttempts((prev) => prev + 1) + + const response = await fetch('/api/webui/system/status', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(3000), // 3秒超时 + }) + + if (response.ok) { + // 重启成功 + setProgress(100) + setStatus('success') + setTimeout(() => { + onRestartComplete?.() + }, 1500) + } else { + throw new Error('Status check failed') + } + } catch { + // 继续尝试 + if (checkAttempts < maxAttempts) { + setTimeout(checkHealth, 2000) // 2秒后重试 + } else { + // 超过最大尝试次数 + setStatus('failed') + onRestartFailed?.() + } + } + } + + checkHealth() + } + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + return ( +
+
+ {/* 图标和状态 */} +
+ {status === 'restarting' && ( + <> + +

正在重启麦麦

+

+ 请稍候,麦麦正在重启中... +

+ + )} + + {status === 'checking' && ( + <> + +

检查服务状态

+

+ 等待服务恢复... (尝试 {checkAttempts}/60) +

+ + )} + + {status === 'success' && ( + <> + +

重启成功

+

+ 正在跳转到登录页面... +

+ + )} + + {status === 'failed' && ( + <> + +

重启超时

+

+ 服务未能在预期时间内恢复,请手动检查或刷新页面 +

+ + )} +
+ + {/* 进度条 */} + {status !== 'failed' && ( +
+ +
+ {progress}% + 已用时: {formatTime(elapsedTime)} +
+
+ )} + + {/* 提示信息 */} +
+

+ {status === 'restarting' && '🔄 配置已保存,正在重启主程序...'} + {status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'} + {status === 'success' && '✅ 配置已生效,服务运行正常'} + {status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'} +

+
+ + {/* 失败时的操作按钮 */} + {status === 'failed' && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/dashboard/src/components/animation-provider.tsx b/dashboard/src/components/animation-provider.tsx new file mode 100644 index 00000000..2a72ba1d --- /dev/null +++ b/dashboard/src/components/animation-provider.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { AnimationContext } from '@/lib/animation-context' + +type AnimationProviderProps = { + children: ReactNode + defaultEnabled?: boolean + defaultWavesEnabled?: boolean + storageKey?: string + wavesStorageKey?: string +} + +export function AnimationProvider({ + children, + defaultEnabled = true, + defaultWavesEnabled = true, + storageKey = 'enable-animations', + wavesStorageKey = 'enable-waves-background', +}: AnimationProviderProps) { + const [enableAnimations, setEnableAnimations] = useState(() => { + const stored = localStorage.getItem(storageKey) + return stored !== null ? stored === 'true' : defaultEnabled + }) + + const [enableWavesBackground, setEnableWavesBackground] = useState(() => { + const stored = localStorage.getItem(wavesStorageKey) + return stored !== null ? stored === 'true' : defaultWavesEnabled + }) + + useEffect(() => { + const root = document.documentElement + + if (enableAnimations) { + root.classList.remove('no-animations') + } else { + root.classList.add('no-animations') + } + + localStorage.setItem(storageKey, String(enableAnimations)) + }, [enableAnimations, storageKey]) + + useEffect(() => { + localStorage.setItem(wavesStorageKey, String(enableWavesBackground)) + }, [enableWavesBackground, wavesStorageKey]) + + const value = { + enableAnimations, + setEnableAnimations, + enableWavesBackground, + setEnableWavesBackground, + } + + return {children} +} diff --git a/dashboard/src/components/back-to-top.tsx b/dashboard/src/components/back-to-top.tsx new file mode 100644 index 00000000..3473566e --- /dev/null +++ b/dashboard/src/components/back-to-top.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, useRef } from 'react' +import { ArrowUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +export function BackToTop() { + const [progress, setProgress] = useState(0) + const [visible, setVisible] = useState(false) + const scrollerRef = useRef(null) + + useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLElement + + // 简单的启发式:如果是主要滚动容器(通常高度较大) + // 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个 + // 只要它有足够的滚动空间 + if (target.scrollHeight > target.clientHeight + 100) { + scrollerRef.current = target + + const scrollTop = target.scrollTop + const height = target.scrollHeight - target.clientHeight + const scrolled = height > 0 ? (scrollTop / height) * 100 : 0 + + setProgress(scrolled) + setVisible(scrollTop > 300) + } + } + + // 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡 + window.addEventListener('scroll', handleScroll, { capture: true, passive: true }) + return () => window.removeEventListener('scroll', handleScroll, { capture: true }) + }, []) + + const scrollToTop = () => { + scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + } + + // SVG 环形进度条参数 + const radius = 18 + const circumference = 2 * Math.PI * radius + const strokeDashoffset = circumference - (progress / 100) * circumference + + return ( +
+ +
+ ) +} diff --git a/dashboard/src/components/emoji-thumbnail.tsx b/dashboard/src/components/emoji-thumbnail.tsx new file mode 100644 index 00000000..c92cd91a --- /dev/null +++ b/dashboard/src/components/emoji-thumbnail.tsx @@ -0,0 +1,123 @@ +/** + * 表情包缩略图组件 + * + * 特性: + * - 自动处理 202 响应(缩略图生成中) + * - 显示 Skeleton 占位符 + * - 自动重试加载 + * - 加载失败显示占位图标 + */ + +import { useState, useEffect, useCallback } from 'react' +import { Skeleton } from '@/components/ui/skeleton' +import { ImageIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface EmojiThumbnailProps { + src: string + alt?: string + className?: string + /** 最大重试次数 */ + maxRetries?: number + /** 重试间隔(毫秒) */ + retryInterval?: number +} + +type LoadingState = 'loading' | 'loaded' | 'generating' | 'error' + +export function EmojiThumbnail({ + src, + alt = '表情包', + className, + maxRetries = 5, + retryInterval = 1500, +}: EmojiThumbnailProps) { + const [state, setState] = useState('loading') + const [retryCount, setRetryCount] = useState(0) + const [imageSrc, setImageSrc] = useState(null) + const [currentSrc, setCurrentSrc] = useState(src) + + // 当 src 变化时重置状态 + if (src !== currentSrc) { + setState('loading') + setRetryCount(0) + setImageSrc(null) + setCurrentSrc(src) + } + + const loadImage = useCallback(async () => { + try { + const response = await fetch(src, { + credentials: 'include', // 携带 Cookie + }) + + if (response.status === 202) { + // 缩略图正在生成中 + setState('generating') + + if (retryCount < maxRetries) { + // 延迟后重试 + setTimeout(() => { + setRetryCount(prev => prev + 1) + }, retryInterval) + } else { + // 超过最大重试次数,显示错误 + setState('error') + } + return + } + + if (!response.ok) { + setState('error') + return + } + + // 成功获取图片 + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + setImageSrc(objectUrl) + setState('loaded') + } catch (error) { + console.error('加载缩略图失败:', error) + setState('error') + } + }, [src, retryCount, maxRetries, retryInterval]) + + useEffect(() => { + loadImage() + }, [loadImage]) + + // 清理 Object URL + useEffect(() => { + return () => { + if (imageSrc) { + URL.revokeObjectURL(imageSrc) + } + } + }, [imageSrc]) + + // 加载中或生成中显示 Skeleton + if (state === 'loading' || state === 'generating') { + return ( + + ) + } + + // 加载失败显示占位图标 + if (state === 'error' || !imageSrc) { + return ( +
+ +
+ ) + } + + // 加载成功显示图片 + return ( + {alt} + ) +} diff --git a/dashboard/src/components/error-boundary.tsx b/dashboard/src/components/error-boundary.tsx new file mode 100644 index 00000000..7e3e0700 --- /dev/null +++ b/dashboard/src/components/error-boundary.tsx @@ -0,0 +1,307 @@ +import { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' +import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { useState } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null +} + +// 解析堆栈信息为结构化数据 +interface StackFrame { + functionName: string + fileName: string + lineNumber: string + columnNumber: string + raw: string +} + +function parseStackTrace(stack: string): StackFrame[] { + const lines = stack.split('\n').slice(1) // 跳过第一行(错误消息) + const frames: StackFrame[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('at ')) continue + + // 匹配格式: at functionName (fileName:line:column) 或 at fileName:line:column + const match = trimmed.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/) + if (match) { + frames.push({ + functionName: match[1] || '', + fileName: match[2], + lineNumber: match[3], + columnNumber: match[4], + raw: trimmed, + }) + } else { + frames.push({ + functionName: '', + fileName: '', + lineNumber: '', + columnNumber: '', + raw: trimmed, + }) + } + } + + return frames +} + +// 错误详情展示组件(函数组件,用于使用 hooks) +function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo | null }) { + const [isStackOpen, setIsStackOpen] = useState(true) + const [isComponentStackOpen, setIsComponentStackOpen] = useState(false) + const [copied, setCopied] = useState(false) + + const stackFrames = error.stack ? parseStackTrace(error.stack) : [] + + const copyErrorInfo = async () => { + const errorText = ` +Error: ${error.name} +Message: ${error.message} + +Stack Trace: +${error.stack || 'No stack trace available'} + +Component Stack: +${errorInfo?.componentStack || 'No component stack available'} + +URL: ${window.location.href} +User Agent: ${navigator.userAgent} +Time: ${new Date().toISOString()} + `.trim() + + try { + await navigator.clipboard.writeText(errorText) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy:', err) + } + } + + return ( +
+ {/* 错误消息 */} + + + + {error.name}: {error.message} + + + + {/* 堆栈跟踪 */} + {stackFrames.length > 0 && ( + + + + + + +
+ {stackFrames.map((frame, index) => ( +
+
+ + {index + 1}. + +
+ + {frame.functionName} + + {frame.fileName && ( +
+ {frame.fileName} + {frame.lineNumber && ( + + :{frame.lineNumber}:{frame.columnNumber} + + )} +
+ )} +
+
+
+ ))} +
+
+
+
+ )} + + {/* 组件堆栈 */} + {errorInfo?.componentStack && ( + + + + + + +
+                {errorInfo.componentStack}
+              
+
+
+
+ )} + + {/* 复制按钮 */} + +
+ ) +} + +// 错误回退 UI +function ErrorFallback({ + error, + errorInfo, +}: { + error: Error + errorInfo: ErrorInfo | null +}) { + const handleGoHome = () => { + window.location.href = '/' + } + + const handleRefresh = () => { + window.location.reload() + } + + return ( +
+ + +
+ +
+ 页面出现了问题 + + 应用程序遇到了意外错误。您可以尝试刷新页面或返回首页。 + +
+ + + + + {/* 操作按钮 */} +
+ + +
+ + {/* 提示信息 */} +

+ 如果问题持续存在,请将错误信息复制并反馈给开发者 +

+
+
+
+ ) +} + +// 错误边界类组件 +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { + hasError: false, + error: null, + errorInfo: null, + } + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + this.setState({ errorInfo }) + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }) + } + + render() { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( + + ) + } + + return this.props.children + } +} + +// 路由级别的错误边界组件(用于 TanStack Router) +export function RouteErrorBoundary({ error }: { error: Error }) { + return ( + + ) +} diff --git a/dashboard/src/components/expression-reviewer.tsx b/dashboard/src/components/expression-reviewer.tsx new file mode 100644 index 00000000..512146ef --- /dev/null +++ b/dashboard/src/components/expression-reviewer.tsx @@ -0,0 +1,1597 @@ +/** + * 表达方式审核器弹窗组件 + * + * 功能: + * 1. 分页显示待审核/已通过/已拒绝的表达方式 + * 2. 支持单条通过/拒绝 + * 3. 支持批量操作 + * 4. 冲突检测(防止与AI自动检查冲突) + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Checkbox } from '@/components/ui/checkbox' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, +} from '@/components/ui/pagination' +import { useToast } from '@/hooks/use-toast' +import { + CheckCircle2, + XCircle, + Clock, + Search, + RefreshCw, + ChevronLeft, + ChevronRight, + Bot, + User, + AlertCircle, + List, + Zap, + X, + Ban, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { + getReviewStats, + getReviewList, + batchReviewExpressions, + getChatList, +} from '@/lib/expression-api' +import type { Expression, ReviewStats, ChatInfo, BatchReviewItem } from '@/types/expression' + +interface ExpressionReviewerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerProps) { + // 审核模式:list(列表模式)或 quick(快速审核模式) + const [reviewMode, setReviewMode] = useState<'list' | 'quick'>('list') + const [stats, setStats] = useState(null) + const [expressions, setExpressions] = useState([]) + + // 快速审核模式状态 + const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked') + const [quickExpressions, setQuickExpressions] = useState([]) + const [quickCurrentIndex, setQuickCurrentIndex] = useState(0) + const [quickLoading, setQuickLoading] = useState(false) + const [quickTotal, setQuickTotal] = useState(0) + const [quickPage, setQuickPage] = useState(1) + const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null) + const [swipeOffset, setSwipeOffset] = useState(0) + const [isAnimating, setIsAnimating] = useState(false) + const [conflictId, setConflictId] = useState(null) + const cardRef = useRef(null) + const dragStartRef = useRef<{ x: number; y: number } | null>(null) + const isDraggingRef = useRef(false) + const [loading, setLoading] = useState(false) + const [statsLoading, setStatsLoading] = useState(false) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [jumpPage, setJumpPage] = useState('') + const [filterType, setFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked') + const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [processingIds, setProcessingIds] = useState>(new Set()) + const [chatNameMap, setChatNameMap] = useState>(new Map()) + const { toast } = useToast() + + // 加载统计数据 + const loadStats = useCallback(async () => { + try { + setStatsLoading(true) + const data = await getReviewStats() + setStats(data) + } catch (error) { + console.error('加载统计失败:', error) + } finally { + setStatsLoading(false) + } + }, []) + + // 加载列表 + const loadList = useCallback(async () => { + try { + setLoading(true) + const response = await getReviewList({ + page, + page_size: pageSize, + filter_type: filterType, + search: search || undefined, + }) + setExpressions(response.data) + setTotal(response.total) + } catch (error) { + toast({ + title: '加载失败', + description: error instanceof Error ? error.message : '无法加载列表', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + }, [page, pageSize, filterType, search, toast]) + + // 加载聊天名称映射 + const loadChatNames = useCallback(async () => { + try { + const response = await getChatList() + if (response?.data) { + const nameMap = new Map() + response.data.forEach((chat: ChatInfo) => { + nameMap.set(chat.chat_id, chat.chat_name) + }) + setChatNameMap(nameMap) + } + } catch (error) { + console.error('加载聊天名称失败:', error) + } + }, []) + + // 快速审核模式 - 加载数据 + const loadQuickList = useCallback(async (resetIndex = true, append = false) => { + try { + setQuickLoading(true) + const pageToLoad = append ? quickPage + 1 : quickPage + const response = await getReviewList({ + page: pageToLoad, + page_size: 20, + filter_type: quickFilterType, + }) + + if (append) { + // 追加模式:拼接数据 + setQuickExpressions(prev => [...prev, ...response.data]) + setQuickPage(pageToLoad) + } else { + // 替换模式 + setQuickExpressions(response.data) + } + + setQuickTotal(response.total) + if (resetIndex) { + setQuickCurrentIndex(0) + } + } catch (error) { + toast({ + title: '加载失败', + description: error instanceof Error ? error.message : '无法加载列表', + variant: 'destructive', + }) + } finally { + setQuickLoading(false) + } + }, [quickPage, quickFilterType, toast]) + + // 快速审核模式 - 切换筛选时重置 + useEffect(() => { + if (reviewMode === 'quick') { + setQuickPage(1) + setQuickCurrentIndex(0) + } + }, [quickFilterType, reviewMode]) + + // 快速审核模式 - 加载数据 + useEffect(() => { + if (open && reviewMode === 'quick') { + loadQuickList() + loadStats() + } + }, [open, reviewMode, quickPage, quickFilterType, loadQuickList, loadStats]) + + // 获取当前卡片允许的滑动方向 + const getAllowedDirections = useCallback((expr: Expression | undefined) => { + if (!expr) return { left: false, right: false } + + if (quickFilterType === 'unchecked') { + // 待审核:左拒绝,右通过 + return { left: true, right: true } + } else if (quickFilterType === 'passed') { + // 已通过:只能左滑改为拒绝 + return { left: true, right: false } + } else if (quickFilterType === 'rejected') { + // 已拒绝:只能右滑改为通过 + return { left: false, right: true } + } else { + // 全部:智能判断 + if (!expr.checked) { + // 未审核:双向 + return { left: true, right: true } + } else if (expr.rejected) { + // 已拒绝:只能右滑 + return { left: false, right: true } + } else { + // 已通过:只能左滑 + return { left: true, right: false } + } + } + }, [quickFilterType]) + + // 快速审核 - 执行审核操作 + const handleQuickReview = useCallback(async (rejected: boolean) => { + const currentExpr = quickExpressions[quickCurrentIndex] + if (!currentExpr || isAnimating) return + + const directions = getAllowedDirections(currentExpr) + if ((rejected && !directions.left) || (!rejected && !directions.right)) { + return + } + + setIsAnimating(true) + setSwipeDirection(rejected ? 'left' : 'right') + setSwipeOffset(rejected ? -400 : 400) + + try { + const response = await batchReviewExpressions([{ + id: currentExpr.id, + rejected, + require_unchecked: quickFilterType === 'unchecked', + }]) + + if (response.results[0]?.success) { + toast({ + title: rejected ? '已拒绝' : '已通过', + description: `表达方式 #${currentExpr.id} ${rejected ? '已拒绝' : '已通过'}`, + }) + + // 从列表中移除当前项 + setTimeout(() => { + setQuickExpressions(prev => prev.filter((_, i) => i !== quickCurrentIndex)) + setQuickTotal(prev => prev - 1) + + // 如果当前索引超出范围,调整索引 + if (quickCurrentIndex >= quickExpressions.length - 1) { + setQuickCurrentIndex(Math.max(0, quickCurrentIndex - 1)) + } + + // 重置状态 + setSwipeDirection(null) + setSwipeOffset(0) + setIsAnimating(false) + + // 刷新统计 + loadStats() + + // 如果列表为空且还有更多数据,加载下一页 + if (quickExpressions.length <= 1 && quickTotal > 1) { + loadQuickList(false) + } + }, 300) + } else { + // 冲突处理 + setConflictId(currentExpr.id) + toast({ + title: '数据冲突', + description: '该条目已被后台任务处理,正在刷新数据...', + variant: 'destructive', + }) + + // 播放冲突动画后刷新 + setTimeout(() => { + setConflictId(null) + setSwipeDirection(null) + setSwipeOffset(0) + setIsAnimating(false) + loadQuickList(false) // 重新加载当前页 + loadStats() + }, 1500) + } + } catch (error) { + toast({ + title: '操作失败', + description: error instanceof Error ? error.message : '未知错误', + variant: 'destructive', + }) + setSwipeDirection(null) + setSwipeOffset(0) + setIsAnimating(false) + } + }, [quickExpressions, quickCurrentIndex, isAnimating, getAllowedDirections, quickFilterType, toast, loadStats, quickTotal, loadQuickList]) + + // 拖拽开始 + const handleDragStart = useCallback((clientX: number, clientY: number) => { + if (isAnimating) return + dragStartRef.current = { x: clientX, y: clientY } + isDraggingRef.current = false + }, [isAnimating]) + + // 触发无效操作动画 + const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => { + if (isAnimating) return + setIsAnimating(true) + // 模拟向该方向移动一点 + setSwipeOffset(direction === 'left' ? -30 : 30) + + setTimeout(() => { + setSwipeOffset(0) + setTimeout(() => setIsAnimating(false), 300) + }, 150) + }, [isAnimating]) + + // 拖拽移动 + const handleDragMove = useCallback((clientX: number) => { + if (!dragStartRef.current || isAnimating) return + + const deltaX = clientX - dragStartRef.current.x + const currentExpr = quickExpressions[quickCurrentIndex] + const directions = getAllowedDirections(currentExpr) + + // 检查方向限制 + if (deltaX < 0 && !directions.left) { + setSwipeOffset(deltaX * 0.2) // 提供阻力反馈 + setSwipeDirection(null) + return + } + if (deltaX > 0 && !directions.right) { + setSwipeOffset(deltaX * 0.2) + setSwipeDirection(null) + return + } + + isDraggingRef.current = true + setSwipeOffset(deltaX) + + if (Math.abs(deltaX) > 50) { + setSwipeDirection(deltaX > 0 ? 'right' : 'left') + } else { + setSwipeDirection(null) + } + }, [quickExpressions, quickCurrentIndex, getAllowedDirections, isAnimating]) + + // 拖拽结束 + const handleDragEnd = useCallback(() => { + if (!dragStartRef.current) return + + const threshold = 100 + if (Math.abs(swipeOffset) > threshold && swipeDirection) { + handleQuickReview(swipeDirection === 'left') + } else { + // 回弹 + setSwipeOffset(0) + setSwipeDirection(null) + } + + dragStartRef.current = null + isDraggingRef.current = false + }, [swipeOffset, swipeDirection, handleQuickReview]) + + // 鼠标事件处理 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + handleDragStart(e.clientX, e.clientY) + }, [handleDragStart]) + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (dragStartRef.current) { + e.preventDefault() + handleDragMove(e.clientX) + } + }, [handleDragMove]) + + const handleMouseUp = useCallback(() => { + handleDragEnd() + }, [handleDragEnd]) + + const handleMouseLeave = useCallback(() => { + if (dragStartRef.current) { + handleDragEnd() + } + }, [handleDragEnd]) + + // 触摸事件处理 + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0] + handleDragStart(touch.clientX, touch.clientY) + }, [handleDragStart]) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0] + handleDragMove(touch.clientX) + }, [handleDragMove]) + + const handleTouchEnd = useCallback(() => { + handleDragEnd() + }, [handleDragEnd]) + + // 键盘事件处理 + useEffect(() => { + if (!open || reviewMode !== 'quick') return + + const handleKeyDown = (e: KeyboardEvent) => { + // 只处理方向键 + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return + + // 阻止事件继续传播,避免被 Tabs 组件捕获 + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + + if (isAnimating || quickLoading) return + + const currentExpr = quickExpressions[quickCurrentIndex] + const directions = getAllowedDirections(currentExpr) + + if (e.key === 'ArrowLeft') { + if (directions.left) { + handleQuickReview(true) // 拒绝 + } else { + triggerInvalidAnimation('left') + } + } else if (e.key === 'ArrowRight') { + if (directions.right) { + handleQuickReview(false) // 通过 + } else { + triggerInvalidAnimation('right') + } + } else if (e.key === 'ArrowDown') { + // 跳过当前项 + if (quickCurrentIndex < quickExpressions.length - 1) { + setQuickCurrentIndex(prev => prev + 1) + } + } else if (e.key === 'ArrowUp') { + // 返回上一项 + if (quickCurrentIndex > 0) { + setQuickCurrentIndex(prev => prev - 1) + } + } + } + + // 使用 capture 模式,在事件到达 Tabs 之前拦截 + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [open, reviewMode, quickExpressions, quickCurrentIndex, isAnimating, quickLoading, getAllowedDirections, handleQuickReview, triggerInvalidAnimation]) + + // 动态加载更多数据 - 当接近列表末尾时自动加载 + useEffect(() => { + if (!open || reviewMode !== 'quick' || quickLoading) return + + // 距离末尾还有5个或更少时,且还有更多数据时,自动加载 + const remaining = quickExpressions.length - quickCurrentIndex - 1 + const hasMoreData = quickExpressions.length < quickTotal + + if (remaining <= 5 && hasMoreData) { + loadQuickList(false, true) // 追加模式 + } + }, [open, reviewMode, quickCurrentIndex, quickExpressions.length, quickTotal, quickLoading, loadQuickList]) + + // 初始加载 + useEffect(() => { + if (open) { + loadStats() + loadList() + loadChatNames() + } + }, [open, loadStats, loadList, loadChatNames]) + + // 切换筛选时重置页码 + useEffect(() => { + setPage(1) + setSelectedIds(new Set()) + }, [filterType, search]) + + // 列表加载时清空选择 + useEffect(() => { + setSelectedIds(new Set()) + }, [expressions]) + + // 搜索处理 + const handleSearch = () => { + setSearch(searchInput) + setPage(1) + } + + // 获取聊天名称 + const getChatName = (chatId: string): string => { + return chatNameMap.get(chatId) || chatId + } + + // 单条审核 + const handleReview = async (id: number, rejected: boolean) => { + try { + setProcessingIds((prev) => new Set(prev).add(id)) + + const response = await batchReviewExpressions([ + { id, rejected, require_unchecked: filterType === 'unchecked' } + ]) + + if (response.results[0]?.success) { + toast({ + title: rejected ? '已拒绝' : '已通过', + description: `表达方式 #${id} ${rejected ? '已拒绝' : '已通过'}`, + }) + // 刷新列表和统计 + loadList() + loadStats() + } else { + toast({ + title: '操作失败', + description: response.results[0]?.message || '未知错误', + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '操作失败', + description: error instanceof Error ? error.message : '未知错误', + variant: 'destructive', + }) + } finally { + setProcessingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + } + } + + // 批量审核 + const handleBatchReview = async (rejected: boolean) => { + if (selectedIds.size === 0) { + toast({ + title: '请选择', + description: '请先选择要审核的表达方式', + variant: 'destructive', + }) + return + } + + try { + setLoading(true) + + const items: BatchReviewItem[] = Array.from(selectedIds).map((id) => ({ + id, + rejected, + require_unchecked: filterType === 'unchecked', + })) + + const response = await batchReviewExpressions(items) + + toast({ + title: '批量审核完成', + description: `成功 ${response.succeeded} 条,失败 ${response.failed} 条`, + variant: response.failed > 0 ? 'destructive' : 'default', + }) + + // 清空选择并刷新 + setSelectedIds(new Set()) + loadList() + loadStats() + } catch (error) { + toast({ + title: '批量审核失败', + description: error instanceof Error ? error.message : '未知错误', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + // 全选/取消全选 + const handleSelectAll = () => { + if (selectedIds.size === expressions.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(expressions.map((e) => e.id))) + } + } + + // 切换选择 + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + // 格式化时间 + const formatTime = (timestamp: number | null) => { + if (!timestamp) return '-' + return new Date(timestamp * 1000).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + // 获取状态标签 + const getStatusBadge = (expr: Expression) => { + if (!expr.checked) { + return ( + + + 待审核 + + ) + } + if (expr.rejected) { + return ( + + + 已拒绝 + + ) + } + return ( + + + 已通过 + + ) + } + + // 获取修改者标签 + const getModifierBadge = (modifier: string | null) => { + if (!modifier) return null + if (modifier === 'ai') { + return ( + + + AI + + ) + } + return ( + + + 人工 + + ) + } + + const totalPages = Math.ceil(total / pageSize) + + // 生成页码数组 + const getPageNumbers = () => { + const pages: (number | 'ellipsis')[] = [] + if (totalPages <= 7) { + // 总页数不多,全部显示 + for (let i = 1; i <= totalPages; i++) { + pages.push(i) + } + } else { + // 总是显示第一页 + pages.push(1) + + if (page > 3) { + pages.push('ellipsis') + } + + // 当前页附近的页码 + const start = Math.max(2, page - 1) + const end = Math.min(totalPages - 1, page + 1) + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + if (page < totalPages - 2) { + pages.push('ellipsis') + } + + // 总是显示最后一页 + if (totalPages > 1) { + pages.push(totalPages) + } + } + return pages + } + + // 处理页码跳转 + const handleJumpPage = () => { + const targetPage = parseInt(jumpPage, 10) + if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) { + setPage(targetPage) + setJumpPage('') + } + } + + return ( + + + {/* 浏览器标签页风格的模式切换器 */} +
+ {/* 列表模式标签 */} + + + {/* 快速审核标签 */} + + + {/* 右侧空白区域和关闭按钮 */} +
+ +
+ + {/* 列表模式内容 */} + {reviewMode === 'list' && ( + <> + + 表达方式审核 + + 审核麦麦学习到的表达方式。通过审核的项目才会被使用(可在配置中调整),被拒绝的项目永远不会被使用。 + + + {/* 统计卡片 */} +
+
+
+ {statsLoading ? '-' : stats?.unchecked ?? 0} +
+
待审核
+
+
+
+ {statsLoading ? '-' : stats?.passed ?? 0} +
+
已通过
+
+
+
+ {statsLoading ? '-' : stats?.rejected ?? 0} +
+
已拒绝
+
+
+
+ {statsLoading ? '-' : stats?.total ?? 0} +
+
总计
+
+
+
+ + {/* 筛选和操作栏 */} +
+ setFilterType(v as typeof filterType)} + className="w-full" + > + + + + 待审核 + 待审 + ({stats?.unchecked ?? 0}) + + + + 已通过 + 通过 + ({stats?.passed ?? 0}) + + + + 已拒绝 + 拒绝 + ({stats?.rejected ?? 0}) + + + 全部 + ({stats?.total ?? 0}) + + + + +
+
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="pl-9" + /> +
+
+ + +
+ + {/* 批量操作按钮 */} + {selectedIds.size > 0 && ( +
+ {filterType === 'unchecked' ? ( + // 待审核:显示批量通过和批量拒绝 + <> + + + + ) : filterType === 'passed' ? ( + // 已通过:只显示批量改为拒绝 + + ) : filterType === 'rejected' ? ( + // 已拒绝:只显示批量改为通过 + + ) : ( + // 全部:显示两个按钮 + <> + + + + )} +
+ )} +
+
+ + {/* 列表区域 */} + + {loading && expressions.length === 0 ? ( +
+ +
+ ) : expressions.length === 0 ? ( +
+ +

没有找到表达方式

+
+ ) : ( +
+ {/* 全选 */} + {expressions.length > 0 && ( +
+
+ 0} + onCheckedChange={handleSelectAll} + /> + + {selectedIds.size === expressions.length && expressions.length > 0 + ? `已全选当前页 (${expressions.length} 条)` + : `全选当前页 (${expressions.length} 条)`} + +
+ {selectedIds.size > 0 && ( + + )} +
+ )} + + {/* 表达方式列表 */} + {expressions.map((expr) => ( +
+
+ {/* 选择框 */} + toggleSelect(expr.id)} + disabled={processingIds.has(expr.id)} + className="mt-1" + /> + + {/* 内容 */} +
+ {/* 情景 */} +
+ 情景: +

{expr.situation}

+
+ + {/* 风格 */} +
+ 风格: +

{expr.style}

+
+ + {/* 元信息 */} +
+ #{expr.id} + · + + {getChatName(expr.chat_id)} + + · + {formatTime(expr.create_date)} +
+ {getStatusBadge(expr)} + {getModifierBadge(expr.modified_by)} +
+
+
+ + {/* 操作按钮 */} +
+ {filterType === 'unchecked' ? ( + <> + + + + ) : filterType === 'passed' ? ( + + ) : filterType === 'rejected' ? ( + + ) : ( + // all 模式下显示两个按钮 + <> + {expr.rejected ? ( + + ) : expr.checked ? ( + + ) : ( + <> + + + + )} + + )} +
+
+
+ ))} +
+ )} +
+ + {/* 分页 */} +
+ {/* 左侧:每页显示数量 */} +
+ 每页 + + + 共 {total} 条 +
+ + {/* 中间:页码导航 */} + + + + + + + {getPageNumbers().map((pageNum, idx) => ( + + {pageNum === 'ellipsis' ? ( + + ) : ( + { + e.preventDefault() + setPage(pageNum) + }} + className="h-8 w-8 cursor-pointer" + > + {pageNum} + + )} + + ))} + + + + + + + + {/* 右侧:跳转 */} +
+ 跳至 + setJumpPage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleJumpPage()} + className="w-16 h-8 text-center" + placeholder={page.toString()} + /> + + +
+
+ + )} + + {/* 快速审核模式内容 */} + {reviewMode === 'quick' && ( +
+ {/* 顶部筛选和统计 */} +
+ {/* 统计信息 */} +
+
+ + 待审核: {stats?.unchecked ?? 0} + + + 已通过: {stats?.passed ?? 0} + + + 已拒绝: {stats?.rejected ?? 0} + +
+ +
+ + {/* 筛选标签 */} + setQuickFilterType(v as typeof quickFilterType)} + className="w-full" + > + + + + 待审核 + 待审 + + + + 已通过 + 通过 + + + + 已拒绝 + 拒绝 + + + 全部 + + + +
+ + {/* 卡片区域 */} +
+ {quickLoading && quickExpressions.length === 0 ? ( +
+ +

加载中...

+
+ ) : quickExpressions.length === 0 ? ( +
+
+ +
+

全部审核完成!

+

当前筛选条件下没有待处理的项目

+
+ ) : ( + <> + {/* 进度提示 */} +
+ {quickCurrentIndex + 1} / {quickExpressions.length} + {quickTotal > quickExpressions.length && ( + (共 {quickTotal} 条) + )} +
+ + {/* 方向提示 (仅针对当前卡片) */} +
+ {(() => { + const currentExpr = quickExpressions[quickCurrentIndex] + const directions = getAllowedDirections(currentExpr) + return ( + <> +
+ + 拒绝 +
+
+ 通过 + +
+ + ) + })()} +
+ + {/* 堆叠卡片 */} +
+ {quickExpressions + .slice(quickCurrentIndex, quickCurrentIndex + 5) + .reverse() + .map((expr, reverseIndex, array) => { + const index = array.length - 1 - reverseIndex // 0 is current, 1 is next... + const isCurrent = index === 0 + + // 计算样式 + let style: React.CSSProperties = { + zIndex: 5 - index, + position: 'absolute', + width: '100%', + transition: isCurrent && !isDraggingRef.current ? 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' : 'none', + } + + if (isCurrent) { + // 当前卡片样式 + style = { + ...style, + transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`, + opacity: Math.max(0, 1 - Math.abs(swipeOffset) / 500), + cursor: 'grab', + } + } else { + // 后方卡片样式 + const progress = Math.min(Math.abs(swipeOffset) / 200, 1) // 0 to 1 + + // 计算指定索引的样式属性 + const getStyleForIndex = (i: number) => { + // 增加一些伪随机的错位感,让堆叠看起来不那么死板 + const randomRotate = (i * 7) % 5 + const randomX = (i * 13) % 7 + + return { + scale: 1 - i * 0.05, + translateY: i * 12, + // 错位效果:奇偶交替旋转 + 伪随机偏移 + rotate: (i % 2 === 0 ? 1 : -1) * (i * 2) + randomRotate, + translateX: (i % 2 === 0 ? -1 : 1) * (i * 4) + randomX, + } + } + + const base = getStyleForIndex(index) + const target = getStyleForIndex(index - 1) + + // 插值计算:所有后方卡片都会跟随第一张卡片的滑动而向前移动 + const currentScale = base.scale + (target.scale - base.scale) * progress + const currentTranslateY = base.translateY + (target.translateY - base.translateY) * progress + const currentRotate = base.rotate + (target.rotate - base.rotate) * progress + const currentTranslateX = base.translateX + (target.translateX - base.translateX) * progress + + style = { + ...style, + transform: `translate3d(${currentTranslateX}px, ${currentTranslateY}px, 0) scale(${currentScale}) rotate(${currentRotate}deg)`, + opacity: 1 - index * 0.15, + filter: `blur(${Math.max(0, index * 1 - progress)}px)`, // 模糊度也随之减小 + pointerEvents: 'none', + } + } + + return ( +
+ {/* 冲突提示遮罩 */} + {isCurrent && conflictId === expr.id && ( +
+
+
+ +
+

数据已更新

+

后台任务已处理此条目

+
+ )} + + {/* 无效操作提示 */} + {isCurrent && ( +
10 && !getAllowedDirections(expr).right)) + ? "opacity-100" + : "opacity-0" + )}> +
+ +
+
+ )} + +
+ {/* 状态和ID */} +
+ #{expr.id} +
+ {getStatusBadge(expr)} + {getModifierBadge(expr.modified_by)} +
+
+ + {/* 情景 */} +
+ +
+

{expr.situation}

+
+
+ + {/* 风格 */} +
+ +
+ {expr.style.split(/[,,]/).map((s, i) => ( + + {s.trim()} + + ))} +
+
+
+ + {/* 底部信息 */} +
+
+
+ +
+ + {getChatName(expr.chat_id)} + +
+ {formatTime(expr.create_date)} +
+
+ ) + })} +
+ + {/* 操作按钮(移动端) */} +
+ {(() => { + const currentExpr = quickExpressions[quickCurrentIndex] + const directions = getAllowedDirections(currentExpr) + return ( + <> + + + + ) + })()} +
+ + )} +
+ + {/* 底部快捷键提示(桌面端) */} +
+
+ + 拒绝 +
+
+ + 通过 +
+
+ + 上一条 +
+
+ + 下一条 +
+ | + 拖拽卡片滑动审核 +
+
+ )} + +
+ ) +} diff --git a/dashboard/src/components/http-warning-banner.tsx b/dashboard/src/components/http-warning-banner.tsx new file mode 100644 index 00000000..55735928 --- /dev/null +++ b/dashboard/src/components/http-warning-banner.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { AlertTriangle, X } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * HTTP 警告横幅组件 + * 当用户通过 HTTP 访问时显示安全警告 + */ +export function HttpWarningBanner() { + // 直接计算初始状态,避免 effect 中调用 setState + const isHttp = window.location.protocol === 'http:' + const hostname = window.location.hostname.toLowerCase() + const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' + const dismissed = sessionStorage.getItem('http-warning-dismissed') === 'true' + + // 本地访问(localhost/127.0.0.1)不显示警告 + const [isVisible, setIsVisible] = useState(isHttp && !isLocalhost && !dismissed) + const [isDismissed, setIsDismissed] = useState(false) + + const handleDismiss = () => { + setIsDismissed(true) + setIsVisible(false) + sessionStorage.setItem('http-warning-dismissed', 'true') + } + + if (!isVisible || isDismissed) { + return null + } + + return ( +
+
+
+
+ +
+

+ 安全警告: + 您正在使用 HTTP 访问 MaiBot WebUI +

+

+ 如果这是公网服务器,您的数据(包括 Token、聊天记录等)可能在传输过程中被窃取。强烈建议使用 HTTPS 访问或仅在本地网络使用。 +

+
+
+ +
+
+
+ ) +} diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts new file mode 100644 index 00000000..cc623fba --- /dev/null +++ b/dashboard/src/components/index.ts @@ -0,0 +1,13 @@ +export { CodeEditor } from './CodeEditor' +export type { Language } from './CodeEditor' + +// 重启遮罩层 +export { RestartOverlay } from './restart-overlay' +// 兼容旧版本 +export { RestartingOverlay } from './RestartingOverlay.legacy' + +// 列表编辑器 +export { ListFieldEditor } from './ListFieldEditor' + +// Markdown 渲染器 +export { MarkdownRenderer } from './markdown-renderer' diff --git a/dashboard/src/components/layout.tsx b/dashboard/src/components/layout.tsx new file mode 100644 index 00000000..4a675b9f --- /dev/null +++ b/dashboard/src/components/layout.tsx @@ -0,0 +1,409 @@ +import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Link, useMatchRoute } from '@tanstack/react-router' +import { useTheme, toggleThemeWithTransition } from './use-theme' +import { useAuthGuard } from '@/hooks/use-auth' +import { logout } from '@/lib/fetch-with-auth' +import { Button } from '@/components/ui/button' +import { Kbd } from '@/components/ui/kbd' +import { SearchDialog } from '@/components/search-dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { HttpWarningBanner } from '@/components/http-warning-banner' +import { BackToTop } from '@/components/back-to-top' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { formatVersion } from '@/lib/version' +import type { ReactNode, ComponentType } from 'react' +import type { LucideProps } from 'lucide-react' + +interface LayoutProps { + children: ReactNode +} + +interface MenuItem { + icon: ComponentType + label: string + path: string + tourId?: string +} + +interface MenuSection { + title: string + items: MenuItem[] +} + +export function Layout({ children }: LayoutProps) { + const { checking } = useAuthGuard() // 检查认证状态 + + const [sidebarOpen, setSidebarOpen] = useState(true) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态 + const { theme, setTheme } = useTheme() + const matchRoute = useMatchRoute() + + // 侧边栏状态变化时,延迟启用/禁用 tooltip + useEffect(() => { + if (sidebarOpen) { + // 侧边栏展开时,立即禁用 tooltip + setTooltipsEnabled(false) + } else { + // 侧边栏收起时,等待动画完成后再启用 tooltip + const timer = setTimeout(() => { + setTooltipsEnabled(true) + }, 350) // 稍大于 CSS transition duration (300ms) + return () => clearTimeout(timer) + } + }, [sidebarOpen]) + + // 搜索快捷键监听(Cmd/Ctrl + K) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setSearchOpen(true) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + // 认证检查中,显示加载状态 + if (checking) { + return ( +
+
正在验证登录状态...
+
+ ) + } + + // 菜单项配置 - 分块结构 + const menuSections: MenuSection[] = [ + { + title: '概览', + items: [ + { icon: Home, label: '首页', path: '/' }, + ], + }, + { + title: '麦麦配置编辑', + items: [ + { icon: FileText, label: '麦麦主程序配置', path: '/config/bot' }, + { icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' }, + { icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' }, + { icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' }, + ], + }, + { + title: '麦麦资源管理', + items: [ + { icon: Smile, label: '表情包管理', path: '/resource/emoji' }, + { icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' }, + { icon: Hash, label: '黑话管理', path: '/resource/jargon' }, + { icon: UserCircle, label: '人物信息管理', path: '/resource/person' }, + { icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' }, + { icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' }, + ], + }, + { + title: '扩展与监控', + items: [ + { icon: Package, label: '插件市场', path: '/plugins' }, + { icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' }, + { icon: Sliders, label: '插件配置', path: '/plugin-config' }, + { icon: FileSearch, label: '日志查看器', path: '/logs' }, + { icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' }, + { icon: MessageSquare, label: '本地聊天室', path: '/chat' }, + ], + }, + { + title: '系统', + items: [ + { icon: Settings, label: '系统设置', path: '/settings' }, + ], + }, + ] + + // 获取实际应用的主题(处理 system 情况) + const getActualTheme = () => { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme + } + + const actualTheme = getActualTheme() + + // 登出处理 + const handleLogout = async () => { + await logout() + } + + return ( + +
+ {/* Sidebar */} + + + {/* Mobile overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + + {/* Main content */} +
+ {/* HTTP 安全警告横幅 */} + + + {/* Topbar */} +
+
+ {/* 移动端菜单按钮 */} + + + {/* 桌面端侧边栏收起/展开按钮 */} + +
+ +
+ {/* 年度总结入口 */} + + + + + {/* 搜索框 */} + + + {/* 搜索对话框 */} + + + {/* 麦麦文档链接 */} + + + {/* 主题切换按钮 */} + + + {/* 分隔线 */} +
+ + {/* 登出按钮 */} + +
+
+ + {/* Page content */} +
{children}
+ + {/* Back to Top Button */} + +
+
+ + ) +} diff --git a/dashboard/src/components/markdown-renderer.tsx b/dashboard/src/components/markdown-renderer.tsx new file mode 100644 index 00000000..429ea41f --- /dev/null +++ b/dashboard/src/components/markdown-renderer.tsx @@ -0,0 +1,134 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' +import type { ComponentPropsWithoutRef } from 'react' + +interface MarkdownRendererProps { + content: string + className?: string +} + +export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { + return ( +
+ & { inline?: boolean }) { + return inline ? ( + + {children} + + ) : ( + + {children} + + ) + }, + // 自定义表格样式 + table({ children, ...props }) { + return ( +
+ + {children} +
+
+ ) + }, + th({ children, ...props }) { + return ( + + {children} + + ) + }, + td({ children, ...props }) { + return ( + + {children} + + ) + }, + // 自定义链接样式 + a({ children, ...props }) { + return ( + + {children} + + ) + }, + // 自定义引用块样式 + blockquote({ children, ...props }) { + return ( +
+ {children} +
+ ) + }, + // 自定义标题样式 + h1({ children, ...props }) { + return ( +

+ {children} +

+ ) + }, + h2({ children, ...props }) { + return ( +

+ {children} +

+ ) + }, + h3({ children, ...props }) { + return ( +

+ {children} +

+ ) + }, + h4({ children, ...props }) { + return ( +

+ {children} +

+ ) + }, + // 自定义列表样式 + ul({ children, ...props }) { + return ( +
    + {children} +
+ ) + }, + ol({ children, ...props }) { + return ( +
    + {children} +
+ ) + }, + // 自定义段落样式 + p({ children, ...props }) { + return ( +

+ {children} +

+ ) + }, + // 自定义分隔线样式 + hr({ ...props }) { + return
+ }, + }} + > + {content} +
+
+ ) +} diff --git a/dashboard/src/components/plugin-stats.tsx b/dashboard/src/components/plugin-stats.tsx new file mode 100644 index 00000000..63bee1b8 --- /dev/null +++ b/dashboard/src/components/plugin-stats.tsx @@ -0,0 +1,302 @@ +/** + * 插件统计组件 + * 显示点赞、点踩、评分和下载量 + */ + +import { useState, useEffect } from 'react' +import { ThumbsUp, ThumbsDown, Star, Download } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Textarea } from '@/components/ui/textarea' +import { useToast } from '@/hooks/use-toast' +import { + getPluginStats, + likePlugin, + dislikePlugin, + ratePlugin, + type PluginStatsData, +} from '@/lib/plugin-stats' + +interface PluginStatsProps { + pluginId: string + compact?: boolean // 紧凑模式(只显示数字) +} + +export function PluginStats({ pluginId, compact = false }: PluginStatsProps) { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [userRating, setUserRating] = useState(0) + const [userComment, setUserComment] = useState('') + const [isRatingDialogOpen, setIsRatingDialogOpen] = useState(false) + const { toast } = useToast() + + // 加载统计数据 + const loadStats = async () => { + setLoading(true) + const data = await getPluginStats(pluginId) + if (data) { + setStats(data) + } + setLoading(false) + } + + useEffect(() => { + loadStats() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginId]) + + // 处理点赞 + const handleLike = async () => { + const result = await likePlugin(pluginId) + + if (result.success) { + toast({ title: '已点赞', description: '感谢你的支持!' }) + loadStats() // 重新加载统计数据 + } else { + toast({ + title: '点赞失败', + description: result.error || '未知错误', + variant: 'destructive', + }) + } + } + + // 处理点踩 + const handleDislike = async () => { + const result = await dislikePlugin(pluginId) + + if (result.success) { + toast({ title: '已反馈', description: '感谢你的反馈!' }) + loadStats() + } else { + toast({ + title: '操作失败', + description: result.error || '未知错误', + variant: 'destructive', + }) + } + } + + // 提交评分 + const handleSubmitRating = async () => { + if (userRating === 0) { + toast({ + title: '请选择评分', + description: '至少选择 1 颗星', + variant: 'destructive', + }) + return + } + + const result = await ratePlugin(pluginId, userRating, userComment || undefined) + + if (result.success) { + toast({ title: '评分成功', description: '感谢你的评价!' }) + setIsRatingDialogOpen(false) + setUserRating(0) + setUserComment('') + loadStats() + } else { + toast({ + title: '评分失败', + description: result.error || '未知错误', + variant: 'destructive', + }) + } + } + + if (loading) { + return ( +
+
+ + - +
+
+ + - +
+
+ ) + } + + if (!stats) { + return null + } + + // 紧凑模式 + if (compact) { + return ( +
+
+ + {stats.downloads.toLocaleString()} +
+
+ + {stats.rating.toFixed(1)} +
+
+ + {stats.likes} +
+
+ ) + } + + // 完整模式 + return ( +
+ {/* 统计数字 */} +
+
+ + {stats.downloads.toLocaleString()} + 下载量 +
+ +
+ + {stats.rating.toFixed(1)} + {stats.rating_count} 条评价 +
+ +
+ + {stats.likes} + 点赞 +
+ +
+ + {stats.dislikes} + 点踩 +
+
+ + {/* 操作按钮 */} +
+ + + + + + + + + + + 为插件评分 + 分享你的使用体验,帮助其他用户 + + +
+ {/* 星级评分 */} +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ + {userRating === 0 && '点击星星进行评分'} + {userRating === 1 && '很差'} + {userRating === 2 && '一般'} + {userRating === 3 && '还行'} + {userRating === 4 && '不错'} + {userRating === 5 && '非常好'} + +
+ + {/* 评论 */} +
+ +