From 3f5a9445a7bfb985b2c7a02fe2d9d0ce58d4dd2e Mon Sep 17 00:00:00 2001 From: songkwon <353127982@qq.com> Date: Thu, 11 Jun 2026 18:30:11 +0800 Subject: [PATCH 01/87] Add GPL v3 license Added the GNU General Public License version 3 to the project. --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From db725eb03974159c01fc1514bb020b256bab1678 Mon Sep 17 00:00:00 2001 From: songkwon Date: Sat, 13 Jun 2026 08:57:46 +0800 Subject: [PATCH 02/87] feat(docs): Mintlify-style MDX rendering engine Add a frontend MDX engine that renders documentation with the full Mintlify component set (callouts, Card/CardGroup/Columns, Tabs, Steps, Accordion/Expandable, CodeGroup, Frame, Tooltip, Badge, fields, Tree, Mermaid, ...). Markdown entries are compiled at request time via next-mdx-remote/rsc with a matching component library and theme-aware styles. Plumb raw markdown end-to-end as `content_md`: docsctl DocumentRecord -> documents.jsonl -> deploy -> store.Page -> getPage -> doc page, which renders it through the MDX engine (falling back to content_html/content_text). Embedded seed docs showcase every component. Note: next-mdx-remote v6 defaults blockJS:true, which strips JSX expression props (cols={3}); the engine sets blockJS:false + blockDangerousJS:true so Mintlify expression props work while eval/Function/require/process stay blocked. Also bundles in-progress admin/UI and infra changes from prior work. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + README.md | 22 + backend/cmd/modex-api/main.go | 10 +- backend/internal/api/llm.go | 341 ++ backend/internal/api/posthog.go | 137 + backend/internal/api/server.go | 244 +- backend/internal/deploy/artifact.go | 1 + backend/internal/search/search.go | 4 + backend/internal/store/memory.go | 140 +- backend/internal/store/models.go | 34 +- backend/internal/store/seed.go | 15 + .../store/seeddata/cbb-build-cache.md | 37 + backend/internal/store/seeddata/demo-guide.md | 185 + .../store/seeddata/demo-maintenance.md | 45 + frontend/app/admin/analytics/page.tsx | 66 - frontend/app/admin/categories/page.tsx | 36 +- frontend/app/admin/mcp-logs/page.tsx | 105 +- frontend/app/admin/modules/page.tsx | 34 +- frontend/app/admin/page.tsx | 21 +- frontend/app/admin/releases/page.tsx | 118 +- frontend/app/admin/search-logs/page.tsx | 125 +- frontend/app/admin/settings/page.tsx | 67 +- frontend/app/admin/teams/page.tsx | 59 +- frontend/app/admin/users/page.tsx | 95 +- frontend/app/categories/[id]/page.tsx | 148 + .../[docsVersion]/[entryKey]/page.tsx | 133 +- frontend/app/globals.css | 635 ++- frontend/app/me/mcp/page.tsx | 117 +- frontend/app/page.tsx | 71 +- frontend/components/admin-shell.tsx | 1 - frontend/components/category-tree.tsx | 92 +- frontend/components/code-block-copy.tsx | 47 + frontend/components/doc-read-stats.tsx | 154 + frontend/components/mdx/accordion.tsx | 54 + frontend/components/mdx/blocks.tsx | 34 + frontend/components/mdx/callout.tsx | 38 + frontend/components/mdx/card.tsx | 61 + frontend/components/mdx/code.tsx | 115 + frontend/components/mdx/fields.tsx | 87 + frontend/components/mdx/icon.tsx | 105 + frontend/components/mdx/index.ts | 59 + frontend/components/mdx/inline.tsx | 42 + frontend/components/mdx/mdx-content.tsx | 49 + frontend/components/mdx/mermaid.tsx | 43 + frontend/components/mdx/steps.tsx | 31 + frontend/components/mdx/tabs.tsx | 34 + frontend/components/mdx/tree.tsx | 20 + frontend/components/module-card.tsx | 4 +- frontend/components/search-results.tsx | 4 +- frontend/components/ui/copy-button.tsx | 39 + frontend/components/ui/empty-state.tsx | 28 + frontend/components/ui/switch.tsx | 40 + frontend/lib/api.ts | 20 +- frontend/lib/use-doc-search.tsx | 16 + frontend/package-lock.json | 3869 +++++++++++++++-- frontend/package.json | 8 +- mcp/npx/package.json | 2 +- tools/docsctl/cmd/docsctl/main.go | 17 +- tools/docsctl/internal/docs/build.go | 219 +- tools/docsctl/internal/docs/types.go | 1 + 60 files changed, 7568 insertions(+), 814 deletions(-) create mode 100644 backend/internal/api/llm.go create mode 100644 backend/internal/api/posthog.go create mode 100644 backend/internal/store/seed.go create mode 100644 backend/internal/store/seeddata/cbb-build-cache.md create mode 100644 backend/internal/store/seeddata/demo-guide.md create mode 100644 backend/internal/store/seeddata/demo-maintenance.md delete mode 100644 frontend/app/admin/analytics/page.tsx create mode 100644 frontend/app/categories/[id]/page.tsx create mode 100644 frontend/components/code-block-copy.tsx create mode 100644 frontend/components/doc-read-stats.tsx create mode 100644 frontend/components/mdx/accordion.tsx create mode 100644 frontend/components/mdx/blocks.tsx create mode 100644 frontend/components/mdx/callout.tsx create mode 100644 frontend/components/mdx/card.tsx create mode 100644 frontend/components/mdx/code.tsx create mode 100644 frontend/components/mdx/fields.tsx create mode 100644 frontend/components/mdx/icon.tsx create mode 100644 frontend/components/mdx/index.ts create mode 100644 frontend/components/mdx/inline.tsx create mode 100644 frontend/components/mdx/mdx-content.tsx create mode 100644 frontend/components/mdx/mermaid.tsx create mode 100644 frontend/components/mdx/steps.tsx create mode 100644 frontend/components/mdx/tabs.tsx create mode 100644 frontend/components/mdx/tree.tsx create mode 100644 frontend/components/ui/copy-button.tsx create mode 100644 frontend/components/ui/empty-state.tsx create mode 100644 frontend/components/ui/switch.tsx diff --git a/.gitignore b/.gitignore index 4ba35af..5e1c792 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ deploy/.env # docsctl build output **/.modex/ +/tools/docsctl/docsctl + +# Serena tool metadata +.serena/ # Node / Next.js node_modules/ diff --git a/README.md b/README.md index 084d103..5847dc1 100644 --- a/README.md +++ b/README.md @@ -412,3 +412,25 @@ migration, and the provider seams are already in place for that work. 部署鉴权已在 `/api/deploy` 实现(支持全局 `DOCS_DEPLOY_TOKEN` 或 per-module token)。 这使得 rd-doc 这样的外部仓库可以持续、结构化地同步到 modex 的指定领域,同时保持构建的完整性。 + +## License + +Modex is licensed under the **GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)**. + +Copyright (C) 2026 songkwon + +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 . + +> AGPL note: if you run a modified version of Modex as a network service, you +> must make the complete source code of your modified version available to its +> users. See the full text in [LICENSE](LICENSE). diff --git a/backend/cmd/modex-api/main.go b/backend/cmd/modex-api/main.go index 795ff8a..50cf5ad 100644 --- a/backend/cmd/modex-api/main.go +++ b/backend/cmd/modex-api/main.go @@ -23,10 +23,15 @@ func main() { httpServer := &http.Server{Addr: addr, Handler: srv.Handler()} // Periodic + graceful-shutdown persistence when DATA_DIR is configured. + // The autosave goroutine gets its own stop channel (closed by main after the + // OS signal). Sharing the signal channel would race: a signal delivered to a + // channel wakes only ONE receiver, so the autosave goroutine could consume it + // and leave main's `<-stop` blocked forever, skipping the final save. stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + autosaveStop := make(chan struct{}) if snapshotPath != "" { - go autosave(st, snapshotPath, stop) + go autosave(st, snapshotPath, autosaveStop) } go func() { @@ -37,6 +42,7 @@ func main() { }() <-stop + close(autosaveStop) if snapshotPath != "" { if err := st.Save(snapshotPath); err != nil { log.Printf("final snapshot save failed: %v", err) @@ -68,7 +74,7 @@ func loadStore() (*store.Store, string) { } } -func autosave(st *store.Store, path string, stop <-chan os.Signal) { +func autosave(st *store.Store, path string, stop <-chan struct{}) { interval := 60 * time.Second if v := os.Getenv("DATA_SAVE_INTERVAL_SECONDS"); v != "" { if d, err := time.ParseDuration(v + "s"); err == nil && d > 0 { diff --git a/backend/internal/api/llm.go b/backend/internal/api/llm.go new file mode 100644 index 0000000..343442e --- /dev/null +++ b/backend/internal/api/llm.go @@ -0,0 +1,341 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "modex/backend/internal/store" +) + +// LLM API formats supported for the AI-ask feature. The frontend exposes these +// as selectable "API 格式" options so any mainstream provider can be wired in. +const ( + protoOpenAIChat = "openai-chat" // POST /chat/completions (OpenAI & all compatible vendors) + protoOpenAIResponses = "openai-responses" // POST /responses (OpenAI Responses API) + protoAnthropic = "anthropic" // POST /v1/messages (Anthropic Messages) + protoGemini = "gemini" // POST /v1beta/models/{m}:generateContent (Google Gemini) +) + +func normalizeProtocol(p string) string { + switch strings.TrimSpace(strings.ToLower(p)) { + case protoOpenAIResponses: + return protoOpenAIResponses + case protoAnthropic: + return protoAnthropic + case protoGemini: + return protoGemini + default: + return protoOpenAIChat + } +} + +// chatComplete sends a single-turn (system + user) completion request using the +// protocol configured in settings and returns the assistant's text. +func chatComplete(ctx context.Context, ai store.AISettings, system, user string) (string, error) { + base := strings.TrimRight(strings.TrimSpace(ai.AskBaseURL), "/") + switch normalizeProtocol(ai.AskProtocol) { + case protoAnthropic: + return chatAnthropic(ctx, base, ai.AskAPIKey, ai.AskModel, system, user) + case protoGemini: + return chatGemini(ctx, base, ai.AskAPIKey, ai.AskModel, system, user) + case protoOpenAIResponses: + return chatOpenAIResponses(ctx, base, ai.AskAPIKey, ai.AskModel, system, user) + default: + return chatOpenAIChat(ctx, base, ai.AskAPIKey, ai.AskModel, system, user) + } +} + +// listModels fetches available model ids from the provider for the given +// protocol so the admin never has to type a model name by hand. +func listModels(ctx context.Context, protocol, base, key string) ([]string, error) { + base = strings.TrimRight(strings.TrimSpace(base), "/") + switch normalizeProtocol(protocol) { + case protoAnthropic: + return modelsAnthropic(ctx, base, key) + case protoGemini: + return modelsGemini(ctx, base, key) + default: // openai-chat & openai-responses share the /models listing + return modelsOpenAI(ctx, base, key) + } +} + +func httpJSON(ctx context.Context, method, url string, headers map[string]string, body any) ([]byte, int, error) { + var reader io.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = strings.NewReader(string(raw)) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + if v != "" { + req.Header.Set(k, v) + } + } + resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + return raw, resp.StatusCode, nil +} + +// ---- OpenAI Chat Completions ------------------------------------------------ + +func chatOpenAIChat(ctx context.Context, base, key, model, system, user string) (string, error) { + raw, code, err := httpJSON(ctx, http.MethodPost, base+"/chat/completions", + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + "temperature": 0.2, + "stream": false, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("chat endpoint %d: %s", code, string(raw)) + } + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("chat endpoint returned no choices") + } + return out.Choices[0].Message.Content, nil +} + +// ---- OpenAI Responses API --------------------------------------------------- + +func chatOpenAIResponses(ctx context.Context, base, key, model, system, user string) (string, error) { + raw, code, err := httpJSON(ctx, http.MethodPost, base+"/responses", + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{ + "model": model, + "instructions": system, + "input": user, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("responses endpoint %d: %s", code, string(raw)) + } + // Prefer the flattened convenience field; fall back to walking output[]. + var out struct { + OutputText string `json:"output_text"` + Output []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if strings.TrimSpace(out.OutputText) != "" { + return out.OutputText, nil + } + for _, o := range out.Output { + for _, c := range o.Content { + if c.Text != "" { + return c.Text, nil + } + } + } + return "", fmt.Errorf("responses endpoint returned no text") +} + +// ---- Anthropic Messages ----------------------------------------------------- + +// anthropicBase normalizes a base URL to the host root (Messages lives at +// /v1/messages), tolerating a base entered with or without a trailing /v1. +func anthropicBase(base string) string { + return strings.TrimSuffix(strings.TrimRight(base, "/"), "/v1") +} + +func chatAnthropic(ctx context.Context, base, key, model, system, user string) (string, error) { + headers := map[string]string{ + "Content-Type": "application/json", + "x-api-key": key, + "anthropic-version": "2023-06-01", + } + raw, code, err := httpJSON(ctx, http.MethodPost, anthropicBase(base)+"/v1/messages", headers, + map[string]any{ + "model": model, + "max_tokens": 1024, + "system": system, + "messages": []map[string]string{ + {"role": "user", "content": user}, + }, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("anthropic endpoint %d: %s", code, string(raw)) + } + var out struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + for _, c := range out.Content { + if c.Type == "text" && c.Text != "" { + return c.Text, nil + } + } + return "", fmt.Errorf("anthropic endpoint returned no text") +} + +// ---- Google Gemini ---------------------------------------------------------- + +// geminiBase normalizes to the API root; generateContent lives under +// /v1beta/models/{model}:generateContent. +func geminiBase(base string) string { + b := strings.TrimRight(base, "/") + b = strings.TrimSuffix(b, "/v1beta") + b = strings.TrimSuffix(b, "/v1") + return b +} + +func chatGemini(ctx context.Context, base, key, model, system, user string) (string, error) { + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", geminiBase(base), model, key) + raw, code, err := httpJSON(ctx, http.MethodPost, url, + map[string]string{"Content-Type": "application/json"}, + map[string]any{ + "systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}}, + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]string{{"text": user}}}, + }, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("gemini endpoint %d: %s", code, string(raw)) + } + var out struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + for _, c := range out.Candidates { + for _, p := range c.Content.Parts { + if p.Text != "" { + return p.Text, nil + } + } + } + return "", fmt.Errorf("gemini endpoint returned no text") +} + +// ---- Model listing per protocol --------------------------------------------- + +func modelsOpenAI(ctx context.Context, base, key string) ([]string, error) { + raw, code, err := httpJSON(ctx, http.MethodGet, base+"/models", + map[string]string{"Authorization": bearer(key)}, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(raw, &parsed) + ids := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + ids = append(ids, m.ID) + } + } + return ids, nil +} + +func modelsAnthropic(ctx context.Context, base, key string) ([]string, error) { + raw, code, err := httpJSON(ctx, http.MethodGet, anthropicBase(base)+"/v1/models", + map[string]string{"x-api-key": key, "anthropic-version": "2023-06-01"}, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(raw, &parsed) + ids := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + ids = append(ids, m.ID) + } + } + return ids, nil +} + +func modelsGemini(ctx context.Context, base, key string) ([]string, error) { + raw, code, err := httpJSON(ctx, http.MethodGet, + fmt.Sprintf("%s/v1beta/models?key=%s", geminiBase(base), key), nil, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + _ = json.Unmarshal(raw, &parsed) + ids := make([]string, 0, len(parsed.Models)) + for _, m := range parsed.Models { + ids = append(ids, strings.TrimPrefix(m.Name, "models/")) + } + return ids, nil +} + +func bearer(key string) string { + if key == "" { + return "" + } + return "Bearer " + key +} diff --git a/backend/internal/api/posthog.go b/backend/internal/api/posthog.go new file mode 100644 index 0000000..adfa5eb --- /dev/null +++ b/backend/internal/api/posthog.go @@ -0,0 +1,137 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "modex/backend/internal/store" +) + +// posthogConfigured reports whether the server-side PostHog query credentials +// are present. This is distinct from the frontend NEXT_PUBLIC_POSTHOG_KEY +// (capture key); querying read stats needs a personal/project API key. +func posthogConfigured() bool { + return os.Getenv("POSTHOG_PERSONAL_API_KEY") != "" && os.Getenv("POSTHOG_PROJECT_ID") != "" +} + +// posthogDocStats queries PostHog (HogQL) for the daily read trend and per-user +// breakdown of one document. It returns ok=false when PostHog is not configured +// or any error occurs, so the caller can fall back to the built-in store. The +// event/property names match what the frontend captures: a "docs_page_view" +// event carrying a "doc_id" property. +func posthogDocStats(docID string, days int) (store.PageReadStats, bool) { + if !posthogConfigured() { + return store.PageReadStats{}, false + } + host := strings.TrimRight(os.Getenv("POSTHOG_API_HOST"), "/") + if host == "" { + host = "https://app.posthog.com" + } + projectID := os.Getenv("POSTHOG_PROJECT_ID") + apiKey := os.Getenv("POSTHOG_PERSONAL_API_KEY") + + query := func(hogql string) ([][]any, bool) { + body, _ := json.Marshal(map[string]any{ + "query": map[string]any{"kind": "HogQLQuery", "query": hogql}, + }) + url := fmt.Sprintf("%s/api/projects/%s/query/", host, projectID) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, false + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, false + } + var out struct { + Results [][]any `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, false + } + return out.Results, true + } + + esc := strings.ReplaceAll(docID, "'", "\\'") + dailyHogQL := fmt.Sprintf( + "SELECT toDate(timestamp) AS d, count() AS c FROM events "+ + "WHERE event = 'docs_page_view' AND properties.doc_id = '%s' "+ + "AND timestamp >= now() - INTERVAL %d DAY GROUP BY d ORDER BY d", + esc, days) + readersHogQL := fmt.Sprintf( + "SELECT coalesce(person.properties.name, person.properties.email, distinct_id) AS reader, "+ + "count() AS c, max(timestamp) AS last FROM events "+ + "WHERE event = 'docs_page_view' AND properties.doc_id = '%s' "+ + "GROUP BY reader ORDER BY c DESC LIMIT 200", + esc) + + dailyRows, ok1 := query(dailyHogQL) + readerRows, ok2 := query(readersHogQL) + if !ok1 || !ok2 { + return store.PageReadStats{}, false + } + + // Build a zero-filled day window so the chart has no gaps. + today := time.Now().UTC().Truncate(24 * time.Hour) + idx := map[string]int{} + daily := make([]store.DailyReadPoint, days) + for i := 0; i < days; i++ { + key := today.AddDate(0, 0, -(days-1-i)).Format("2006-01-02") + daily[i] = store.DailyReadPoint{Date: key, Count: 0} + idx[key] = i + } + total := 0 + for _, row := range dailyRows { + if len(row) < 2 { + continue + } + date := fmt.Sprintf("%v", row[0]) + if len(date) > 10 { + date = date[:10] + } + c := toInt(row[1]) + if i, ok := idx[date]; ok { + daily[i].Count = c + } + total += c + } + + readers := make([]store.ReaderStat, 0, len(readerRows)) + for _, row := range readerRows { + if len(row) < 3 { + continue + } + name := fmt.Sprintf("%v", row[0]) + if name == "" || name == "" { + name = "匿名" + } + last, _ := time.Parse(time.RFC3339, fmt.Sprintf("%v", row[2])) + readers = append(readers, store.ReaderStat{Reader: name, Count: toInt(row[1]), LastReadAt: last}) + } + return store.PageReadStats{DocID: docID, Total: total, Daily: daily, Readers: readers}, true +} + +func toInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case json.Number: + i, _ := n.Int64() + return int(i) + } + return 0 +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 1eb3a5d..b588658 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -3,12 +3,15 @@ package api import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "mime" + "net" "net/http" "net/url" "os" @@ -53,7 +56,16 @@ func New(st *store.Store) *Server { accessKey := os.Getenv("MINIO_ROOT_USER") secretKey := os.Getenv("MINIO_ROOT_PASSWORD") secure := strings.HasPrefix(strings.ToLower(endpoint), "https://") - client, err := minio.New(endpoint, &minio.Options{ + // minio-go expects a bare host:port; strip any scheme/trailing slash so + // MINIO_ENDPOINT=http://minio:9000 doesn't fail init ("Endpoint url + // cannot have fully qualified paths"), which would silently drop us to + // the in-memory fallback and never create the bucket. + host := endpoint + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + host = strings.TrimRight(host, "/") + client, err := minio.New(host, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: secure, }) @@ -79,6 +91,7 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealth) mux.HandleFunc("/api/auth/me", s.handleMe) + mux.HandleFunc("/api/me/mcp-token", s.handleMeMCPToken) mux.HandleFunc("/api/auth/mock-login", s.handleMockLogin) mux.HandleFunc("/api/auth/login", s.handleLogin) mux.HandleFunc("/api/auth/callback", s.handleCallback) @@ -98,6 +111,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/deploy", s.handleDeploy) mux.HandleFunc("/api/analytics/page-view", s.handlePageView) mux.HandleFunc("/api/analytics/read-progress", s.handleReadProgress) + mux.HandleFunc("/api/analytics/doc", s.handleDocAnalytics) mux.HandleFunc("/api/admin/releases", s.handleReleases) mux.HandleFunc("/api/admin/releases/", s.handleReleaseRoutes) mux.HandleFunc("/api/admin/analytics/pages", s.handlePageAnalytics) @@ -344,9 +358,14 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "search_failed", err.Error()) return } - filters, _ := json.Marshal(req.Filters) - user, _ := s.currentUser(r) - s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("sl-%d", time.Now().UnixNano()), UserID: user.ID, Query: req.Query, Mode: string(resp.Mode), FiltersJSON: string(filters), ResultCount: resp.Total, SearchedAt: time.Now().UTC()}) + // Only persist explicit, user-committed searches (Enter / search button / + // result click). Live as-you-type queries set Log=false to avoid flooding + // the log with one row per keystroke. + if req.Log && strings.TrimSpace(req.Query) != "" { + filters, _ := json.Marshal(req.Filters) + user, _ := s.currentUser(r) + s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("sl-%d", time.Now().UnixNano()), UserID: user.ID, IPAddress: clientIP(r), Query: req.Query, Mode: string(resp.Mode), FiltersJSON: string(filters), ResultCount: resp.Total, SearchedAt: time.Now().UTC()}) + } writeJSON(w, http.StatusOK, resp) } @@ -388,7 +407,7 @@ func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { } answer, provider := s.synthesizeAnswer(r.Context(), req.Query, resp.Results) user, _ := s.currentUser(r) - s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("ask-%d", time.Now().UnixNano()), UserID: user.ID, Query: req.Query, Mode: "ask", ResultCount: len(resp.Results), SearchedAt: time.Now().UTC()}) + s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("ask-%d", time.Now().UnixNano()), UserID: user.ID, IPAddress: clientIP(r), Query: req.Query, Mode: "ask", ResultCount: len(resp.Results), SearchedAt: time.Now().UTC()}) writeJSON(w, http.StatusOK, map[string]any{ "query": req.Query, "answer": answer, @@ -477,48 +496,8 @@ func (s *Server) askOpenAICompatible(ctx context.Context, ai store.AISettings, q system = "你是企业研发文档助手。只依据提供的【文档片段】回答用户问题,使用简洁中文;若片段中没有答案,明确说明未在文档中找到,不要编造。回答末尾不要重复罗列来源。" } userMsg := fmt.Sprintf("文档片段:\n%s\n问题:%s", ctxBuilder.String(), query) - payload, _ := json.Marshal(map[string]any{ - "model": ai.AskModel, - "messages": []map[string]string{ - {"role": "system", "content": system}, - {"role": "user", "content": userMsg}, - }, - "temperature": 0.2, - "stream": false, - }) - endpoint := strings.TrimRight(ai.AskBaseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload))) - if err != nil { - return "", err - } - httpReq.Header.Set("Content-Type", "application/json") - if ai.AskAPIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+ai.AskAPIKey) - } - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - return "", fmt.Errorf("chat endpoint %d: %s", resp.StatusCode, string(body)) - } - var out struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - if err := json.Unmarshal(body, &out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", fmt.Errorf("chat endpoint returned no choices") - } - return out.Choices[0].Message.Content, nil + // Dispatch to the configured API format (OpenAI / Anthropic / Gemini / …). + return chatComplete(ctx, ai, system, userMsg) } func firstNonEmptyStr(vals ...string) string { @@ -562,16 +541,18 @@ func (s *Server) handleAdminModels(w http.ResponseWriter, r *http.Request) { return } var body struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` + Protocol string `json:"protocol"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` } if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } + cur := s.store.Settings().AI base := strings.TrimSpace(body.BaseURL) if base == "" { - base = s.store.Settings().AI.AskBaseURL + base = cur.AskBaseURL } if base == "" { writeError(w, http.StatusBadRequest, "bad_request", "base_url required") @@ -579,39 +560,17 @@ func (s *Server) handleAdminModels(w http.ResponseWriter, r *http.Request) { } key := strings.TrimSpace(body.APIKey) if key == "" { - key = s.store.Settings().AI.AskAPIKey + key = cur.AskAPIKey } - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, strings.TrimRight(base, "/")+"/models", nil) - if err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + protocol := strings.TrimSpace(body.Protocol) + if protocol == "" { + protocol = cur.AskProtocol } - if key != "" { - req.Header.Set("Authorization", "Bearer "+key) - } - resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + ids, err := listModels(r.Context(), protocol, base, key) if err != nil { writeError(w, http.StatusBadGateway, "models_failed", err.Error()) return } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - writeError(w, http.StatusBadGateway, "models_failed", fmt.Sprintf("%d: %s", resp.StatusCode, string(raw))) - return - } - var parsed struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` - } - _ = json.Unmarshal(raw, &parsed) - ids := make([]string, 0, len(parsed.Data)) - for _, m := range parsed.Data { - if m.ID != "" { - ids = append(ids, m.ID) - } - } writeJSON(w, http.StatusOK, map[string]any{"models": ids}) } @@ -795,6 +754,28 @@ func (s *Server) handlePageView(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, map[string]any{"status": "recorded", "view_id": pv.ID}) } +// handleDocAnalytics powers the doc-page "eye" popover: a daily read trend and +// a per-reader breakdown for one document. PostHog is used when configured +// (server-side), otherwise the built-in page_views store is the source. +func (s *Server) handleDocAnalytics(w http.ResponseWriter, r *http.Request) { + docID := strings.TrimSpace(r.URL.Query().Get("doc_id")) + if docID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + return + } + days := 30 + if v := r.URL.Query().Get("days"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 180 { + days = n + } + } + if stats, ok := posthogDocStats(docID, days); ok { + writeJSON(w, http.StatusOK, map[string]any{"source": "posthog", "stats": stats}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"source": "internal", "stats": s.store.PageReadStats(docID, days)}) +} + func (s *Server) handleReadProgress(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") @@ -819,11 +800,47 @@ func (s *Server) handleReadProgress(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSearchLogs(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.SearchLogs()) + logs := s.store.SearchLogs() + type out struct { + store.SearchLog + DisplayName string `json:"display_name"` + } + res := make([]out, len(logs)) + for i, log := range logs { + dn := "" + if log.UserID != "" { + if u, err := s.store.UserByID(log.UserID); err == nil { + dn = u.DisplayName + if dn == "" { + dn = u.Username + } + } + } + res[i] = out{SearchLog: log, DisplayName: dn} + } + writeJSON(w, http.StatusOK, res) } func (s *Server) handleMCPLogs(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.MCPLogs()) + logs := s.store.MCPLogs() + type out struct { + store.MCPLog + DisplayName string `json:"display_name"` + } + res := make([]out, len(logs)) + for i, log := range logs { + dn := "" + if log.UserID != "" { + if u, err := s.store.UserByID(log.UserID); err == nil { + dn = u.DisplayName + if dn == "" { + dn = u.Username + } + } + } + res[i] = out{MCPLog: log, DisplayName: dn} + } + writeJSON(w, http.StatusOK, res) } func (s *Server) handleMCPLog(w http.ResponseWriter, r *http.Request) { @@ -838,10 +855,43 @@ func (s *Server) handleMCPLog(w http.ResponseWriter, r *http.Request) { return } user, _ := s.currentUser(r) + if user.ID == "" { + if tok := bearerToken(r); tok != "" { + if u, err := s.store.UserByMCPToken(tok); err == nil { + user = u + } + } + } s.store.AddMCPLog(store.MCPLog{ID: fmt.Sprintf("ml-%d", time.Now().UnixNano()), ToolName: req.ToolName, UserID: user.ID, Query: req.Query, InputJSON: req.InputJSON, ResultCount: req.ResultCount, CreatedAt: time.Now().UTC()}) writeJSON(w, http.StatusAccepted, map[string]any{"status": "logged"}) } +func (s *Server) handleMeMCPToken(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "not logged in") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]string{"mcp_token": user.MCPToken}) + case http.MethodPost: + tok, err := randomToken(32) + if err != nil { + writeError(w, http.StatusInternalServerError, "token_gen_failed", err.Error()) + return + } + updated, err := s.store.SetUserMCPToken(user.ID, tok) + if err != nil { + writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"mcp_token": updated.MCPToken}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + // currentUser returns the authenticated user from the session cookie. Login is // a real, cookie-backed action in both mock and OIDC modes, so there is no // silent impersonation; anonymous callers simply get ok == false. @@ -1613,7 +1663,7 @@ func toStoreArtifact(a deploy.Artifact) store.DeployArtifact { out.Documents = append(out.Documents, store.DeployDocument{ DocID: d.DocID, ModuleKey: d.ModuleKey, ModuleName: d.ModuleName, DocsVersion: d.DocsVersion, PackageVersion: d.PackageVersion, EntryKey: d.EntryKey, EntryType: d.EntryType, Title: d.Title, - Description: d.Description, Content: d.Content, Path: d.Path, SourceFile: d.SourceFile, + Description: d.Description, Content: d.Content, ContentMD: d.ContentMD, Path: d.Path, SourceFile: d.SourceFile, Keywords: append([]string(nil), d.Keywords...), Status: d.Status, }) } @@ -1679,3 +1729,37 @@ func envInt64(key string, fallback int64) int64 { } return v } + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.Index(xff, ","); i != -1 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if xri := r.Header.Get("X-Real-Ip"); xri != "" { + return strings.TrimSpace(xri) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func bearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if strings.HasPrefix(auth, prefix) { + return strings.TrimSpace(auth[len(prefix):]) + } + return "" +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/backend/internal/deploy/artifact.go b/backend/internal/deploy/artifact.go index a30006a..4060623 100644 --- a/backend/internal/deploy/artifact.go +++ b/backend/internal/deploy/artifact.go @@ -61,6 +61,7 @@ type DocumentRecord struct { Title string `json:"title"` Description string `json:"description"` Content string `json:"content"` + ContentMD string `json:"content_md,omitempty"` Path string `json:"path"` SourceFile string `json:"source_file"` Keywords []string `json:"keywords"` diff --git a/backend/internal/search/search.go b/backend/internal/search/search.go index a2877a9..f08459c 100644 --- a/backend/internal/search/search.go +++ b/backend/internal/search/search.go @@ -34,6 +34,10 @@ type Request struct { Filters Filters `json:"filters"` Page int `json:"page"` PageSize int `json:"page_size"` + // Log marks an explicit, user-committed search (Enter / search button / + // result click) that should be persisted to the search log. Live + // as-you-type queries leave this false so the log isn't flooded. + Log bool `json:"log"` } type Result struct { diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go index e7f3f8e..6a7afb7 100644 --- a/backend/internal/store/memory.go +++ b/backend/internal/store/memory.go @@ -146,9 +146,9 @@ func NewSeeded() *Store { {ID: "e-fumadocs-guide", ModuleKey: "FumadocsKit", DocsVersion: "latest", EntryKey: "guide", Title: "Fumadocs 文档站接入", EntryType: "fumadocs", Builder: "fumadocs", Source: "content/docs", StorageURI: "minio://modex/FumadocsKit/latest/site/guide/index.html", NavURI: "minio://modex/FumadocsKit/latest/nav.json", IndexStatus: "indexed", IsPrimary: true, SortOrder: 1, Status: "active", CreatedAt: now}, } s.pages = []Page{ - {ID: "p-demo-guide", DocID: "DemoModule:latest:guide", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "guide", EntryType: "markdown", Title: "模块落地指导", Description: "面向业务开发人员的模块接入、部署、接口和异常处理说明。", Path: "/docs/DemoModule/latest/guide", SourceFile: "docs/integration-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad"}, ContentText: "模块落地指导说明如何接入 DemoModule,包括接口设计、部署运行、异常处理、风险影响面和发布检查。", UpdatedAt: now}, - {ID: "p-demo-maintenance", DocID: "DemoModule:latest:maintenance", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "maintenance", EntryType: "markdown", Title: "模块维护说明", Description: "面向维护开发人员的架构、设计、流程和维护说明。", Path: "/docs/DemoModule/latest/maintenance", SourceFile: "docs/maintenance-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad", "architecture"}, ContentText: "模块维护说明包含总体架构、设计原则、模块结构、核心流程、时序逻辑、前后端设计和质量可维护性要求。", UpdatedAt: now}, - {ID: "p-cbb-build", DocID: "CBB:latest:build-cache", ModuleKey: "CBB", ModuleName: "CBB 文档", DocsVersion: "latest", PackageVersion: "2.8.0", EntryKey: "build-cache", EntryType: "markdown", Title: "构建缓存清理", Description: "CBB 构建缓存清理和常见构建问题排查。", Path: "/docs/CBB/latest/build-cache", SourceFile: "docs/build-cache.md", DocType: "markdown", Status: "active", OwnerGroup: "engineering", CategoryIDs: []string{"engineering", "engineering.cbb"}, Tags: []string{"cbb", "ci", "build"}, ContentText: "构建缓存清理用于解决依赖缓存、编译缓存和 CI 工作区残留导致的构建异常。可以重新拉取依赖并清理本地缓存。", UpdatedAt: now}, + {ID: "p-demo-guide", DocID: "DemoModule:latest:guide", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "guide", EntryType: "markdown", Title: "模块落地指导", Description: "面向业务开发人员的模块接入、部署、接口和异常处理说明。", Path: "/docs/DemoModule/latest/guide", SourceFile: "docs/integration-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad"}, ContentText: "模块落地指导说明如何接入 DemoModule,包括接口设计、部署运行、异常处理、风险影响面和发布检查。", ContentMD: seedDemoGuideMD, UpdatedAt: now}, + {ID: "p-demo-maintenance", DocID: "DemoModule:latest:maintenance", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "maintenance", EntryType: "markdown", Title: "模块维护说明", Description: "面向维护开发人员的架构、设计、流程和维护说明。", Path: "/docs/DemoModule/latest/maintenance", SourceFile: "docs/maintenance-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad", "architecture"}, ContentText: "模块维护说明包含总体架构、设计原则、模块结构、核心流程、时序逻辑、前后端设计和质量可维护性要求。", ContentMD: seedDemoMaintenanceMD, UpdatedAt: now}, + {ID: "p-cbb-build", DocID: "CBB:latest:build-cache", ModuleKey: "CBB", ModuleName: "CBB 文档", DocsVersion: "latest", PackageVersion: "2.8.0", EntryKey: "build-cache", EntryType: "markdown", Title: "构建缓存清理", Description: "CBB 构建缓存清理和常见构建问题排查。", Path: "/docs/CBB/latest/build-cache", SourceFile: "docs/build-cache.md", DocType: "markdown", Status: "active", OwnerGroup: "engineering", CategoryIDs: []string{"engineering", "engineering.cbb"}, Tags: []string{"cbb", "ci", "build"}, ContentText: "构建缓存清理用于解决依赖缓存、编译缓存和 CI 工作区残留导致的构建异常。可以重新拉取依赖并清理本地缓存。", ContentMD: seedCBBBuildCacheMD, UpdatedAt: now}, {ID: "p-vuepress-guide", DocID: "VuePressGuide:latest:guide", ModuleKey: "VuePressGuide", ModuleName: "VuePressGuide", DocsVersion: "latest", PackageVersion: "0.4.0", EntryKey: "guide", EntryType: "vuepress", Title: "VuePress 文档站接入", Description: "VuePress 文档通过 docsctl 执行构建命令并复制 dist 输出目录。", Path: "/docs/VuePressGuide/latest/guide", SourceFile: "docs/README.md", DocType: "vuepress", Status: "active", OwnerGroup: "frontend-platform", CategoryIDs: []string{"frontend", "frontend.docs"}, Tags: []string{"vuepress", "frontend", "markdown"}, ContentText: "VuePress 文档站接入说明如何声明 docs.yaml、执行 npm run docs:build、复制 docs/.vuepress/dist 并生成标准文档包。", UpdatedAt: now.Add(-2 * time.Hour)}, {ID: "p-fumadocs-guide", DocID: "FumadocsKit:latest:guide", ModuleKey: "FumadocsKit", ModuleName: "FumadocsKit", DocsVersion: "latest", PackageVersion: "0.2.0", EntryKey: "guide", EntryType: "fumadocs", Title: "Fumadocs 文档站接入", Description: "Fumadocs 文档通过 Next.js 与 MDX 构建,适合现代前端文档站。", Path: "/docs/FumadocsKit/latest/guide", SourceFile: "content/docs/index.mdx", DocType: "fumadocs", Status: "active", OwnerGroup: "frontend-platform", CategoryIDs: []string{"frontend", "frontend.docs"}, Tags: []string{"fumadocs", "nextjs", "mdx", "frontend"}, ContentText: "Fumadocs 文档站接入说明如何维护 MDX 内容、运行 Next.js 构建、输出静态站点并交给 Modex 进行搜索和 MCP 读取。", UpdatedAt: now.Add(-90 * time.Minute)}, } @@ -182,6 +182,12 @@ func (s *Store) Users(keyword string) []User { func (s *Store) UserByID(id string) (User, error) { s.mu.RLock() defer s.mu.RUnlock() + return s.userByIDLocked(id) +} + +// userByIDLocked looks up a user without acquiring the lock; callers must hold +// s.mu (read or write). +func (s *Store) userByIDLocked(id string) (User, error) { for _, u := range s.users { if u.ID == id { return u, nil @@ -190,6 +196,30 @@ func (s *Store) UserByID(id string) (User, error) { return User{}, ErrNotFound } +func (s *Store) UserByMCPToken(token string) (User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.users { + if u.MCPToken == token { + return u, nil + } + } + return User{}, ErrNotFound +} + +func (s *Store) SetUserMCPToken(id string, token string) (User, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.users { + if s.users[i].ID == id { + s.users[i].MCPToken = token + s.users[i].UpdatedAt = time.Now().UTC() + return s.users[i], nil + } + } + return User{}, ErrNotFound +} + func (s *Store) CreateUser(u User) (User, error) { s.mu.Lock() defer s.mu.Unlock() @@ -587,6 +617,24 @@ func (s *Store) CategoryName(id string) string { return id } +func (s *Store) categoryPathLocked(ids []string) string { + if len(ids) == 0 { + return "" + } + var parts []string + for _, id := range ids { + name := id + for _, c := range s.categories { + if c.ID == id { + name = c.Name + break + } + } + parts = append(parts, name) + } + return strings.Join(parts, " / ") +} + // EntryModuleKey returns the module key that owns an entry, for permission checks. func (s *Store) EntryModuleKey(entryID string) (string, bool) { s.mu.RLock() @@ -849,7 +897,10 @@ func (s *Store) IngestArtifact(a DeployArtifact) (DeployResult, error) { m.LastSyncedAt = now m.UpdatedAt = now } - module := s.modules[moduleIdx] + // Preserve existing category assignment and rebuild the display path from + // current categories so the admin UI and module cards show the right labels. + module := &s.modules[moduleIdx] + module.CategoryPath = s.categoryPathLocked(module.CategoryIDs) versionFound := false for i := range s.versions { if strings.EqualFold(s.versions[i].ModuleKey, a.ModuleKey) && s.versions[i].DocsVersion == a.DocsVersion { @@ -939,6 +990,7 @@ func (s *Store) IngestArtifact(a DeployArtifact) (DeployResult, error) { CategoryIDs: cloneStrings(module.CategoryIDs), Tags: cloneStrings(coalesceStrings(d.Keywords, a.Keywords)), ContentText: d.Content, + ContentMD: d.ContentMD, UpdatedAt: now, }) } @@ -1148,6 +1200,86 @@ func (s *Store) seedReadsLocked(moduleKey string, week bool) int { return 0 } +// PageReadStats aggregates recorded views for one document into a daily read +// trend (last `days` days, inclusive of today) plus a per-reader breakdown. +// Readers are keyed by user id, falling back to session id for anonymous views. +func (s *Store) PageReadStats(docID string, days int) PageReadStats { + if days <= 0 { + days = 30 + } + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now().UTC() + today := now.Truncate(24 * time.Hour) + // Pre-seed every day in the window so the line chart has no gaps. + idxByDate := map[string]int{} + daily := make([]DailyReadPoint, days) + for i := 0; i < days; i++ { + d := today.AddDate(0, 0, -(days - 1 - i)) + key := d.Format("2006-01-02") + daily[i] = DailyReadPoint{Date: key, Count: 0} + idxByDate[key] = i + } + windowStart := today.AddDate(0, 0, -(days - 1)) + + type ragg struct { + userID string + count int + last time.Time + } + readers := map[string]*ragg{} + total := 0 + for _, pv := range s.pageViews { + if pv.DocID != docID { + continue + } + total++ + if !pv.ViewedAt.Before(windowStart) { + if i, ok := idxByDate[pv.ViewedAt.UTC().Format("2006-01-02")]; ok { + daily[i].Count++ + } + } + key := pv.UserID + if key == "" { + key = "session:" + pv.SessionID + } + r := readers[key] + if r == nil { + r = &ragg{userID: pv.UserID} + readers[key] = r + } + r.count++ + if pv.ViewedAt.After(r.last) { + r.last = pv.ViewedAt + } + } + + out := PageReadStats{DocID: docID, Total: total, Daily: daily, Readers: []ReaderStat{}} + for _, r := range readers { + name := "匿名" + if r.userID != "" { + if u, err := s.userByIDLocked(r.userID); err == nil { + if u.DisplayName != "" { + name = u.DisplayName + } else if u.Username != "" { + name = u.Username + } + } else { + name = r.userID + } + } + out.Readers = append(out.Readers, ReaderStat{Reader: name, UserID: r.userID, Count: r.count, LastReadAt: r.last}) + } + sort.Slice(out.Readers, func(i, j int) bool { + if out.Readers[i].Count != out.Readers[j].Count { + return out.Readers[i].Count > out.Readers[j].Count + } + return out.Readers[i].LastReadAt.After(out.Readers[j].LastReadAt) + }) + return out +} + // CreateCategory adds a new category. Key is required and must be unique. func (s *Store) CreateCategory(c Category) (Category, error) { s.mu.Lock() diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go index 65bfa5f..89fb365 100644 --- a/backend/internal/store/models.go +++ b/backend/internal/store/models.go @@ -21,6 +21,9 @@ type User struct { // super admin status can be granted either statically (env, for bootstrap) // or dynamically via the admin UI. SuperAdmin bool `json:"is_super_admin,omitempty"` + // MCPToken is the user's personal bearer token for the MCP server, so MCP + // calls can be attributed to them. Never serialized (revealed via /api/me/mcp-token). + MCPToken string `json:"-"` LastLoginAt time.Time `json:"last_login_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` @@ -173,6 +176,7 @@ type Page struct { Tags []string `json:"tags"` ContentText string `json:"content_text"` ContentHTML string `json:"content_html,omitempty"` + ContentMD string `json:"content_md,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -180,8 +184,11 @@ type Page struct { // AI answers (RAG). It targets any OpenAI-compatible /chat/completions endpoint // (OpenAI, DeepSeek, Qwen/DashScope-compat, local vLLM/Ollama, …). type AISettings struct { + // AskProtocol selects the API format of the chat endpoint: + // "openai-chat" (default), "openai-responses", "anthropic", or "gemini". + AskProtocol string `json:"ask_protocol"` AskBaseURL string `json:"ask_base_url"` // e.g. https://api.openai.com/v1 - AskModel string `json:"ask_model"` // e.g. gpt-4o-mini, deepseek-chat + AskModel string `json:"ask_model"` // fetched from the endpoint AskAPIKey string `json:"ask_api_key"` // secret; masked when read back AskSystemPrompt string `json:"ask_system_prompt"` // optional override UpdatedAt time.Time `json:"updated_at"` @@ -217,6 +224,7 @@ type DeployDocument struct { Title string `json:"title"` Description string `json:"description"` Content string `json:"content"` + ContentMD string `json:"content_md,omitempty"` Path string `json:"path"` SourceFile string `json:"source_file"` Keywords []string `json:"keywords"` @@ -262,6 +270,7 @@ type SiteFile struct { type SearchLog struct { ID string `json:"id"` UserID string `json:"user_id"` + IPAddress string `json:"ip_address,omitempty"` // recorded for anonymous searches Query string `json:"query"` Mode string `json:"mode"` FiltersJSON string `json:"filters_json"` @@ -308,3 +317,26 @@ type PageStat struct { AvgDurationSec int `json:"avg_duration_seconds"` LastViewedAt time.Time `json:"last_viewed_at"` } + +// DailyReadPoint is one day's read count for a single page (line-chart point). +type DailyReadPoint struct { + Date string `json:"date"` // YYYY-MM-DD (UTC) + Count int `json:"count"` +} + +// ReaderStat is one reader's aggregated read activity for a single page. +type ReaderStat struct { + Reader string `json:"reader"` // display name, username, or "匿名" + UserID string `json:"user_id"` + Count int `json:"count"` + LastReadAt time.Time `json:"last_read_at"` +} + +// PageReadStats is the per-page reading detail surfaced behind the doc-page +// "eye" popover: a daily read trend plus a per-reader breakdown. +type PageReadStats struct { + DocID string `json:"doc_id"` + Total int `json:"total"` + Daily []DailyReadPoint `json:"daily"` + Readers []ReaderStat `json:"readers"` +} diff --git a/backend/internal/store/seed.go b/backend/internal/store/seed.go new file mode 100644 index 0000000..1028a79 --- /dev/null +++ b/backend/internal/store/seed.go @@ -0,0 +1,15 @@ +package store + +import _ "embed" + +// Seed markdown showcases the Mintlify-style component set rendered by the +// frontend MDX engine. They are embedded so the demo data is rich out of the +// box, even before any real documentation artifact is published. +var ( + //go:embed seeddata/demo-guide.md + seedDemoGuideMD string + //go:embed seeddata/demo-maintenance.md + seedDemoMaintenanceMD string + //go:embed seeddata/cbb-build-cache.md + seedCBBBuildCacheMD string +) diff --git a/backend/internal/store/seeddata/cbb-build-cache.md b/backend/internal/store/seeddata/cbb-build-cache.md new file mode 100644 index 0000000..4ace66f --- /dev/null +++ b/backend/internal/store/seeddata/cbb-build-cache.md @@ -0,0 +1,37 @@ +--- +title: 构建缓存清理 +description: CBB 构建缓存清理和常见构建问题排查。 +--- + +当依赖缓存、编译缓存或 CI 工作区残留导致构建异常时,可按本页步骤清理。 + +清理缓存会导致下一次构建变慢,请在确认存在缓存污染时再执行。 + +## 清理步骤 + + + + ```bash + cbb cache clean --all + ``` + + + ```bash + cbb deps sync --force + ``` + + + ```bash + cbb build --no-cache + ``` + + + +## 不同环境 + + + 删除 `.cbb/cache` 目录后重新构建。 + 在流水线中清理 runner 工作区缓存卷。 + + +构建成功后产物哈希应与上一个稳定版本一致(除非源码变更)。 diff --git a/backend/internal/store/seeddata/demo-guide.md b/backend/internal/store/seeddata/demo-guide.md new file mode 100644 index 0000000..c1bf6e4 --- /dev/null +++ b/backend/internal/store/seeddata/demo-guide.md @@ -0,0 +1,185 @@ +--- +title: 模块落地指导 +description: 面向业务开发人员的 DemoModule 接入、部署、接口和异常处理说明。 +--- + +DemoModule 提供统一的业务接入能力。本页演示 Modex 内置的 Mintlify 风格组件渲染引擎,涵盖提示框、卡片、标签页、步骤、代码组等全部组件。 + + + 本文档由 Modex 的 MDX 渲染引擎生成,组件与 Mintlify 保持一致。你可以在任意 `.md` / `.mdx` 文档中直接书写这些组件。 + + +## 提示框 Callouts + +这是一条信息提示,用于补充背景说明。 +这是一条技巧提示,给出最佳实践建议。 +这是一条警告提示,提醒潜在风险。 +这是一条成功提示,表示校验通过。 +这是一条普通注解提示。 + +## 卡片 Cards + + + + 三步完成 DemoModule 接入。 + + + 查看请求与响应字段定义。 + + + 了解模块的总体架构与时序。 + + + 在 GitLab 查看实现细节。 + + + +## 多列布局 Columns + + + P99 < 50ms 的接口响应。 + 多副本部署,自动故障转移。 + 内置指标、日志与链路追踪。 + + +## 步骤 Steps + + + + 使用包管理器安装 DemoModule SDK。 + + ```bash + npm install @demo/module + ``` + + + 填入服务地址与密钥即可创建客户端。 + + 初始化成功后会打印 `client ready`。 + + + 调用 `submit()` 完成业务接入。 + + + +## 标签页 Tabs + + + + ```js + import { DemoClient } from "@demo/module"; + const client = new DemoClient({ token: process.env.TOKEN }); + await client.submit({ id: 1 }); + ``` + + + ```python + from demo import DemoClient + client = DemoClient(token=os.environ["TOKEN"]) + client.submit(id=1) + ``` + + + ```bash + curl -X POST https://api.example.com/submit \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"id": 1}' + ``` + + + +## 代码组 CodeGroup + + + ```ts config.ts + export const config = { + endpoint: "https://api.example.com", + timeout: 5000, + }; + ``` + + ```yaml config.yaml + endpoint: https://api.example.com + timeout: 5000 + ``` + + +## 折叠面板 Accordion + + + + 在控制台「凭据管理」页面创建,令牌具备最小权限。 + + + 支持 Node.js 18+、Python 3.9+,以及任意可发起 HTTPS 请求的环境。 + + + +## 字段 Fields + + + 业务实体的唯一标识。 + + + 是否以异步方式提交。 + + + + 处理结果,取值 `ok` 或 `failed`。 + + +## 可展开 Expandable + + + + 失败重试次数。 + + + 重试退避策略。 + + + +## 图片框 Frame + + + ![控制台](https://placehold.co/720x360/eef2ff/4f46e5?text=DemoModule+Console) + + +## 行内组件 + +支持 SLA 悬浮提示、状态徽标 Beta 稳定,以及颜色样本 #4f46e5。 + +## 更新日志 Update + + + 新增异步提交能力,优化错误码体系。 + + +## 文件树 Tree + + + - src + - index.ts + - client.ts + - package.json + + +## 流程图 Mermaid + + +graph LR + A[业务系统] --> B[DemoModule SDK] + B --> C[网关] + C --> D[(数据存储)] + + +## 普通 Markdown + +支持标准 Markdown:**加粗**、*斜体*、`行内代码`、[链接](https://example.com)、列表与表格。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | string | 实体标识 | +| async | boolean | 异步提交 | + +> 引用块同样按 Mintlify 风格渲染。 diff --git a/backend/internal/store/seeddata/demo-maintenance.md b/backend/internal/store/seeddata/demo-maintenance.md new file mode 100644 index 0000000..3ee7830 --- /dev/null +++ b/backend/internal/store/seeddata/demo-maintenance.md @@ -0,0 +1,45 @@ +--- +title: 模块维护说明 +description: 面向维护开发人员的架构、设计、流程和维护说明。 +--- + +本页面向维护人员,说明 DemoModule 的总体架构、核心流程与质量要求。 + +维护前请先阅读[模块落地指导](/docs/DemoModule/latest/guide),了解对外接口契约。 + +## 总体架构 + +```mermaid +graph TD + GW[网关层] --> SVC[业务服务] + SVC --> CACHE[(缓存)] + SVC --> DB[(数据库)] +``` + +## 设计原则 + + + 每个子模块只负责一个清晰的能力边界。 + 关键路径埋点指标、日志、链路追踪三件套。 + + +## 核心流程 + + + 网关完成鉴权与限流后转发到业务服务。 + 业务服务执行校验、写入数据库并更新缓存。 + 统一错误码与响应结构返回调用方。 + + +## 常见维护操作 + + + + 调整副本数即可水平扩容,服务无状态。 + + + 查看链路追踪定位耗时阶段,再结合数据库慢查询日志分析。 + + + +变更数据库 Schema 前必须经过评审并准备回滚脚本。 diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx deleted file mode 100644 index 2222b54..0000000 --- a/frontend/app/admin/analytics/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { getPageAnalytics } from "@/lib/api"; -import { AdminShell } from "@/components/admin-shell"; - -export default async function AnalyticsPage() { - const data = await getPageAnalytics(); - return ( - -
-
-
-
总 PV
-
{data.total_pv}
-
-
-
近 7 天阅读
-
{data.reads_7d}
-
-
-
页面数
-
{data.popular_pages.length}
-
-
-
事件类型
-
{data.events.length}
-
-
- -
- - - - - - - - - - - - - - - {data.popular_pages.map((p) => ( - - - - - - - - - - - ))} - -
文档模块版本PVUV近7天近30天平均时长(s)
- {p.title} -
{p.doc_id}
-
{p.module_name}{p.docs_version}{p.pv}{p.uv}{p.reads_7d}{p.reads_30d}{p.avg_duration_seconds}
-
- -

埋点事件:{data.events.join(" / ")}

-
-
- ); -} diff --git a/frontend/app/admin/categories/page.tsx b/frontend/app/admin/categories/page.tsx index 622a9d7..69112f2 100644 --- a/frontend/app/admin/categories/page.tsx +++ b/frontend/app/admin/categories/page.tsx @@ -1,10 +1,11 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"; +import { ChevronRight, FolderTree, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { Modal } from "@/components/ui/modal"; import { Combobox, type ComboOption } from "@/components/ui/combobox"; +import { EmptyState } from "@/components/ui/empty-state"; import { CategoryIcon, IconPicker } from "@/components/ui/icon-picker"; import { getCategories, getTeams, createCategory, updateCategory, deleteCategory, moveCategory } from "@/lib/api"; import type { Category, Team } from "@/types/modex"; @@ -81,7 +82,7 @@ function TreeNode({ {category.description ? {category.description} : null}
- +
@@ -233,27 +234,26 @@ export default function AdminCategoriesPage() { return ( {error ?
{error}
: null}
-
{flatten(categories).length} 个领域节点 · 拖动卡片排序或改层级
+
{flatten(categories).length} 个分类节点 · 拖动卡片排序或改层级
- +
{categories.length === 0 && !error ? ( -
-
-
暂无领域
-

点击「新增顶级领域」开始创建层级结构。支持任意嵌套,可绑定负责团队。

-
-
+ ) : (
{categories.map((cat) => ( @@ -266,8 +266,8 @@ export default function AdminCategoriesPage() { setModalOpen(false)} - title={data.id ? "编辑领域" : data.parent_id ? "新增子领域" : "新增顶级领域"} - subtitle="顶层领域需超管权限,子领域可由父领域管理员或负责团队创建" + title={data.id ? "编辑分类" : data.parent_id ? "新增子分类" : "新增顶级分类"} + subtitle="顶层分类需超管权限,子分类可由父分类管理员或负责团队创建" footer={ <> @@ -282,11 +282,11 @@ export default function AdminCategoriesPage() {
- setData({ ...data, description: e.target.value })} /> + setData({ ...data, description: e.target.value })} />
- - setData({ ...data, parent_id: v[0] || "" })} multiple={false} placeholder="选择父领域…" /> + + setData({ ...data, parent_id: v[0] || "" })} multiple={false} placeholder="选择父分类…" />
diff --git a/frontend/app/admin/mcp-logs/page.tsx b/frontend/app/admin/mcp-logs/page.tsx index f83ac99..d558999 100644 --- a/frontend/app/admin/mcp-logs/page.tsx +++ b/frontend/app/admin/mcp-logs/page.tsx @@ -1,12 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { MessageSquareText, Search } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Pagination } from "@/components/ui/pagination"; import { api } from "@/lib/api"; +const PAGE_SIZE = 12; + type MCPLog = { id: string; tool_name: string; + display_name: string; user_id: string; query: string; input_json: string; @@ -18,6 +24,8 @@ export default function MCPLogsPage() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); useEffect(() => { let cancelled = false; @@ -34,49 +42,82 @@ export default function MCPLogsPage() { return () => { cancelled = true; }; }, []); + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return data; + return data.filter((l) => + (l.tool_name || "").toLowerCase().includes(q) || + (l.query || "").toLowerCase().includes(q) || + (l.display_name || "").toLowerCase().includes(q) || + (l.user_id || "").toLowerCase().includes(q), + ); + }, [data, keyword]); + const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + return ( - + {error ? (
加载失败:{error}(可能需要超级管理员权限)
) : null} +
+
+ + { setKeyword(e.target.value); setPage(1); }} + /> +
+
+
{loading ? (
加载中...
- ) : data.length === 0 ? ( -
-
-
暂无 MCP 调用
-

启动 docs-mcp-server 并调用工具后,这里会出现记录。

-
-
+ ) : filtered.length === 0 ? ( + ) : ( -
- - - - - - - - - - - - {data.map((log) => ( - - - - - - - + <> +
工具查询结果数用户输入时间
{log.tool_name}{log.query || "-"}{log.result_count}{log.user_id || "-"}{log.input_json || "{}"}{log.created_at?.slice(0, 19).replace("T", " ")}
+ + + + + + + + - ))} - -
工具查询结果数用户输入时间
+ + + {pageRows.map((log) => ( + + {log.tool_name} + {log.query || "-"} + {log.result_count} + + {log.display_name ? ( + {log.display_name} + ) : log.user_id ? ( + {log.user_id} + ) : ( + - + )} + + {log.input_json || "{}"} + {log.created_at?.slice(0, 19).replace("T", " ")} + + ))} + +
+ + )}
diff --git a/frontend/app/admin/modules/page.tsx b/frontend/app/admin/modules/page.tsx index 42e7e60..0951ea6 100644 --- a/frontend/app/admin/modules/page.tsx +++ b/frontend/app/admin/modules/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Copy, GitBranch, Pencil, Plus, Search } from "lucide-react"; +import { Boxes, GitBranch, Pencil, Plus, Search } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { Modal } from "@/components/ui/modal"; +import { CopyButton } from "@/components/ui/copy-button"; import { Combobox, type ComboOption } from "@/components/ui/combobox"; +import { EmptyState } from "@/components/ui/empty-state"; import { Pagination } from "@/components/ui/pagination"; import { createModule, getCategories, getDeployToken, getModules, rotateDeployToken, updateModule } from "@/lib/api"; import type { Category, ModuleInfo } from "@/types/modex"; @@ -33,6 +35,12 @@ type Draft = { const emptyDraft: Draft = { name: "", doc_type: "vitepress", mount: "single", category_ids: [], description: "" }; +function maskToken(t: string) { + if (!t) return ""; + if (t.length <= 10) return "•".repeat(t.length); + return `${t.slice(0, 6)}${"•".repeat(Math.max(8, t.length - 10))}${t.slice(-4)}`; +} + function flatten(cats: Category[], depth = 0): ComboOption[] { return cats.flatMap((c) => [{ value: c.id, label: c.name, hint: c.key, depth }, ...flatten(c.children || [], depth + 1)]); } @@ -187,12 +195,13 @@ export default function AdminModulesPage() { ))} {!pageItems.length ? ( -
-
-
暂无文档源
-

点击「接入文档源」绑定一个文档仓库到分类。

-
-
+ + + ) : null} @@ -258,11 +267,16 @@ export default function AdminModulesPage() {
- e.currentTarget.select()} /> - + +
- 在文档仓库 GitLab CI 变量里设为 MODEX_DEPLOY_TOKEN(Masked)。仓库地址/分支会在首次 CI 推送时自动带过来。 + 出于安全考虑 Token 仅以掩码展示,点击复制按钮可拷贝完整值。在文档仓库 GitLab CI 变量里设为 MODEX_DEPLOY_TOKEN(Masked)。仓库地址/分支会在首次 CI 推送时自动带过来。
) : null} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index c88d2ab..d3535d7 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -1,23 +1,22 @@ import Link from "next/link"; -import { ArrowRight, BarChart3, Boxes, FolderTree, History, MessageSquareText, Search, Settings, Users, UsersRound } from "lucide-react"; +import { ArrowRight, Boxes, FolderTree, History, MessageSquareText, Search, Settings, Users, UsersRound } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { ReindexControls } from "@/components/reindex-controls"; const links = [ - ["/admin/categories", "分类管理", "维护层级领域、排序、状态与团队负责人", FolderTree], - ["/admin/teams", "团队管理", "文档维护团队、负责人、成员,团队负责领域文档结构", UsersRound], - ["/admin/modules", "文档源 / 模块", "接入 Git 仓库、绑定能力域、版本与同步", Boxes], - ["/admin/users", "用户管理", "管理用户、用户组、角色和状态", Users], - ["/admin/settings", "模型设置", "对接大模型与向量化服务,启用 AI 问答", Settings], - ["/admin/releases", "发布记录", "追踪 Pipeline、构建与发布结果", History], - ["/admin/analytics", "阅读统计", "查看 PV、UV 和热门文档", BarChart3], - ["/admin/search-logs", "搜索日志", "分析查询词、命中数和点击", Search], - ["/admin/mcp-logs", "MCP 日志", "查看 AI 工具读取文档记录", MessageSquareText], + ["/admin/categories", "分类管理", "维护层级分类、排序、状态与负责团队", FolderTree], + ["/admin/teams", "团队管理", "维护文档团队的负责人与成员,并为分类指定负责方", UsersRound], + ["/admin/modules", "文档源", "接入文档仓库、绑定分类、生成 Deploy Token", Boxes], + ["/admin/users", "用户管理", "管理用户资料、状态与权限", Users], + ["/admin/settings", "模型设置", "对接对话 / 向量 / 重排序模型,启用 AI 问答", Settings], + ["/admin/releases", "发布记录", "追踪每次文档发布的来源、构建与状态", History], + ["/admin/search-logs", "搜索日志", "分析查询词、命中数与点击", Search], + ["/admin/mcp-logs", "MCP 日志", "查看 AI 工具读取文档的记录", MessageSquareText], ] as const; export default function AdminPage() { return ( - +
{links.map(([href, label, desc, Icon]) => ( diff --git a/frontend/app/admin/releases/page.tsx b/frontend/app/admin/releases/page.tsx index 5c91e0f..3fb1dd5 100644 --- a/frontend/app/admin/releases/page.tsx +++ b/frontend/app/admin/releases/page.tsx @@ -1,6 +1,14 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { History, Search } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Pagination } from "@/components/ui/pagination"; import { api } from "@/lib/api"; +const PAGE_SIZE = 12; + type Release = { release_id: string; module_key: string; @@ -15,43 +23,87 @@ type Release = { published_at: string; }; -export default async function ReleasesPage() { - const releases = await api("/api/admin/releases"); +export default function ReleasesPage() { + const [releases, setReleases] = useState([]); + const [error, setError] = useState(""); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + useEffect(() => { + let cancelled = false; + api("/api/admin/releases") + .then((r) => { if (!cancelled) setReleases(r || []); }) + .catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return releases; + return releases.filter((r) => + (r.module_key || "").toLowerCase().includes(q) || + (r.publisher || "").toLowerCase().includes(q) || + (r.docs_version || "").toLowerCase().includes(q) || + (r.release_id || "").toLowerCase().includes(q), + ); + }, [releases, keyword]); + const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + return ( - + + {error ?
加载失败:{error}
: null} + +
+
+ + { setKeyword(e.target.value); setPage(1); }} + /> +
+
+
- {releases.length === 0 ? ( -
暂无发布记录
+ {filtered.length === 0 ? ( + ) : ( -
- - - - - - - - - - - - - {releases.map((r) => ( - - - - - - - - + <> +
Release模块文档版本发布人构建状态发布时间
- {r.release_id} -
{r.artifact_version}
-
{r.module_key}{r.docs_version}{r.publisher}{r.build_system} #{r.build_id}{r.status}{r.published_at?.slice(0, 10)}
+ + + + + + + + + - ))} - -
Release模块文档版本发布人构建状态发布时间
+ + + {pageRows.map((r) => ( + + + {r.release_id} +
{r.artifact_version}
+ + {r.module_key} + {r.docs_version} + {r.publisher} + {r.build_system} #{r.build_id} + {r.status} + {r.published_at?.slice(0, 10)} + + ))} + +
+ + )}
diff --git a/frontend/app/admin/search-logs/page.tsx b/frontend/app/admin/search-logs/page.tsx index b70694c..e3f736c 100644 --- a/frontend/app/admin/search-logs/page.tsx +++ b/frontend/app/admin/search-logs/page.tsx @@ -1,9 +1,19 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Pagination } from "@/components/ui/pagination"; import { api } from "@/lib/api"; +const PAGE_SIZE = 12; + type SearchLog = { id: string; user_id: string; + display_name: string; + ip_address: string; query: string; mode: string; result_count: number; @@ -11,43 +21,92 @@ type SearchLog = { searched_at: string; }; -export default async function SearchLogsPage() { - const data = await api("/api/admin/analytics/search"); +export default function SearchLogsPage() { + const [data, setData] = useState([]); + const [error, setError] = useState(""); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + useEffect(() => { + let cancelled = false; + api("/api/admin/analytics/search") + .then((d) => { if (!cancelled) setData(d || []); }) + .catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return data; + return data.filter((l) => + (l.query || "").toLowerCase().includes(q) || + (l.display_name || "").toLowerCase().includes(q) || + (l.user_id || "").toLowerCase().includes(q) || + (l.ip_address || "").toLowerCase().includes(q), + ); + }, [data, keyword]); + const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + return ( - + + {error ?
加载失败:{error}
: null} + +
+
+ + { setKeyword(e.target.value); setPage(1); }} + /> +
+
+
- {data.length === 0 ? ( -
-
-
暂无搜索日志
-

在搜索页执行一次查询后,这里会显示记录。

-
-
+ {filtered.length === 0 ? ( + ) : ( -
- - - - - - - - - - - - {data.map((log) => ( - - - - - - - + <> +
查询词模式结果数用户点击时间
{log.query || "-"}{log.mode}{log.result_count}{log.user_id || "-"}{log.clicked_doc_id || "-"}{log.searched_at?.slice(0, 19).replace("T", " ")}
+ + + + + + + + - ))} - -
查询词模式结果数用户点击时间
+ + + {pageRows.map((log) => ( + + {log.query || "-"} + {log.mode} + {log.result_count} + + {log.display_name ? ( + {log.display_name} + ) : log.user_id ? ( + {log.user_id} + ) : log.ip_address ? ( + {log.ip_address} + ) : ( + - + )} + + {log.clicked_doc_id || "-"} + {log.searched_at?.slice(0, 19).replace("T", " ")} + + ))} + +
+ + )}
diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx index 0bdac6f..9ace383 100644 --- a/frontend/app/admin/settings/page.tsx +++ b/frontend/app/admin/settings/page.tsx @@ -6,15 +6,35 @@ import { AdminShell } from "@/components/admin-shell"; import { Combobox } from "@/components/ui/combobox"; import { fetchModels, getSettings, saveSettings, type AISettings } from "@/lib/api"; -const PRESETS: { label: string; base: string; model: string }[] = [ - { label: "OpenAI", base: "https://api.openai.com/v1", model: "gpt-4o-mini" }, - { label: "DeepSeek", base: "https://api.deepseek.com/v1", model: "deepseek-chat" }, - { label: "通义千问 (DashScope 兼容)", base: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus" }, - { label: "本地 Ollama", base: "http://localhost:11434/v1", model: "qwen2.5" }, +// Supported chat API formats. "openai-chat" covers OpenAI and every +// OpenAI-compatible vendor (DeepSeek, Qwen, GLM, Moonshot, Ollama, vLLM, …); +// the others are the native protocols of the major providers. +const PROTOCOLS: { value: string; label: string }[] = [ + { value: "openai-chat", label: "OpenAI Chat Completions" }, + { value: "anthropic", label: "Anthropic Messages(原生)" }, + { value: "gemini", label: "Gemini generateContent(原生)" }, + { value: "openai-responses", label: "OpenAI Responses API" }, ]; +// Provider presets only fill the API format + Base URL — the model is always +// fetched from the endpoint, never hardcoded. Both fields stay editable. +const PRESETS: { label: string; base: string; protocol: string }[] = [ + { label: "OpenAI", base: "https://api.openai.com/v1", protocol: "openai-chat" }, + { label: "Anthropic Claude", base: "https://api.anthropic.com", protocol: "anthropic" }, + { label: "Google Gemini", base: "https://generativelanguage.googleapis.com", protocol: "gemini" }, + { label: "DeepSeek", base: "https://api.deepseek.com/v1", protocol: "openai-chat" }, + { label: "通义千问 Qwen", base: "https://dashscope.aliyuncs.com/compatible-mode/v1", protocol: "openai-chat" }, + { label: "智谱 GLM", base: "https://open.bigmodel.cn/api/paas/v4", protocol: "openai-chat" }, + { label: "Kimi (Moonshot)", base: "https://api.moonshot.cn/v1", protocol: "openai-chat" }, + { label: "SiliconFlow", base: "https://api.siliconflow.cn/v1", protocol: "openai-chat" }, + { label: "Ollama(本地)", base: "http://localhost:11434/v1", protocol: "openai-chat" }, + { label: "vLLM(自托管)", base: "http://localhost:8000/v1", protocol: "openai-chat" }, +]; + +const DEFAULT_AI: AISettings = { ask_protocol: "openai-chat", ask_base_url: "", ask_model: "", ask_api_key: "", ask_system_prompt: "" }; + export default function AdminSettingsPage() { - const [ai, setAI] = useState({ ask_base_url: "", ask_model: "", ask_api_key: "", ask_system_prompt: "" }); + const [ai, setAI] = useState(DEFAULT_AI); const [keySet, setKeySet] = useState(false); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -22,14 +42,16 @@ export default function AdminSettingsPage() { const [models, setModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const protocol = ai.ask_protocol || "openai-chat"; + async function loadModels() { if (!ai.ask_base_url) { setError("请先填写 API Base URL"); return; } setLoadingModels(true); setError(""); try { - const r = await fetchModels(ai.ask_base_url, ai.ask_api_key); + const r = await fetchModels(ai.ask_base_url, ai.ask_api_key, protocol); setModels(r.models || []); - if (!r.models?.length) setError("该端点未返回模型列表"); + if (!r.models?.length) setError("该端点未返回模型列表,请确认地址、密钥和 API 格式是否匹配。"); } catch (e) { setError(String(e)); } finally { @@ -40,7 +62,7 @@ export default function AdminSettingsPage() { useEffect(() => { getSettings() .then((s) => { - setAI({ ...s.ai, ask_api_key: "" }); + setAI({ ...DEFAULT_AI, ...s.ai, ask_api_key: "" }); setKeySet(s.ask_api_key_set); }) .catch((e) => setError(String(e))); @@ -54,7 +76,7 @@ export default function AdminSettingsPage() { const payload: AISettings = { ...ai }; if (!payload.ask_api_key) delete payload.ask_api_key; // keep existing key const s = await saveSettings(payload); - setAI({ ...s.ai, ask_api_key: "" }); + setAI({ ...DEFAULT_AI, ...s.ai, ask_api_key: "" }); setKeySet(s.ask_api_key_set); setSaved(true); setTimeout(() => setSaved(false), 2500); @@ -66,7 +88,7 @@ export default function AdminSettingsPage() { } return ( - + {error ?
{error}
: null}
@@ -74,17 +96,30 @@ export default function AdminSettingsPage() {
{PRESETS.map((p) => ( - ))}
+ 选择预设只填入 API 格式与 Base URL,模型请点「获取模型列表」从接口拉取。 + + +
+ + { setModels([]); setAI({ ...ai, ask_protocol: v[0] || "openai-chat", ask_model: "" }); }} + multiple={false} + placeholder="选择 API 格式…" + /> + 决定请求的端点与报文格式。大多数国产/开源服务用 OpenAI Chat Completions
setAI({ ...ai, ask_base_url: e.target.value })} /> - OpenAI 兼容地址,自动追加 /chat/completions/models + 服务的根地址;不同 API 格式会自动追加各自的路径(如 /chat/completions/v1/messages)。
@@ -112,14 +147,14 @@ export default function AdminSettingsPage() { onChange={(v) => setAI({ ...ai, ask_model: v[0] || "" })} multiple={false} allowCreate - placeholder={models.length ? "选择模型…" : "先获取模型列表,或手动输入"} + placeholder={models.length ? "选择模型…" : "点右侧「获取模型列表」从接口拉取"} />
- 填好 Base URL 与 Key 后点击获取;也可直接输入模型名。 + 模型从接口实时获取;填好 Base URL 与 Key 后点击拉取。
@@ -141,7 +176,7 @@ export default function AdminSettingsPage() {

- 提示:向量检索(embedding)当前由部署环境变量配置;对话模型在此页配置后立即生效,无需重启。 + 提示:对话模型在此页配置后立即生效,无需重启。向量 / 重排序模型配置将在后续版本加入。

); diff --git a/frontend/app/admin/teams/page.tsx b/frontend/app/admin/teams/page.tsx index 1cef2ac..bbdb713 100644 --- a/frontend/app/admin/teams/page.tsx +++ b/frontend/app/admin/teams/page.tsx @@ -1,10 +1,14 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Pencil, Plus, Trash2, UserPlus, X } from "lucide-react"; +import { Pencil, Plus, Search, Trash2, UserPlus, UsersRound, X } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { Modal } from "@/components/ui/modal"; import { Combobox } from "@/components/ui/combobox"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Pagination } from "@/components/ui/pagination"; + +const PAGE_SIZE = 8; import { getTeams, createTeam, @@ -47,6 +51,8 @@ export default function AdminTeamsPage() { const [draft, setDraft] = useState(emptyDraft); const [isEdit, setIsEdit] = useState(false); const [addMemberFor, setAddMemberFor] = useState>({}); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); async function refresh() { try { @@ -68,6 +74,18 @@ export default function AdminTeamsPage() { return flatCats.filter((c) => c.responsible_team === teamKey).map((c) => c.name); } + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return teams || []; + return (teams || []).filter((t) => + (t.name || "").toLowerCase().includes(q) || + (t.key || "").toLowerCase().includes(q) || + (t.leader || "").toLowerCase().includes(q) || + (t.members || []).some((m) => m.toLowerCase().includes(q)), + ); + }, [teams, keyword]); + const pageTeams = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + function openCreate() { setDraft(emptyDraft); setIsEdit(false); @@ -101,7 +119,7 @@ export default function AdminTeamsPage() { } async function removeTeam(t: Team) { - if (!confirm(`确认删除团队 ${t.key}? 关联的领域负责人不会自动清除。`)) return; + if (!confirm(`确认删除团队 ${t.key}? 关联的分类负责方不会自动清除。`)) return; try { await deleteTeam(t.key); await refresh(); @@ -135,13 +153,20 @@ export default function AdminTeamsPage() { return ( {error ?
{error}
: null}
-
共 {teams?.length ?? 0} 个团队
+
+ + { setKeyword(e.target.value); setPage(1); }} + /> +
- {owned.length > 0 ? owned.map((n) => {n}) : 未绑定领域} + {owned.length > 0 ? owned.map((n) => {n}) : 未绑定分类}
@@ -207,24 +232,26 @@ export default function AdminTeamsPage() { ); })} - {(teams || []).length === 0 ? ( -
-
-
暂无团队
-

创建一个团队,并将其设置为某个领域的负责人,即可开始维护文档结构。

-
-
+ {filtered.length === 0 ? ( + + + ) : null}
+
setModalOpen(false)} title={isEdit ? `编辑团队 · ${draft.key}` : "新增团队"} - subtitle={isEdit ? "更新团队信息与成员" : "负责人会自动加入成员列表;Key 可用于领域负责人绑定"} + subtitle={isEdit ? "更新团队信息与成员" : "负责人会自动加入成员列表;标识可用于分类负责方绑定"} footer={ <> diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx index 9e24061..d8e8297 100644 --- a/frontend/app/admin/users/page.tsx +++ b/frontend/app/admin/users/page.tsx @@ -1,15 +1,16 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { Pencil, Plus, Search, Trash2, Users as UsersIcon } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { Modal } from "@/components/ui/modal"; import { Combobox, type ComboOption } from "@/components/ui/combobox"; import { Pagination } from "@/components/ui/pagination"; -import { createUser, deleteUser, getCategories, getGroups, getTeams, getUsers, updateUser } from "@/lib/api"; -import type { Category, Group, Team, User } from "@/types/modex"; +import { Switch } from "@/components/ui/switch"; +import { EmptyState } from "@/components/ui/empty-state"; +import { createUser, deleteUser, getCategories, getTeams, getUsers, updateUser } from "@/lib/api"; +import type { Category, Team, User } from "@/types/modex"; -const ROLES = ["admin", "maintainer", "viewer"]; const PAGE_SIZE = 8; type Draft = { @@ -18,8 +19,6 @@ type Draft = { display_name: string; email: string; department: string; - groups: string[]; - roles: string[]; managed_categories: string[]; is_super_admin: boolean; status: string; @@ -30,8 +29,6 @@ const emptyDraft: Draft = { display_name: "", email: "", department: "", - groups: [], - roles: ["viewer"], managed_categories: [], is_super_admin: false, status: "active", @@ -50,7 +47,6 @@ function flattenCategories(tree: Category[]): ComboOption[] { export default function AdminUsersPage() { const [users, setUsers] = useState([]); - const [groups, setGroups] = useState([]); const [teams, setTeams] = useState([]); const [categories, setCategories] = useState([]); const [keyword, setKeyword] = useState(""); @@ -71,24 +67,26 @@ export default function AdminUsersPage() { useEffect(() => { refresh(""); - getGroups().then(setGroups).catch(() => {}); getTeams().then(setTeams).catch(() => {}); getCategories().then(setCategories).catch(() => {}); }, []); - // Identity is a single derived tier: super admin > team leader > regular user. + // Identity is a single derived tier: super admin > team leader > member. const leaderUsernames = useMemo(() => new Set((teams || []).map((t) => t.leader).filter(Boolean)), [teams]); function identityOf(u: User): { label: string; cls: string } { if (u.is_super_admin) return { label: "超级管理员", cls: "badge-danger" }; if (leaderUsernames.has(u.username)) return { label: "团队负责人", cls: "badge-success" }; - return { label: "普通用户", cls: "" }; + return { label: "成员", cls: "" }; + } + + // Team membership is owned by the Team (leader + members), so a user's teams + // are derived here rather than stored on the user. + function teamsOf(u: User): string[] { + return (teams || []) + .filter((t) => t.leader === u.username || (t.members || []).includes(u.username)) + .map((t) => t.name || t.key); } - const groupOptions: ComboOption[] = useMemo( - () => groups.map((g) => ({ value: g.group_key, label: g.name || g.group_key, hint: g.group_key })), - [groups], - ); - const roleOptions: ComboOption[] = ROLES.map((r) => ({ value: r, label: r })); const categoryOptions = useMemo(() => flattenCategories(categories), [categories]); const pageUsers = users.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); @@ -104,8 +102,6 @@ export default function AdminUsersPage() { display_name: u.display_name || "", email: u.email || "", department: u.department || "", - groups: u.groups || [], - roles: u.roles || [], managed_categories: u.managed_categories || [], is_super_admin: !!u.is_super_admin, status: u.status || "active", @@ -121,8 +117,6 @@ export default function AdminUsersPage() { display_name: draft.display_name, email: draft.email, department: draft.department, - groups: draft.groups, - roles: draft.roles, managed_categories: draft.managed_categories, is_super_admin: draft.is_super_admin, status: draft.status, @@ -133,15 +127,12 @@ export default function AdminUsersPage() { display_name: draft.display_name, email: draft.email, department: draft.department, - groups: draft.groups, - roles: draft.roles, managed_categories: draft.managed_categories, is_super_admin: draft.is_super_admin, }); } setModalOpen(false); await refresh(); - getGroups().then(setGroups).catch(() => {}); } catch (e) { setError(String(e)); } @@ -158,7 +149,7 @@ export default function AdminUsersPage() { } return ( - + {error ?
{error}
: null}
@@ -187,7 +178,7 @@ export default function AdminUsersPage() { 邮箱 部门 身份 - 用户组 + 团队 状态 操作 @@ -195,16 +186,17 @@ export default function AdminUsersPage() { {pageUsers.map((u) => { const ident = identityOf(u); + const userTeams = teamsOf(u); return (
{u.display_name || u.username}
{u.email || "—"} {u.department || "—"} {ident.label} - {(u.groups || []).length ? (u.groups || []).map((g) => {g}) : } + {userTeams.length ? userTeams.map((t) => {t}) : } - {u.status || "active"} + {u.status === "disabled" ? "已停用" : "启用"} @@ -217,7 +209,13 @@ export default function AdminUsersPage() { ); })} {!pageUsers.length ? ( -
暂无用户
+ + + ) : null} @@ -229,7 +227,7 @@ export default function AdminUsersPage() { open={modalOpen} onClose={() => setModalOpen(false)} title={isEdit ? `编辑用户 · ${draft.username}` : "新增用户"} - subtitle={isEdit ? "更新用户资料、用户组与权限" : "创建一个新的平台用户"} + subtitle={isEdit ? "更新用户资料与权限" : "创建一个新用户"} footer={ <> @@ -256,32 +254,33 @@ export default function AdminUsersPage() {
- setDraft({ ...draft, department: e.target.value })} /> + setDraft({ ...draft, department: e.target.value })} />
- - setDraft({ ...draft, groups })} allowCreate placeholder="搜索或新增用户组…" /> -
-
- - setDraft({ ...draft, roles })} placeholder="选择角色…" /> -
-
- - setDraft({ ...draft, managed_categories })} placeholder="搜索能力域…" /> - 超级管理员可管理全部领域,无需在此指定。 + + setDraft({ ...draft, managed_categories })} placeholder="搜索分类…" /> + 该用户可管理所选分类下的内容。超级管理员可管理全部分类,无需在此指定。
{isEdit ? (
- - setDraft({ ...draft, status: v[0] || "active" })} multiple={false} /> + setDraft({ ...draft, status: on ? "active" : "disabled" })} + label="启用账号" + hint="停用后该用户将无法登录。" + />
) : null} - +
+ setDraft({ ...draft, is_super_admin: on })} + tone="danger" + label="超级管理员" + hint="拥有全部权限,可管理所有用户与分类。" + /> +
); diff --git a/frontend/app/categories/[id]/page.tsx b/frontend/app/categories/[id]/page.tsx new file mode 100644 index 0000000..9612c34 --- /dev/null +++ b/frontend/app/categories/[id]/page.tsx @@ -0,0 +1,148 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowUpRight, BookOpen } from "lucide-react"; +import { CategoryTree } from "@/components/category-tree"; +import { getCategories, getEntries, getModules } from "@/lib/api"; +import type { Category, ModuleInfo } from "@/types/modex"; + +export default async function CategoryPage({ params }: { params: { id: string } }) { + const categories = await getCategories(); + const category = findCategory(categories, params.id); + if (!category) notFound(); + + const modules = await getModules(`?category_id=${encodeURIComponent(params.id)}`); + + return ( +
+
+ `/categories/${c.id}`} + /> +
+
+
+

{category.name}

+

{modules.length} 个文档集合 · {category.description || ""}

+
+
+ {modules.length === 1 ? ( + + ) : ( +
+ {modules.map((module) => ( + + ))} + {modules.length === 0 ?
该分类下暂无文档集合
: null} +
+ )} +
+
+
+ ); +} + +function SingleModuleView({ module }: { module: ModuleInfo }) { + const isStaticSite = module.doc_type === "vitepress" || module.doc_type === "vuepress" || module.doc_type === "fumadocs"; + if (isStaticSite) { + return ; + } + return ; +} + +async function StaticSiteEmbed({ module }: { module: ModuleInfo }) { + const entries = await getEntries(module.module_key, module.default_version); + const primary = entries.find((e) => e.is_primary) || entries[0]; + if (!primary) { + return
暂无文档入口
; + } + const apiBase = process.env.INTERNAL_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8671"; + const src = `${apiBase}/api/docs/${module.module_key}/${module.default_version}/${primary.entry_key}/site/index.html`; + return ( +
+