[
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n# 音谷 - AI 多角色多情绪配音平台\n\n</div>\n<p align=\"center\">\n\n<!-- 🌟 官方链接徽章 -->\n<a href=\"https://sw4s2hg7k5y.feishu.cn/wiki/WjbUw1t7JiWIa7k2pFXcxqSbnde?from=from_copylink\">\n  <img src=\"https://img.shields.io/badge/飞书-使用教程-4285F4?logo=googleclassroom&logoColor=white\" />\n</a>\n<img src=\"https://img.shields.io/badge/license-AGPLv3-blue?logo=gnu\" />\n<img src=\"https://img.shields.io/badge/release-v1.1.3-brightgreen?logo=semver\" />\n</p>\n\n> 一个开源的多角色、多情绪 AI 配音生成平台，支持小说、剧本、视频等内容的自动配音与导出。  \n\n---\n## 📝 详细使用文档\n[音谷 - AI 多角色多情绪配音平台使用教程](https://sw4s2hg7k5y.feishu.cn/wiki/WjbUw1t7JiWIa7k2pFXcxqSbnde?from=from_copylink)\n## 📖 软件简介\n- **软件名称**：音谷 - AI 多角色多情绪配音平台  \n- **定位**：为小说、剧本、视频等内容提供多角色、多情绪的 AI 语音合成与配音服务  \n- **主要功能**：\n  - 小说 / 剧本文本导入\n  - 多角色角色库管理\n  - 情绪音色选择与绑定\n  - 台词自动拆分与配音生成\n  - 批量任务管理与导出\n  - 支持自定义 LLM 接口选择与调用\n  - 基于Index-TTS-2.0的多情绪TTS服务\n  - 支持精准的音频编辑功能，可以自定义删除音频片段或者添加静音片段。\n  - 支持自定义提示词，适配个性化拆分需求\n## 🛠 技术栈\n- **前端**：Electron + Vue + Element Plus  \n- **后端**：FastAPI / Python\n- **AI 接口**：兼容 OpenAI API 协议的大模型  \n- **TTS 服务**：IndexTTs-2 + Cloud Native Build 平台（免费 H20 显卡支持）/ 本地部署整合包\n\n## 二次开发说明\n本软件依据 **AGPL-3.0** 开源许可协议发布。基于本项目进行二次开发时，开发者须遵守以下规范：\n### 1. 署名要求\n必须在衍生软件的用户界面及代码文档中清晰标注：\n> \"本软件基于开源项目《音谷》二次开发\"\n\n并附上原项目仓库链接。\n### 2. 商业使用限制\n未获得书面商业授权前，任何基于本项目的衍生作品不得用于商业用途或提供商业服务。\n\n## 🚀 快速开始\n\n### 1️⃣ 克隆项目\n```bash\ngit clone https://github.com/xcLee001/SonicVale.git\ncd SonicVale\n```\n### 2️⃣ 启动后端\n首先，需要下载ffmpeg.exe到app/core/ffmpeg/ffmpeg.exe\n\n\n可以去官网[ffmpeg](https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.0-full_build.7z)\n。也可以使用[此镜像](https://www.alipan.com/s/ey5QRqW3Jji)\n\n然后复制到app/core/ffmpeg/目录下\n\n安装依赖和启动服务\n```bash\ncd SonicVale\npip install -r requirements.txt\nuvicorn app.main:app --reload --port 8200\n```\n\n\n```\napp/\n├── core/               # 全局配置、tts引擎、llm引擎、ffmpeg封装、字幕生成、websocket、异步队列\n├── db/                 # 数据库连接和Base\n├── models/             # ORM模型\n├── dto/                # 数据传输对象（请求/响应验证）\n├── entity/             # 实体类（结合 ORM 与业务层）\n├── repositories/       # 数据库封装\n├── services/           # 核心业务逻辑\n├── routers/            # FastAPI路由接口\n└── main.py             # FastAPI启动入口\n\n```\n\n\n\n### 3️⃣ 启动前端\n```bash\ncd sonicvale-front\nnpm install   # 安装依赖\nnpm run start # 启动前端包括electron\n```\n## Coffe\n如果您觉得我的项目对您有所帮助，欢迎您的赞助。您的支持将使我有更多的动力继续维护和改进这个项目。\n您可以通过扫描下面的二维码来请我喝杯咖啡：\n\n\n<img src=\"image/赞赏码.jpg\" alt=\"赞赏码\" width=\"320px\" height=\"320px\">\n\n## 🎥 效果演示\n👉 [点击查看 B 站演示效果视频](https://www.bilibili.com/video/BV1tSpTz6EBH/)\n\n\n## 📷 截图\n\nLLM 配置界面\n![alt text](image/image-1.png)\n\nTTS 配置界面\n![alt text](image/image-2.png)\n\n音色管理界面\n![alt text](image/image-3.png)\n\n项目创建界面\n![alt text](image/image-4.png)\n\n章节创建界面\n![alt text](image/image-5.png)\n\n章节内容导入\n![alt text](image/image-6.png)\n\n台词自动拆分\n![alt text](image/image-7.png)\n\n角色绑定，多章节共享角色音色\n![alt text](image/image-8.png)\n\n台词编辑，高度自定义\n![alt text](image/image-9.png)\n  - 在台词编辑区，用户可手动修改：\n    - 台词文本\n    - 角色归属\n    - 情绪类型\n    - 情绪轻度\n  - 修改后自动保存并更新。\n\n配音生成\n![alt text](image/image-10.png)\n\n生成后音频可编辑\n![alt text](image/image-11.png)\n\n\n\n## 📬 联系方式\n\n如果在使用过程中遇到 **Bug** 或者有 **功能建议**，请通过 [GitHub Issues](https://github.com/xcLee001/SonicVale/issues) 提交，这样可以帮助我们更好地跟踪与解决问题。  \n\n如果你希望加入用户交流社区，欢迎加入我们的 QQ 群：  \n\n- 💬 QQ交流群：1060711739（1群已满）、575715633（2群） （验证信息请填写 “音谷配音”）\n\n## 📜 协议\n\n本项目采用 [GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE) 开源协议。  \n\n您可以自由地使用、复制、修改、合并、发布和分发本软件及其副本，但必须遵守以下条款：\n\n- 您必须在分发的软件中包含原始许可声明和版权声明。\n\n- 若您修改并发布本软件，或通过网络提供服务（如 SaaS、Web 应用），您必须同时公开修改后的源代码。\n\n- 您不得附加任何与 AGPL-3.0 条款冲突的限制。\n\n## ⚠️ 免责声明\n\n本项目仅供学习与研究使用。  \n用户不得利用本项目从事任何违法违规行为，包括但不限于：  \n- 克隆或模仿未经授权的声音；  \n- 侵犯他人声音权、肖像权、著作权、名誉权；  \n- 其他可能违反法律法规的行为。  \n\n开发者不对用户使用本项目所产生的任何后果负责，所有风险与责任由用户自行承担。  \n使用本项目即表示您已阅读并同意本免责声明。  \n\n---\n\n## ⚠️ Disclaimer\n\nThis project is intended for research and educational purposes only.  \nUsers are strictly prohibited from using this project for any unlawful activities, including but not limited to:  \n- Cloning or imitating voices without authorization;  \n- Infringing upon the rights of others (voice rights, portrait rights, copyrights, reputation rights, etc.);  \n- Any other activities in violation of applicable laws and regulations.  \n\nThe developer shall not be held liable for any consequences arising from the use of this project.  \nAll risks and responsibilities lie solely with the user.  \nBy using this project, you acknowledge that you have read and agreed to this disclaimer.\n"
  },
  {
    "path": "SonicVale/.gitignore",
    "content": "# python cache\n__pycache__/\n*.pyc\n*.pyo\n*.pyd\n\n# JetBrains IDE\n.idea/\n\n# venv\n.venv/\nenv/\nvenv/\n\n\n# 打包输出\ndist\nbuild\n*.spec\n*.exe\n\n# logs\n*.log\n\n# others\n.DS_Store"
  },
  {
    "path": "SonicVale/README.md",
    "content": "```\napp/\n├── core/               # 全局配置、tts引擎、llm引擎、ffmpeg封装、字幕生成、websocket、异步队列\n├── db/                 # 数据库连接和Base\n├── models/             # ORM模型\n├── dto/                # 数据传输对象（请求/响应验证）\n├── entity/             # 实体类（结合 ORM 与业务层）\n├── repositories/       # 数据库封装\n├── services/           # 核心业务逻辑\n├── routers/            # FastAPI路由接口\n└── main.py             # FastAPI启动入口\n\n\n```\n"
  },
  {
    "path": "SonicVale/app/__init__.py",
    "content": ""
  },
  {
    "path": "SonicVale/app/core/__init__.py",
    "content": ""
  },
  {
    "path": "SonicVale/app/core/audio_engin.py",
    "content": "import os\nimport subprocess\nimport tempfile\nimport soundfile as sf\nimport numpy as np\n\nfrom app.core.config import getFfmpegPath\n\n\nclass AudioProcessor:\n    def __init__(self, audio_path: str, keep_format=True, default_sr=44100, default_ch=2):\n        self.audio_path = audio_path\n        self.keep_format = keep_format\n        self.default_sr = default_sr\n        self.default_ch = default_ch\n\n        info = sf.info(audio_path)\n        self.sr = info.samplerate if keep_format else default_sr\n        self.ch = info.channels if keep_format else default_ch\n        self.duration = info.duration\n\n        self.ffmpeg_path = getFfmpegPath()\n        self.temp_path = self._create_tmp_file()\n\n    def _create_tmp_file(self):\n        os.makedirs(os.path.dirname(self.audio_path) or \".\", exist_ok=True)\n        tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".wav\",\n                                          dir=os.path.dirname(self.audio_path) or \".\")\n        return tmp.name\n\n    def _run_ffmpeg(self, cmd):\n        subprocess.run(\n            cmd, check=True,\n            creationflags=subprocess.CREATE_NO_WINDOW if os.name == \"nt\" else 0\n        )\n\n    def _normalize(self, path):\n        \"\"\"防止音量削波\"\"\"\n        data, sr = sf.read(path, dtype=\"float32\", always_2d=True)\n        peak = float(np.max(np.abs(data)))\n        if peak > 1.0:\n            data = data / peak\n            sf.write(path, data, sr, format=\"WAV\", subtype=\"PCM_16\")\n\n    # ---------------------- 模块功能 ---------------------- #\n\n    def cut(self, start_ms: int, end_ms: int):\n        \"\"\"删除音频区间 [start_ms, end_ms]\"\"\"\n        start_sec = start_ms / 1000\n        end_sec = end_ms / 1000\n\n        cmd = [\n            self.ffmpeg_path, \"-y\", \"-i\", self.audio_path,\n            \"-filter_complex\",\n            f\"[0:a]atrim=0:{start_sec},asetpts=PTS-STARTPTS[first];\"\n            f\"[0:a]atrim={end_sec},asetpts=PTS-STARTPTS[second];\"\n            f\"[first][second]concat=n=2:v=0:a=1[out]\",\n            \"-map\", \"[out]\",\n            \"-ar\", str(self.sr),\n            \"-ac\", str(self.ch),\n            \"-c:a\", \"pcm_s16le\",\n            self.temp_path\n        ]\n        self._run_ffmpeg(cmd)\n        os.replace(self.temp_path, self.audio_path)\n\n    def insert_silence(self, insert_ms: int, duration_sec: float):\n        \"\"\"在指定时间点插入静音\"\"\"\n        insert_sec = insert_ms / 1000\n        cmd = [\n            self.ffmpeg_path, \"-y\",\n            \"-i\", self.audio_path,\n            \"-f\", \"lavfi\", \"-t\", str(duration_sec),\n            \"-i\", f\"anullsrc=channel_layout={'stereo' if self.ch == 2 else 'mono'}:sample_rate={self.sr}\",\n            \"-filter_complex\",\n            f\"[0:a]atrim=0:{insert_sec},asetpts=PTS-STARTPTS[first];\"\n            f\"[0:a]atrim={insert_sec},asetpts=PTS-STARTPTS[second];\"\n            f\"[first][1:a][second]concat=n=3:v=0:a=1[out]\",\n            \"-map\", \"[out]\",\n            \"-ar\", str(self.sr),\n            \"-ac\", str(self.ch),\n            \"-c:a\", \"pcm_s16le\",\n            self.temp_path\n        ]\n        self._run_ffmpeg(cmd)\n        os.replace(self.temp_path, self.audio_path)\n\n    def append_silence(self, duration_sec: float):\n        \"\"\"\n        在音频末尾添加或裁剪静音段：\n        - duration_sec > 0: 在末尾添加指定秒数静音\n        - duration_sec < 0: 从末尾裁剪指定秒数的内容\n        \"\"\"\n        if duration_sec == 0:\n            return  # 无需处理\n\n        # ---------- 情况1：添加静音 ----------\n        if duration_sec > 0:\n            cmd = [\n                self.ffmpeg_path, \"-y\",\n                \"-i\", self.audio_path,\n                \"-f\", \"lavfi\", \"-t\", str(duration_sec),\n                \"-i\", f\"anullsrc=channel_layout={'stereo' if self.ch == 2 else 'mono'}:sample_rate={self.sr}\",\n                \"-filter_complex\",\n                \"[0:a][1:a]concat=n=2:v=0:a=1[out]\",\n                \"-map\", \"[out]\",\n                \"-ar\", str(self.sr),\n                \"-ac\", str(self.ch),\n                \"-c:a\", \"pcm_s16le\",\n                self.temp_path\n            ]\n\n        # ---------- 情况2：裁剪末尾 ----------\n        else:\n            cut_dur = self.duration + duration_sec  # 因为 duration_sec 为负\n            if cut_dur < 0:\n                cut_dur = 0  # 防止全裁掉出错\n            cmd = [\n                self.ffmpeg_path, \"-y\",\n                \"-i\", self.audio_path,\n                \"-filter_complex\",\n                f\"[0:a]atrim=0:{cut_dur},asetpts=PTS-STARTPTS[out]\",\n                \"-map\", \"[out]\",\n                \"-ar\", str(self.sr),\n                \"-ac\", str(self.ch),\n                \"-c:a\", \"pcm_s16le\",\n                self.temp_path\n            ]\n\n        # 执行 ffmpeg 命令\n        self._run_ffmpeg(cmd)\n        os.replace(self.temp_path, self.audio_path)\n        # 更新音频时长（防止后续操作出错）\n        info = sf.info(self.audio_path)\n        self.duration = info.duration\n\n    def change_speed(self, speed: float):\n        \"\"\"变速处理 (0.5~2.0倍)\"\"\"\n        speed = float(np.clip(speed, 0.5, 2.0))\n        cmd = [\n            self.ffmpeg_path, \"-y\", \"-i\", self.audio_path,\n            \"-af\", f\"atempo={speed}\",\n            \"-ar\", str(self.sr),\n            \"-ac\", str(self.ch),\n            \"-c:a\", \"pcm_s16le\",\n            self.temp_path\n        ]\n        self._run_ffmpeg(cmd)\n        os.replace(self.temp_path, self.audio_path)\n\n    def change_volume(self, volume: float):\n        \"\"\"音量调整\"\"\"\n        volume = max(0.0, float(volume))\n        cmd = [\n            self.ffmpeg_path, \"-y\", \"-i\", self.audio_path,\n            \"-af\", f\"volume={volume}\",\n            \"-ar\", str(self.sr),\n            \"-ac\", str(self.ch),\n            \"-c:a\", \"pcm_s16le\",\n            self.temp_path\n        ]\n        self._run_ffmpeg(cmd)\n        os.replace(self.temp_path, self.audio_path)\n\n    def export(self, out_path: str):\n        \"\"\"导出音频到目标路径（带软限幅）\"\"\"\n        self._normalize(self.audio_path)\n        os.replace(self.audio_path, out_path)\n        return out_path\n"
  },
  {
    "path": "SonicVale/app/core/config.py",
    "content": "import os\n\nimport os, sys\nfrom pathlib import Path\n# 得到默认配置文件\ndef getConfigPath():\n    # 用户 目录下SonicVale目录\n    user_dir = os.path.join(os.path.expanduser(\"~\"), \"SonicVale\")\n\n    # 如果目录不存在，创建它\n    if not os.path.exists(user_dir):\n        os.makedirs(user_dir, exist_ok=True)\n\n    # 返回 config.json 路径（目录已保证存在）\n    return user_dir\n\ndef getFfmpegPath():\n    BASE_DIR = getattr(sys, \"_MEIPASS\", Path(os.path.abspath(\".\")))\n    FFMPEG_PATH = os.path.join(BASE_DIR, \"core\", \"ffmpeg\", \"ffmpeg.exe\")\n    return FFMPEG_PATH"
  },
  {
    "path": "SonicVale/app/core/enums.py",
    "content": "from enum import Enum\n\nclass TaskEnum(str, Enum):\n    DUBBING = \"台词拆分\"\n\n"
  },
  {
    "path": "SonicVale/app/core/llm_engine.py",
    "content": "# app/core/llm_engine.py\nimport json\nimport logging\n# app/core/llm_engine.py\n\nimport re\nimport time\nimport random\nfrom openai import OpenAI\nfrom numba.cuda import stream\n\nfrom app.core.prompts import get_auto_fix_json_prompt\n\n\nclass LLMEngine:\n    def __init__(self, api_key: str, base_url: str, model_name: str, custom_params: str):\n        \"\"\"\n        api_key: LLM API Key\n        base_url: OpenAI-compatible API URL（例如企业版/自建 LLM）\n        model_name: 模型名称\n        custom_params: 自定义参数（JSON字符串）\n        \"\"\"\n        self.api_key = api_key\n        self.base_url = base_url.rstrip(\"/\")  # 去掉末尾斜杠\n        self.model_name = model_name\n        \n        # custom_params从string转为dict\n        custom_params = json.loads(custom_params)\n        if not isinstance(custom_params, dict):\n            raise ValueError(\"无效的 custom_params\")\n        self.custom_params = custom_params\n        \n        # 使用新版 OpenAI 客户端\n        self.client = OpenAI(\n            api_key=api_key,\n            base_url=self.base_url\n        )\n\n    def _extract_result_tag(self, text: str) -> str:\n        \"\"\"提取 <result> 标签内容\"\"\"\n        match = re.search(r\"<result>(.*?)</result>\", text, re.DOTALL)\n        if not match:\n            raise ValueError(\"Response does not contain <result>...</result> tag\")\n        return match.group(1).strip()\n\n    def generate_text_test(self, prompt: str) -> str:\n        \"\"\"\n        测试：生成结果并返回（非流式）\n        \"\"\"\n        response = self.client.chat.completions.create(\n            model=self.model_name,\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n            timeout=3000,\n            **self.custom_params\n        )\n        return response.choices[0].message.content\n    def generate_text(self, prompt: str, retries: int = 3, delay: float = 1.0) -> str:\n        \"\"\"\n        流式生成：边生成边输出\n        \"\"\"\n        for attempt in range(retries):\n            try:\n                # 开启流式\n                # stream = self.client.chat.completions.create(\n                #     model=self.model_name,\n                #     messages=[{\"role\": \"user\", \"content\": prompt}],\n                #     stream=True,\n                #     timeout=3000,\n                #     **self.custom_params\n                # )\n\n                # 关闭流式，直接获取完整响应\n                response = self.client.chat.completions.create(\n                    model=self.model_name,\n                    messages=[{\"role\": \"user\", \"content\": prompt}],\n                    stream=False,  # 关键：设置为 False\n                    timeout=3000,\n                    **self.custom_params\n                )\n\n                # 直接获取完整文本\n                full_text = response.choices[0].message.content\n                return full_text\n\n            except Exception as e:\n                if attempt < retries - 1:\n                    sleep_time = delay * (2 ** attempt) + random.random()\n                    time.sleep(sleep_time)\n                else:\n                    raise e\n    def save_load_json(self, json_str: str):\n        \"\"\"解析JSON，支持自动提取<result>标签内容\"\"\"\n        # 先尝试提取 <result> 标签内容\n        try:\n            json_str = self._extract_result_tag(json_str)\n        except ValueError:\n            # 没有 <result> 标签，直接使用原文本\n            pass\n        \n        # 尝试加载json\n        try:\n            return json.loads(json_str)\n        except json.JSONDecodeError:\n            # JSON解析失败，尝试让LLM修复\n            prompt = get_auto_fix_json_prompt(json_str)\n            res = self.generate_text(prompt)\n            # 递归调用，修复后的结果也可能包含 <result> 标签\n            return self.save_load_json(res)\n\n    def generate_smart_text(self, prompt: str) -> str:\n        \"\"\"\n        智能文本生成（流式）\n        \"\"\"\n        stream = self.client.chat.completions.create(\n            model=self.model_name,\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n            stream=True,\n            timeout=3000\n        )\n\n        # 拼接 delta.content\n        full_text = \"\"\n        for chunk in stream:\n            if chunk.choices and len(chunk.choices) > 0:\n                delta = chunk.choices[0].delta\n                content = delta.content if hasattr(delta, 'content') else None\n                if content:\n                    # print(content, end=\"\", flush=True)\n                    full_text += content\n\n        logging.debug(\"流式生成完成\")\n        return full_text\n"
  },
  {
    "path": "SonicVale/app/core/prompts.py",
    "content": "# 根据小说内容生成\n\nimport textwrap\n\n\ndef get_context2lines_prompt(possible_characters, novel_content,possible_emotions,possible_strengths) -> str:\n\n    prompt = f\"\"\"\n你的任务是将给定小说内容划分为角色台词和旁白，并输出包含<result>标签的结构化JSON结果。\n\n划分规则：\n\n台词识别:\n识别所有角色说话的内容，包括带引号、破折号、叹号等常见台词标记的文本。\n如果角色在给定角色列表中，使用该角色名；\n如果角色未在列表中出现，根据上下文合理归纳角色名。\n重要规则：相邻台词之间如果角色相同，可以适当合并，但是一段内容最多不超过150字。如果单段内容超过150字，请将内容拆分为多条。\n\n\n旁白识别:\n对叙述性、心理描写、环境描写、动作描写等非台词内容统一标记为“旁白：”。\n重要规则：相邻台词之间如果都为旁白内容，可以适当合并，但是一段内容最多不超过150字。如果单段内容超过150字，请将内容拆分为多条。\n\n情绪以及情绪强弱识别:\n根据上下文场景，识别出每条台词所对应的情绪以及情绪强度。情绪和情绪强度的内容必须来自情绪列表possible_emotions和情绪强度列表possible_strengths。\n旁白的情绪和情绪强度统一为一样的，统一为‘平静’情绪，强度为‘中等’。\n\n特殊情况处理:\n多角色对话连续出现时，每条台词对应正确角色。\n混合旁白和台词的段落可拆分为旁白和台词两条记录。\n避免重复、遗漏台词或旁白。\n\n输出格式:\n输出严格遵循包含<result>标签的JSON数组形式\n\n示例：\n<result>\n[\n{\"role_name\": \"张三\", \"text_content\": \"你到底在干什么！\", \"emotion_name\": \"生气\", \"strength_name\": \"强烈\"},\n{\"role_name\": \"旁白\", \"text_content\": \"此时，张三愤怒站着\", \"emotion_name\": \"平静\", \"strength_name\": \"中等\"},\n{\"role_name\": \"李四\", \"text_content\": \"这可不管我的事儿\", \"emotion_name\": \"害怕\", \"strength_name\": \"微弱\"}\n]\n</result>\n\n注意事项:\n保持文本顺序与逻辑一致。\n不要改写原文台词或旁白内容。\n所有划分结果必须完整输出在 <result> 标签内。\n\n输入内容：\n可能包含的角色列表：\n<possible_characters>\n{possible_characters}\n</possible_characters>\n\n可能包含的情绪列表：\n<possible_emotions>\n{possible_emotions}\n</possible_emotions>\n\n可能包含的情绪强弱列表：\n<possible_strengths>\n{possible_strengths}\n</possible_strengths>\n\n小说原文：\n<novel_content>\n{novel_content}\n</novel_content>\n\n\n\"\"\"\n    return textwrap.dedent(prompt)\n\ndef get_prompt_str():\n    prompt = \"\"\"\n    你的任务是将给定小说内容划分为角色和内容，并输出为结构化JSON结果。\n    台词识别规则：\n    1. 必须完整保留原文内容，不得遗漏、删改或省略任何字句。\n    2. 提取角色对话内容喝旁白。识别所有内容，包括带引号（“”）、破折号（——）、感叹号（！）、冒号（：）等常见台词标记的文本，其余均为旁白内容。\n    3. 若角色在已知角色列表<possible_characters>中，则直接使用该角色名；若不在列表中，则根据上下文合理判断角色身份。\n    4. 相邻台词如属同一角色，可合并为一条，但单条台词长度不得超过150字。\n    5. 若单条台词超过150字，需按语义完整性拆分为多条，每条不超过150字，并确保原文内容不缺失。\n    \n    旁白识别规则：\n    1. 所有非台词的叙述性内容（包括心理活动、环境描写、动作描写、场景过渡等）均标记为“旁白”。\n    2. 必须保留原文的所有文字内容，不得遗漏、删改或省略任何字句。\n    3. 相邻的旁白内容可合并为一条，但单条长度不得超过150字。\n    4. 若单条旁白超过150字，需按语义完整性拆分为多条，每条不超过150字，确保原文内容完整呈现。\n    \n    情绪与情绪强度识别规则：\n    1. 根据上下文语境、语气及场景变化，为每条台词识别情绪和情绪强度。\n    2. 情绪与强度必须严格从提供的情绪列表（possible_emotions）与强度列表（possible_strengths）中选择。\n    3. “旁白”内容的情绪与强度统一为：情绪“平静”，强度“中等”。\n    4. 情绪识别不得影响或改写原文内容，仅用于标注。\n    \n    特殊情况处理：\n    1. 多角色连续对话时，确保每条台词对应正确角色，避免角色错配。\n    2. 当段落中混合出现旁白与台词时，应拆分为独立记录：旁白一条、台词一条。\n    3. 输出结果不得出现遗漏、重复、合并错误或原文缺失的情况。\n    4. 拆分、合并及情绪标注仅为结构化目的，须保证原文内容100%完整保留。\n    \n    输出格式:\n    严格输出为 json数组。\n    \n    示例：\n    小说原文：\n    <novel_content>\n    一名靠前的灰衣少年似乎与石台上的少年颇为熟悉，他听得大伙的窃窃私语，不由得得意一笑，压低声音道：“牧哥可是被选拔出来参加过“灵路”的人，我们整个北灵境中，可就牧哥一人有名额，你们应该也知道参加“灵路”的都是些什么变态吧？当年我们这北灵境可是因为此事沸腾了好一阵的，从那里出来的人，最后基本全部都是被“五大院”给预定了的。”\n    </novel_content>\n    输出：\n    [\n      {\"role_name\": \"旁白\", \"text_content\": \"一名靠前的灰衣少年似乎与石台上的少年颇为熟悉，他听得大伙的窃窃私语，不由得得意一笑，压低声音道\", \"emotion_name\": \"平静\", \"strength_name\": \"中等\"},\n      {\"role_name\": \"灰衣少年\", \"text_content\": \"牧哥可是被选拔出来参加过“灵路”的人，我们整个北灵境中，可就牧哥一人有名额，你们应该也知道参加“灵路”的都是些什么变态吧？当年我们这北灵境可是因为此事沸腾了好一阵的，从那里出来的人，最后基本全部都是被“五大院”给预定了的。\", \"emotion_name\": \"高兴\", \"strength_name\": \"中等\"}\n    ]\n    \n    \n    输入内容：\n    可能包含的角色列表：\n    <possible_characters>\n    {possible_characters}\n    </possible_characters>\n    \n    可能包含的情绪列表：\n    <possible_emotions>\n    {possible_emotions}\n    </possible_emotions>\n    \n    可能包含的情绪强弱列表：\n    <possible_strengths>\n    {possible_strengths}\n    </possible_strengths>\n    \n    小说原文：\n    <novel_content>\n    {novel_content}\n    </novel_content>\n\n    \"\"\"\n    return textwrap.dedent(prompt)\n\n\n\n\ndef get_auto_fix_json_prompt(json_str: str) -> str:\n    prompt = f\"\"\"\n    你将收到一段可能出错的 JSON 字符串（它可能是 LLM 生成的结果），其中可能存在以下问题：\n        多余或缺失的逗号\n        缺少引号或多余引号\n        键值格式错误\n        JSON 外含无关说明文字\n        非法转义符\n    你的任务是：\n    仅输出一个严格合法、可被 json.loads 解析的 JSON。\n    保持原有数据结构和内容不变（除非必须修正格式）。\n    不要在 JSON 外输出任何解释、额外文字或注释。\n    输出必须完整输出在 <result> </result>标签内。\n    输入内容：\n    <json_str>\n    {json_str}\n    </json_str>w\n    \"\"\"\n    return textwrap.dedent(prompt)\n\n\ndef get_add_smart_role_and_voice(original_text: str, role_name, voice_names):\n    prompt = f\"\"\"\n    你是“角色音色匹配助手”。你的任务是：根据小说原文中的角色表现，为每个在<role_name>中出现的角色匹配最符合其语气与性格的音色。\n\n    原文内容：\n    <original_text>\n    {original_text}\n    </original_text>\n\n    角色列表信息：\n    <role_name>\n    {role_name}\n    </role_name>\n\n    音色列表信息：\n    <voice>\n    {voice_names}\n    </voice>\n\n    匹配规则（必须严格遵守）：\n    1. 仅根据【原文内容】判断哪些角色实际出现；未在原文中出现的角色一律忽略，不输出。\n    2. 对于每个实际出现的角色，根据原文中体现的性格特征、语气风格、情绪倾向、年龄感等信息，推断该角色适合的音色类型。\n    3. 再根据音色库中每个音色的名称或描述，为角色挑选最匹配的音色。\n    4. 若某角色最匹配的音色与其他角色重复使用是不允许的（音色数量可能不足）。\n    5. 若确实存在无法匹配的角色（例如原文完全无语气风格线索），则该角色不输出。\n    6. 不得臆造原文中不存在的角色特征或音色特征。\n    7. 最终输出必须是一个标准 JSON 数组，且数组中的每个对象必须包含：\n       - \"role_name\": 角色名\n       - \"voice_name\": 匹配的音色名\n\n    输出格式要求：\n    - 严格输出 JSON 数组。\n    - 不得输出任何解释说明、自然语言、注释或多余内容。\n\n    示例输出（格式示例）：\n    [\n      {{ \"role_name\": \"灰衣少年\", \"voice_name\": \"小王\" }},\n      {{ \"role_name\": \"白衣少年\", \"voice_name\": \"小正\" }}\n    ]\n    \"\"\"\n\n    return textwrap.dedent(prompt)\n\n\ndef get_subtitle_correction_prompt(original_text: str, subtitle_lines: list) -> str:\n    \"\"\"\n    生成字幕矫正的prompt\n    original_text: 原始正确文本\n    subtitle_lines: ASR识别的字幕行列表，格式为 [{\"index\": 1, \"text\": \"...\"}]\n    \"\"\"\n    subtitle_json = \"\\n\".join([f'  {{\"index\": {item[\"index\"]}, \"text\": \"{item[\"text\"]}\"}}' for item in subtitle_lines])\n    \n    prompt = f\"\"\"\n你是一个专业的字幕校对助手。你的任务是根据原文内容，修正ASR自动识别产生的字幕错误。\n\n## 任务说明\nASR（自动语音识别）生成的字幕可能存在以下问题：\n1. 同音字错误（如\"他\"与\"她\"、\"的\"与\"得\"）\n2. 近音字错误\n3. 词语分割错误\n4. 标点符号错误或缺失\n\n你需要参考原文，将每条字幕修正为正确的文本。\n\n## 重要规则\n1. 严格保持字幕条目数量不变（输入多少条，输出多少条）\n2. 尽量保持每条字幕的长度相近，不要大幅改变字幕的切分位置\n3. 仅修正错误，不要改写原意或增删内容\n4. 如果某条字幕已经正确，原样保留\n5. 输出格式必须是JSON数组\n\n## 原文内容\n<original_text>\n{original_text}\n</original_text>\n\n## 待矫正的字幕\n<subtitle_lines>\n[\n{subtitle_json}\n]\n</subtitle_lines>\n\n## 输出格式\n严格输出JSON数组，每个元素包含index和corrected_text字段：\n<result>\n[\n  {{\"index\": 1, \"corrected_text\": \"修正后的文本\"}},\n  {{\"index\": 2, \"corrected_text\": \"修正后的文本\"}}\n]\n</result>\n\n请开始矫正：\n\"\"\"\n    return textwrap.dedent(prompt)"
  },
  {
    "path": "SonicVale/app/core/response.py",
    "content": "# app/core/response.py\nfrom pydantic.generics import GenericModel\nfrom typing import Generic, TypeVar, Optional\n\nT = TypeVar(\"T\")\n\nclass Res(GenericModel, Generic[T]):\n    code: int = 200\n    message: str = \"success\"\n    data: Optional[T] = None\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/ASRData.py",
    "content": "import json\nimport logging\nimport re\nfrom typing import List\nfrom pathlib import Path\n\nclass ASRDataSeg:\n    def __init__(self, text, start_time, end_time):\n        self.text = text\n        self.start_time = start_time\n        self.end_time = end_time\n\n    def to_srt_ts(self) -> str:\n        \"\"\"Convert to SRT timestamp format\"\"\"\n        return f\"{self._ms_to_srt_time(self.start_time)} --> {self._ms_to_srt_time(self.end_time)}\"\n\n\n    def to_lrc_ts(self) -> str:\n        \"\"\"Convert to LRC timestamp format\"\"\"\n        return f\"[{self._ms_to_lrc_time(self.start_time)}]\"\n    \n    def to_ass_ts(self) -> tuple[str, str]:\n        \"\"\"Convert to ASS timestamp format\"\"\"\n        return self._ms_to_ass_ts(self.start_time), self._ms_to_ass_ts(self.end_time)\n\n    def _ms_to_lrc_time(self, ms) -> str:\n        seconds = ms / 1000\n        minutes, seconds = divmod(seconds, 60)\n        return f\"{int(minutes):02}:{seconds:.2f}\"\n    \n    @staticmethod\n    def _ms_to_srt_time(ms) -> str:\n        \"\"\"Convert milliseconds to SRT time format (HH:MM:SS,mmm)\"\"\"\n        total_seconds, milliseconds = divmod(ms, 1000)\n        minutes, seconds = divmod(total_seconds, 60)\n        hours, minutes = divmod(minutes, 60)\n        return f\"{int(hours):02}:{int(minutes):02}:{int(seconds):02},{int(milliseconds):03}\"\n\n    @staticmethod\n    def _ms_to_ass_ts(ms) -> str:\n        \"\"\"Convert milliseconds to ASS timestamp format (H:MM:SS.cc)\"\"\"\n        total_seconds, milliseconds = divmod(ms, 1000)\n        minutes, seconds = divmod(total_seconds, 60)\n        hours, minutes = divmod(minutes, 60)\n        # ASS格式使用厘秒(1/100秒)而不是毫秒\n        centiseconds = int(milliseconds / 10)\n        return f\"{int(hours):01}:{int(minutes):02}:{int(seconds):02}.{centiseconds:02}\"\n\n    @property\n    def transcript(self) -> str:\n        \"\"\"Return segment text\"\"\"\n        return self.text\n\n    def __str__(self) -> str:\n        return f\"ASRDataSeg({self.text}, {self.start_time}, {self.end_time})\"\n\n\nclass ASRData:\n    def __init__(self, segments: List[ASRDataSeg]):\n        self.segments = segments\n\n    def __iter__(self):\n        return iter(self.segments)\n    \n    def __len__(self) -> int:\n        return len(self.segments)\n    \n    def has_data(self) -> bool:\n        \"\"\"Check if there are any utterances\"\"\"\n        return len(self.segments) > 0\n    \n    def is_word_timestamp(self) -> bool:\n        \"\"\"\n        判断是否是字级时间戳\n        规则：\n        1. 对于英文，每个segment应该只包含一个单词\n        2. 对于中文，每个segment应该只包含一个汉字\n        3. 允许20%的误差率\n        \"\"\"\n        if not self.segments:\n            return False\n            \n        valid_segments = 0\n        total_segments = len(self.segments)\n        \n        for seg in self.segments:\n            text = seg.text.strip()\n            # 检查是否只包含一个英文单词或一个汉字\n            if (len(text.split()) == 1 and text.isascii()) or len(text.strip()) <= 2:\n                valid_segments += 1\n        logging.info(\"valid_segments: %s, total_segments: %s\", valid_segments, total_segments)\n        return (valid_segments / total_segments) >= 0.8\n\n\n    def save(self, save_path: str, ass_style: str = None, layout: str = \"原文在上\") -> None:\n        \"\"\"Save the ASRData to a file\"\"\"\n        # 根据文件后缀名选择保存格式\n        Path(save_path).parent.mkdir(parents=True, exist_ok=True)\n        if save_path.endswith('.srt'):\n            self.to_srt(save_path=save_path)\n        elif save_path.endswith('.txt'):\n            with open(save_path, 'w', encoding='utf-8') as f:\n                f.write(self.to_txt())\n        elif save_path.endswith('.json'):\n            with open(save_path, 'w', encoding='utf-8') as f:\n                json.dump(self.to_json(), f, ensure_ascii=False)\n        elif save_path.endswith('.ass'):\n            self.to_ass(save_path=save_path, style_str=ass_style, layout=layout)\n        else:\n            raise ValueError(f\"Unsupported file extension: {save_path}\")\n\n    def to_txt(self) -> str:\n        \"\"\"Convert to plain text subtitle format (without timestamps)\"\"\"\n        return \"\\n\".join(seg.transcript for seg in self.segments)\n\n    def to_srt(self, save_path=None) -> str:\n        \"\"\"Convert to SRT subtitle format\"\"\"\n        srt_text = \"\\n\".join(\n            f\"{n}\\n{seg.to_srt_ts()}\\n{seg.transcript}\\n\"\n            for n, seg in enumerate(self.segments, 1))\n        if save_path:\n            with open(save_path, 'w', encoding='utf-8') as f:\n                f.write(srt_text)\n        return srt_text\n\n    def to_lrc(self, save_path=None) -> str:\n        \"\"\"Convert to LRC subtitle format\"\"\"\n        lrc_text = \"\\n\".join(\n            f\"{seg.to_lrc_ts()}{seg.transcript}\" for seg in self.segments\n        )\n        if save_path:\n            with open(save_path, 'w', encoding='utf-8') as f:\n                f.write(lrc_text)\n        return lrc_text\n\n    def to_json(self) -> dict:\n        result_json = {}\n        for i, segment in enumerate(self.segments, 1):\n            # 检查是否有换行符\n            if \"\\n\" in segment.text:\n                original_subtitle, translated_subtitle = segment.text.split(\"\\n\")\n            else:\n                original_subtitle, translated_subtitle = segment.text, \"\"\n\n            result_json[str(i)] = {\n                \"start_time\": segment.start_time,\n                \"end_time\": segment.end_time,\n                \"original_subtitle\": original_subtitle,\n                \"translated_subtitle\": translated_subtitle\n            }\n        return result_json\n\n    def to_ass(self, style_str: str = None, layout: str = \"原文在上\", save_path: str = None) -> str:\n        \"\"\"转换为ASS字幕格式\n        \n        Args:\n            style_str: ASS样式字符串,为空则使用默认样式\n            layout: 字幕布局,可选值[\"译文在上\", \"原文在上\", \"仅原文\", \"仅译文\"]\n            \n        Returns:\n            ASS格式字幕内容\n        \"\"\"\n        # 默认样式\n        if not style_str:\n            style_str = (\n                \"[V4+ Styles]\\n\"\n                \"Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,\"\n                \"Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,\"\n                \"Alignment,MarginL,MarginR,MarginV,Encoding\\n\"\n                \"Style: Default,微软雅黑,66,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,\"\n                \"0,0,1,2,0,2,10,10,10,1\\n\"\n                \"Style: Translate,微软雅黑,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,\"\n                \"0,0,1,2,0,2,10,10,10,1\"\n            )\n\n        # 构建ASS文件头\n        ass_content = (\n            \"[Script Info]\\n\"\n            \"; Script generated by VideoCaptioner\\n\"\n            \"; https://github.com/weifeng2333\\n\"\n            \"ScriptType: v4.00+\\n\"\n            \"PlayResX: 1280\\n\"\n            \"PlayResY: 720\\n\\n\"\n            f\"{style_str}\\n\\n\"\n            \"[Events]\\n\"\n            \"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\\n\"\n        )\n\n        # 根据布局生成对话内容\n        for seg in self.segments:\n            start_time = seg.to_ass_ts()[0]\n            end_time = seg.to_ass_ts()[1]\n            dialogue_template = 'Dialogue: 0,{},{},{},,0,0,0,,{}\\n'\n\n            # 检查是否有换行符分隔的原文和译文\n            if \"\\n\" in seg.text:\n                original, translate = seg.text.split(\"\\n\")\n                if layout == \"译文在上\" and translate:\n                    ass_content += dialogue_template.format(start_time, end_time, \"Secondary\", original)\n                    ass_content += dialogue_template.format(start_time, end_time, \"Default\", translate)\n                elif layout == \"原文在上\" and translate:\n                    ass_content += dialogue_template.format(start_time, end_time, \"Secondary\", translate)\n                    ass_content += dialogue_template.format(start_time, end_time, \"Default\", original)\n                elif layout == \"仅原文\":\n                    ass_content += dialogue_template.format(start_time, end_time, \"Default\", original)\n                elif layout == \"仅译文\" and translate:\n                    ass_content += dialogue_template.format(start_time, end_time, \"Default\", translate)\n            else:\n                original = seg.text\n                ass_content += dialogue_template.format(start_time, end_time, \"Default\", original)\n            # 根据布局生成对话行\n            \n        if save_path:\n            with open(save_path, 'w', encoding='utf-8') as f:\n                f.write(ass_content)\n        return ass_content\n\n    def merge_segments(self, start_index: int, end_index: int, merged_text: str = None):\n            \"\"\"合并从 start_index 到 end_index 的段（包含）。\"\"\"\n            if start_index < 0 or end_index >= len(self.segments) or start_index > end_index:\n                raise IndexError(\"无效的段索引。\")\n            merged_start_time = self.segments[start_index].start_time\n            merged_end_time = self.segments[end_index].end_time\n            if merged_text is None:\n                merged_text = ''.join(seg.text for seg in self.segments[start_index:end_index+1])\n            merged_seg = ASRDataSeg(merged_text, merged_start_time, merged_end_time)\n            # 替换 segments[start_index:end_index+1] 为 merged_seg\n            self.segments[start_index:end_index+1] = [merged_seg]\n\n    def merge_with_next_segment(self, index: int) -> None:\n        \"\"\"合并指定索引的段与下一个段。\"\"\"\n        if index < 0 or index >= len(self.segments) - 1:\n            raise IndexError(\"索引超出范围或没有下一个段可合并。\")\n        current_seg = self.segments[index]\n        next_seg = self.segments[index + 1]\n\n        # 合并文本\n        merged_text = f\"{current_seg.text} {next_seg.text}\"\n        merged_start_time = current_seg.start_time\n        merged_end_time = next_seg.end_time\n        merged_seg = ASRDataSeg(merged_text, merged_start_time, merged_end_time)\n\n        # 替换当前段为合并后的段\n        self.segments[index] = merged_seg\n        # 删除下一个段\n        del self.segments[index + 1]\n\n    def __str__(self):\n        return self.to_txt()\n\ndef from_subtitle_file(file_path: str) -> 'ASRData':\n    \"\"\"从文件路径加载ASRData实例\n    \n    Args:\n        file_path: 字幕文件路径，支持.srt、.vtt、.ass、.json格式\n        \n    Returns:\n        ASRData: 解析后的ASRData实例\n        \n    Raises:\n        ValueError: 不支持的文件格式或文件读取错误\n    \"\"\"\n    file_path = Path(file_path)\n    if not file_path.exists():\n        raise FileNotFoundError(f\"文件不存在: {file_path}\")\n        \n    try:\n        content = file_path.read_text(encoding='utf-8')\n    except UnicodeDecodeError:\n        content = file_path.read_text(encoding='gbk')\n        \n    suffix = file_path.suffix.lower()\n    \n    if suffix == '.srt':\n        return from_srt(content)\n    elif suffix == '.vtt':\n        if '<c>' in content:  # YouTube VTT格式包含字级时间戳\n            return from_youtube_vtt(content)\n        return from_vtt(content)\n    elif suffix == '.ass':\n        return from_ass(content)\n    elif suffix == '.json':\n        return from_json(json.loads(content))\n    else:\n        raise ValueError(f\"不支持的文件格式: {suffix}\")\n\ndef from_json(json_data: dict) -> 'ASRData':\n    \"\"\"从JSON数据创建ASRData实例\"\"\"\n    segments = []\n    for i in sorted(json_data.keys(), key=int):\n        segment_data = json_data[i]\n        text = segment_data['original_subtitle']\n        if segment_data['translated_subtitle']:\n            text += '\\n' + segment_data['translated_subtitle']\n        segment = ASRDataSeg(\n            text=text,\n            start_time=segment_data['start_time'],\n            end_time=segment_data['end_time']\n        )\n        segments.append(segment)\n    return ASRData(segments)\n\ndef from_srt(srt_str: str) -> 'ASRData':\n    \"\"\"\n    从SRT格式的字符串创建ASRData实例。\n\n    :param srt_str: 包含SRT格式字幕的字符串。\n    :return: 解析后的ASRData实例。\n    \"\"\"\n    segments = []\n    srt_time_pattern = re.compile(\n        r'(\\d{2}):(\\d{2}):(\\d{1,2})[.,](\\d{3})\\s-->\\s(\\d{2}):(\\d{2}):(\\d{1,2})[.,](\\d{3})'\n    )\n\n    for block in re.split(r'\\n\\s*\\n', srt_str.strip()):\n        lines = block.splitlines()\n        if len(lines) < 3:\n            continue\n\n        match = srt_time_pattern.match(lines[1])\n        if not match:\n            continue\n\n        time_parts = list(map(int, match.groups()))\n        start_time = sum([\n            time_parts[0] * 3600000,\n            time_parts[1] * 60000,\n            time_parts[2] * 1000,\n            time_parts[3]\n        ])\n        end_time = sum([\n            time_parts[4] * 3600000,\n            time_parts[5] * 60000,\n            time_parts[6] * 1000,\n            time_parts[7]\n        ])\n\n        text = '\\n'.join(lines[2:]).strip()\n        segments.append(ASRDataSeg(text, start_time, end_time))\n\n    return ASRData(segments)\n\ndef from_vtt(vtt_str: str) -> 'ASRData':\n    \"\"\"\n    从YouTube VTT格式的字符串创建ASRData实例。\n    \n    :param vtt_str: YouTube VTT格式的字幕字符串\n    :return: ASRData实例\n    \"\"\"\n    segments = []\n    # 跳过头部元数据\n    content = vtt_str.split('\\n\\n')[2:]\n    \n    current_text = \"\"\n    current_start = 0\n    current_end = 0\n    \n    for block in content:\n        lines = block.strip().split('\\n')\n        if not lines:\n            continue\n            \n        # 解析时间戳行\n        timestamp_line = lines[0]\n        if '-->' not in timestamp_line:\n            continue\n            \n        # 提取开始和结束时间\n        times = timestamp_line.split(' --> ')[0]\n        hours, minutes, seconds = times.split(':')\n        seconds, milliseconds = seconds.split('.')\n        start_time = (int(hours) * 3600 + int(minutes) * 60 + int(seconds)) * 1000 + int(milliseconds)\n        \n        times = timestamp_line.split(' --> ')[1].split()[0]\n        hours, minutes, seconds = times.split(':')\n        seconds, milliseconds = seconds.split('.')\n        end_time = (int(hours) * 3600 + int(minutes) * 60 + int(seconds)) * 1000 + int(milliseconds)\n        \n        # 提取并清文本内容\n        if len(lines) > 1:\n            text_line = lines[1]\n            # 移除时间戳和样式标记\n            cleaned_text = re.sub(r'<\\d{2}:\\d{2}:\\d{2}\\.\\d{3}>', '', text_line)\n            cleaned_text = re.sub(r'</?c>', '', cleaned_text)\n            cleaned_text = cleaned_text.strip()\n            \n            if cleaned_text and cleaned_text != \" \":\n                segments.append(ASRDataSeg(cleaned_text, start_time, end_time))\n    \n    return ASRData(segments)\n\ndef from_youtube_vtt(vtt_str: str) -> 'ASRData':\n    \"\"\"\n    从YouTube VTT格式的字符串创建ASRData实例，提取字级时间戳。\n    \n    :param vtt_str: 包含VTT格式字幕的字符串\n    :return: 解析后的ASRData实例\n    \"\"\"\n    def parse_timestamp(ts: str) -> int:\n        \"\"\"将时间戳字符串转换为毫秒\"\"\"\n        h, m, s = ts.split(':')\n        return int(float(h) * 3600000 + float(m) * 60000 + float(s) * 1000)\n    \n    def split_timestamped_text(text: str) -> List[ASRDataSeg]:\n        \"\"\"分离带时间戳的文本为单词段\"\"\"\n        # 匹配 <时间戳>文本 的模式\n        pattern = re.compile(r'<(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})>([^<]*)')\n        matches = list(pattern.finditer(text))\n        word_segments = []\n        \n        for i in range(len(matches) - 1):\n            current_match = matches[i]\n            next_match = matches[i + 1]\n            \n            start_time = parse_timestamp(current_match.group(1))\n            end_time = parse_timestamp(next_match.group(1))\n            word = current_match.group(2).strip()\n            \n            if word:  # 只有当文本不为空时才创建segment\n                word_segments.append(ASRDataSeg(word, start_time, end_time))\n        \n        return word_segments\n    \n    segments = []\n    # 跳过WEBVTT头部\n    blocks = re.split(r'\\n\\n+', vtt_str.strip())\n    \n    # 时间戳匹配模式\n    timestamp_pattern = re.compile(\n        r'(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{3})\\s*-->\\s*(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{3})'\n    )    \n    for block in blocks:\n        lines = block.strip().split('\\n')\n        if not lines:\n            continue\n            \n        # 匹配时间戳行\n        match = timestamp_pattern.match(lines[0])\n        if not match:\n            continue\n            \n        # 计算块的开始和结束时间\n        block_start_time = (\n            int(match.group(1)) * 3600000 +\n            int(match.group(2)) * 60000 +\n            float(match.group(3)) * 1000\n        )\n        block_end_time = (\n            int(match.group(4)) * 3600000 +\n            int(match.group(5)) * 60000 +\n            float(match.group(6)) * 1000\n        )\n        \n        # 获取文本内容\n        text = '\\n'.join(lines)\n        \n        timestamp_row = re.search(r'\\n(.*?<c>.*?</c>.*)', block)\n        if timestamp_row:\n            text = re.sub(r'<c>|</c>', '', timestamp_row.group(1))\n            block_start_time_string = f\"{match.group(1)}:{match.group(2)}:{match.group(3)}\"\n            block_end_time_string = f\"{match.group(4)}:{match.group(5)}:{match.group(6)}\"\n            text = f\"<{block_start_time_string}>{text}<{block_end_time_string}>\"\n            \n            # 分离每个带时间戳的单词\n            word_segments = split_timestamped_text(text)\n            segments.extend(word_segments)\n    \n    return ASRData(segments)\n\ndef from_ass(ass_str: str) -> 'ASRData':\n    \"\"\"\n    从ASS格式的字符串创建ASRData实例。\n    \n    :param ass_str: 包含ASS格式字幕的字符串\n    :return: ASRData实例\n    \"\"\"\n    segments = []\n    # ASS时间戳格式: H:MM:SS.cc\n    ass_time_pattern = re.compile(r'Dialogue: \\d+,(\\d+:\\d{2}:\\d{2}\\.\\d{2}),(\\d+:\\d{2}:\\d{2}\\.\\d{2}),(.*?),.*?,\\d+,\\d+,\\d+,.*?,(.*?)$')\n    \n    def parse_ass_time(time_str: str) -> int:\n        \"\"\"将ASS时间戳转换为毫秒\"\"\"\n        hours, minutes, seconds = time_str.split(':')\n        seconds, centiseconds = seconds.split('.')\n        return (int(hours) * 3600000 + \n                int(minutes) * 60000 + \n                int(seconds) * 1000 + \n                int(centiseconds) * 10)  # 厘秒转毫秒\n    \n    # 按行处理ASS文件\n    for line in ass_str.splitlines():\n        if line.startswith('Dialogue:'):\n            match = ass_time_pattern.match(line)\n            if match:\n                start_time = parse_ass_time(match.group(1))\n                end_time = parse_ass_time(match.group(2))\n                text = match.group(4)\n                \n                # 清理ASS格式标记\n                text = re.sub(r'\\{[^}]*\\}', '', text)  # 移除样式标记 {xxx}\n                text = text.replace('\\\\N', '\\n')  # 处理换行符\n                text = text.strip()\n                \n                if text:  # 只有当文本不为空时才创建segment\n                    segments.append(ASRDataSeg(text, start_time, end_time))\n    \n    return ASRData(segments)\n\nif __name__ == '__main__':\n    ass_style_str = \"\"\"[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\nStyle: Default,微软雅黑,62,&H0017f1be,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,1.0,0,1,0.8,0,2,10,10,10,1\nStyle: Secondary,微软雅黑,40,&H00ffffff,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0.0,0,1,0.0,0,2,10,10,10,1\"\"\"\n    # 测试\n    from pathlib import Path\n    # vtt_file_path = r\"E:\\GithubProject\\VideoCaptioner\\app\\work_dir\\Setting the record straight\\subtitle\\original_subtitle.en.vtt\"\n    # vtt_file_path = r\"E:\\GithubProject\\VideoCaptioner\\work_dir\\Wake up babe a dangerous new open-source AI model is here\\subtitle\\original.en.vtt\"\n    # asr_data = from_youtube_vtt(Path(vtt_file_path).read_text(encoding=\"utf-8\"))\n    srt_file_path = r\"E:\\GithubProject\\VideoCaptioner\\app\\work_dir\\低视力音乐助人者_mp4\\result_subtitle.srt\"\n    asr_data = from_srt(Path(srt_file_path).read_text(encoding=\"utf-8\"))\n\n    logging.info(\"%s\", asr_data.to_ass(style_str=ass_style_str, save_path=srt_file_path.replace(\".srt\", \".ass\")))\n    # pass\n    # asr_data = ASRData(seg)\n    # Uncomment to test different formats:\n    # print(asr_data.to_srt(save_path=vtt_file_path.replace(\".vtt\", \".srt\")))\n    # print(asr_data.to_lrc())\n    # print(asr_data.to_txt())\n    # print(asr_data.to_json())\n    # print(asr_data.to_json())\n\n\n\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/BaseASR.py",
    "content": "import json\nimport logging\nimport os\nimport zlib\nimport tempfile\nimport threading\n\nfrom .ASRData import ASRDataSeg, ASRData\n\n\nclass BaseASR:\n    SUPPORTED_SOUND_FORMAT = [\"flac\", \"m4a\", \"mp3\", \"wav\"]\n    CACHE_FILE = os.path.join(tempfile.gettempdir(), \"bk_asr\", \"asr_cache.json\")\n    _lock = threading.Lock()\n\n    def __init__(self, audio_path: [str, bytes], use_cache: bool = False):\n        self.audio_path = audio_path\n        self.file_binary = None\n\n        self.crc32_hex = None\n        self.use_cache = use_cache\n\n        self._set_data()\n\n        self.cache = self._load_cache()\n\n    def _load_cache(self):\n        if not self.use_cache:\n            return {}\n        os.makedirs(os.path.dirname(self.CACHE_FILE), exist_ok=True)\n        with self._lock:\n            if os.path.exists(self.CACHE_FILE):\n                try:\n                    with open(self.CACHE_FILE, 'r', encoding='utf-8') as f:\n                        cache = json.load(f)\n                        if isinstance(cache, dict):\n                            return cache\n                except (json.JSONDecodeError, IOError):\n                    return {}\n            return {}\n\n    def _save_cache(self):\n        if not self.use_cache:\n            return\n        with self._lock:\n            try:\n                with open(self.CACHE_FILE, 'w', encoding='utf-8') as f:\n                    json.dump(self.cache, f, ensure_ascii=False, indent=2)\n                if os.path.exists(self.CACHE_FILE) and os.path.getsize(self.CACHE_FILE) > 10 * 1024 * 1024:\n                    os.remove(self.CACHE_FILE)\n            except IOError as e:\n                logging.error(f\"Failed to save cache: {e}\")\n\n    def _set_data(self):\n        if isinstance(self.audio_path, bytes):\n            self.file_binary = self.audio_path\n        else:\n            ext = self.audio_path.split(\".\")[-1].lower()\n            assert ext in self.SUPPORTED_SOUND_FORMAT, f\"Unsupported sound format: {ext}\"\n            assert os.path.exists(self.audio_path), f\"File not found: {self.audio_path}\"\n            with open(self.audio_path, \"rb\") as f:\n                self.file_binary = f.read()\n        crc32_value = zlib.crc32(self.file_binary) & 0xFFFFFFFF\n        self.crc32_hex = format(crc32_value, '08x')\n\n    def _get_key(self):\n        return f\"{self.__class__.__name__}-{self.crc32_hex}\"\n\n    def run(self):\n        k = self._get_key()\n        if k in self.cache and self.use_cache:\n            resp_data = self.cache[k]\n        else:\n            resp_data = self._run()\n            # Cache the result\n            self.cache[k] = resp_data\n            self._save_cache()\n        segments = self._make_segments(resp_data)\n        return ASRData(segments)\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        raise NotImplementedError(\"_make_segments method must be implemented in subclass\")\n\n    def _run(self) -> dict:\n        \"\"\" Run the ASR service and return the response data. \"\"\"\n        raise NotImplementedError(\"_run method must be implemented in subclass\")\n\n\n\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/BcutASR.py",
    "content": "import json\nimport logging\nimport time\nfrom os import PathLike\nfrom typing import Optional\n\nimport requests\n\nfrom .ASRData import ASRData, ASRDataSeg\nfrom .BaseASR import BaseASR\n\n\n__version__ = \"0.0.3\"\n\nAPI_BASE_URL = \"https://member.bilibili.com/x/bcut/rubick-interface\"\n\n# 申请上传\nAPI_REQ_UPLOAD = API_BASE_URL + \"/resource/create\"\n\n# 提交上传\nAPI_COMMIT_UPLOAD = API_BASE_URL + \"/resource/create/complete\"\n\n# 创建任务\nAPI_CREATE_TASK = API_BASE_URL + \"/task\"\n\n# 查询结果\nAPI_QUERY_RESULT = API_BASE_URL + \"/task/result\"\n\n\nclass BcutASR(BaseASR):\n    \"\"\"必剪 语音识别接口\"\"\"\n    headers = {\n        'User-Agent': 'Bilibili/1.0.0 (https://www.bilibili.com)',\n        'Content-Type': 'application/json'\n    }\n\n    def __init__(self, audio_path: [str, bytes], use_cache: bool = False):\n        super().__init__(audio_path, use_cache=use_cache)\n        self.session = requests.Session()\n        self.task_id = None\n        self.__etags = []\n\n        self.__in_boss_key: Optional[str, None] = None\n        self.__resource_id: Optional[str, None] = None\n        self.__upload_id: Optional[str, None] = None\n        self.__upload_urls: Optional[list[str]] = []\n        self.__per_size: Optional[int, None] = None\n        self.__clips: Optional[int, None] = None\n\n        self.__etags: Optional[list[str]] = []\n        self.__download_url: Optional[str, None] = None\n        self.task_id: Optional[str, None] = None\n\n\n    def upload(self) -> None:\n        \"\"\"申请上传\"\"\"\n        if not self.file_binary:\n            raise ValueError(\"none set data\")\n        payload = json.dumps({\n            \"type\": 2,\n            \"name\": \"audio.mp3\",\n            \"size\": len(self.file_binary),\n            \"ResourceFileType\": \"mp3\",\n            \"model_id\": \"8\",\n        })\n\n        resp = requests.post(\n            API_REQ_UPLOAD,\n            data=payload,\n            headers=self.headers\n        )\n        resp.raise_for_status()\n        resp = resp.json()\n        resp_data = resp[\"data\"]\n\n        self.__in_boss_key = resp_data[\"in_boss_key\"]\n        self.__resource_id = resp_data[\"resource_id\"]\n        self.__upload_id = resp_data[\"upload_id\"]\n        self.__upload_urls = resp_data[\"upload_urls\"]\n        self.__per_size = resp_data[\"per_size\"]\n        self.__clips = len(resp_data[\"upload_urls\"])\n\n        logging.info(\n            f\"申请上传成功, 总计大小{resp_data['size'] // 1024}KB, {self.__clips}分片, 分片大小{resp_data['per_size'] // 1024}KB: {self.__in_boss_key}\"\n        )\n        self.__upload_part()\n        self.__commit_upload()\n\n    def __upload_part(self) -> None:\n        \"\"\"上传音频数据\"\"\"\n        for clip in range(self.__clips):\n            start_range = clip * self.__per_size\n            end_range = (clip + 1) * self.__per_size\n            logging.info(f\"开始上传分片{clip}: {start_range}-{end_range}\")\n            resp = requests.put(\n                self.__upload_urls[clip],\n                data=self.file_binary[start_range:end_range],\n                headers=self.headers\n            )\n            resp.raise_for_status()\n            etag = resp.headers.get(\"Etag\")\n            self.__etags.append(etag)\n            logging.info(f\"分片{clip}上传成功: {etag}\")\n\n    def __commit_upload(self) -> None:\n        \"\"\"提交上传数据\"\"\"\n        data = json.dumps({\n            \"InBossKey\": self.__in_boss_key,\n            \"ResourceId\": self.__resource_id,\n            \"Etags\": \",\".join(self.__etags),\n            \"UploadId\": self.__upload_id,\n            \"model_id\": \"8\",\n        })\n        resp = requests.post(\n            API_COMMIT_UPLOAD,\n            data=data,\n            headers=self.headers\n        )\n        resp.raise_for_status()\n        resp = resp.json()\n        self.__download_url = resp[\"data\"][\"download_url\"]\n        logging.info(f\"提交成功\")\n\n    def create_task(self) -> str:\n        \"\"\"开始创建转换任务\"\"\"\n        resp = requests.post(\n            API_CREATE_TASK, json={\"resource\": self.__download_url, \"model_id\": \"8\"}, headers=self.headers\n        )\n        resp.raise_for_status()\n        resp = resp.json()\n        self.task_id = resp[\"data\"][\"task_id\"]\n        logging.info(f\"任务已创建: {self.task_id}\")\n        return self.task_id\n\n    def result(self, task_id: Optional[str] = None):\n        \"\"\"查询转换结果\"\"\"\n        resp = requests.get(API_QUERY_RESULT, params={\"model_id\": 7, \"task_id\": task_id or self.task_id}, headers=self.headers)\n        resp.raise_for_status()\n        resp = resp.json()\n        return resp[\"data\"]\n\n    def _run(self):\n        self.upload()\n        self.create_task()\n        # 轮询检查任务状态\n        for _ in range(500):\n            task_resp = self.result()\n            if task_resp[\"state\"] == 4:\n                break\n            time.sleep(1)\n        logging.info(f\"转换成功\")\n        return json.loads(task_resp[\"result\"])\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        return [ASRDataSeg(u['transcript'], u['start_time'], u['end_time']) for u in resp_data['utterances']]\n\n\nif __name__ == '__main__':\n    logging.basicConfig(level=logging.INFO, format=\"%(asctime)s - %(levelname)s - %(message)s\")\n    # Example usage\n    audio_file = r\"test.mp3\"\n    asr = BcutASR(audio_file)\n    asr_data = asr.run()\n    logging.info(\"%s\", asr_data)\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/JianYingASR.py",
    "content": "import datetime\nimport hashlib\nimport hmac\nimport json\nimport os\nimport time\nimport uuid\nfrom typing import Dict, Tuple, Union\n\nimport requests\n\nfrom .ASRData import ASRDataSeg\nfrom .BaseASR import BaseASR\n\n\n# from ASRData import ASRDataSeg\n# from BaseASR import BaseASR\n\nclass JianYingASR(BaseASR):\n    def __init__(self, audio_path: Union[str, bytes], use_cache: bool = False, need_word_time_stamp: bool = False,\n                 start_time: float = 0, end_time: float = 6000):\n        super().__init__(audio_path, use_cache)\n        self.audio_path = audio_path\n        self.end_time = end_time\n        self.start_time = start_time\n\n        # AWS credentials\n        self.session_token = None\n        self.secret_key = None\n        self.access_key = None\n\n        # Upload details\n        self.store_uri = None\n        self.auth = None\n        self.upload_id = None\n        self.session_key = None\n        self.upload_hosts = None\n\n        self.need_word_time_stamp = need_word_time_stamp\n        self.tdid = \"3943278516897751\" if datetime.datetime.now().year != 2024 else f\"{uuid.getnode():012d}\"\n\n    def submit(self) -> str:\n        \"\"\"Submit the task\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/audio_subtitle/submit\"\n        payload = {\n            \"adjust_endtime\": 200,\n            \"audio\": self.store_uri,\n            \"caption_type\": 2,\n            \"client_request_id\": \"45faf98c-160f-4fae-a649-6d89b0fe35be\",\n            \"max_lines\": 1,\n            \"songs_info\": [{\"end_time\": self.end_time, \"id\": \"\", \"start_time\": self.start_time}],\n            \"words_per_line\": 16\n        }\n\n        sign, device_time = self._generate_sign_parameters(url='/lv/v1/audio_subtitle/submit', pf='4', appvr='6.6.0',\n                                                           tdid=self.tdid)\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, json=payload, headers=headers)\n\n        resp_data = response.json()\n\n        if resp_data.get('ret') != '0':\n            error_msg = f\"API Error: {resp_data.get('errmsg', 'Unknown error')} (ret: {resp_data.get('ret')})\"\n            raise ValueError(error_msg)\n\n        query_id = resp_data['data']['id']\n        return query_id\n\n    def upload(self):\n        \"\"\"Upload the file\"\"\"\n        self._upload_sign()\n        self._upload_auth()\n        self._upload_file()\n        self._upload_check()\n        uri = self._upload_commit()\n        return uri\n\n    def query(self, query_id: str):\n        \"\"\"Query the task\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/audio_subtitle/query\"\n        payload = {\n            \"id\": query_id,\n            \"pack_options\": {\"need_attribute\": True}\n        }\n        sign, device_time = self._generate_sign_parameters(url='/lv/v1/audio_subtitle/query', pf='4', appvr='6.6.0',\n                                                           tdid=self.tdid)\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, json=payload, headers=headers)\n        resp_data = response.json()\n\n        if resp_data.get('ret') != '0':\n            error_msg = f\"API Error: {resp_data.get('errmsg', 'Unknown error')} (ret: {resp_data.get('ret')})\"\n\n            raise ValueError(error_msg)\n\n        return resp_data\n\n\n    def _run(self, callback=None):\n        # logging.info(\"正在上传文件...\")\n        if callback:\n            callback(20, \"正在上传...\")\n        self.upload()\n        if callback:\n            callback(50, \"提交任务...\")\n        query_id = self.submit()\n        if callback:\n            callback(60, \"获取结果...\")\n        resp_data = self.query(query_id)\n        if callback:\n            callback(100, \"转录完成\")\n        return resp_data\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        if self.need_word_time_stamp:\n            return [ASRDataSeg(w['text'].strip(), w['start_time'], w['end_time']) for u in\n                    resp_data['data']['utterances'] for w in u['words']]\n        else:\n            return [ASRDataSeg(u['text'], u['start_time'], u['end_time']) for u in resp_data['data']['utterances']]\n\n    def _get_key(self):\n        return f\"{self.__class__.__name__}-{self.crc32_hex}-{self.need_word_time_stamp}\"\n\n    def _generate_sign_parameters(self, url: str, pf: str = '4', appvr: str = '6.6.0', tdid='') -> \\\n            Tuple[str, str]:\n        \"\"\"Generate signature and timestamp via an HTTP request\"\"\"\n        current_time = str(int(time.time()))\n        data = {\n            'url': url,\n            'current_time': current_time,\n            'pf': pf,\n            'appvr': appvr,\n            'tdid': self.tdid\n        }\n        # Replace with your actual endpoint URL\n        get_sign_url = 'https://asrtools-update.bkfeng.top/sign'\n        try:\n            response = requests.post(get_sign_url, json=data)\n            response.raise_for_status()\n            response_data = response.json()\n            sign = response_data.get('sign')\n            if not sign:\n                raise ValueError(\"No 'sign' in response\")\n        except requests.exceptions.RequestException as e:\n            raise SystemExit(f\"HTTP Request failed: {e}\")\n        except ValueError as ve:\n            raise SystemExit(f\"Invalid response: {ve}\")\n        return sign.lower(), current_time\n\n    def _build_headers(self, device_time: str, sign: str) -> Dict[str, str]:\n        \"\"\"Build headers for requests\"\"\"\n        return {\n            'User-Agent': \"Cronet/TTNetVersion:d4572e53 2024-06-12 QuicVersion:4bf243e0 2023-04-17\",\n            'appvr': \"6.6.0\",\n            'device-time': str(device_time),\n            'pf': \"4\",\n            'sign': sign,\n            'sign-ver': \"1\",\n            'tdid': self.tdid,\n        }\n\n    def _uplosd_headers(self):\n        headers = {\n            'User-Agent': \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 Thea/1.0.1\",\n            'Authorization': self.auth,\n            'Content-CRC32': self.crc32_hex,\n        }\n        return headers\n\n    def _upload_sign(self):\n        \"\"\"Get upload sign\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/upload_sign\"\n        payload = json.dumps({\"biz\": \"pc-recognition\"})\n        sign, device_time = self._generate_sign_parameters(url='/lv/v1/upload_sign', pf='4', appvr='6.6.0',\n                                                           tdid=self.tdid)\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, data=payload, headers=headers)\n        response.raise_for_status()\n        login_data = response.json()\n        self.access_key = login_data['data']['access_key_id']\n        self.secret_key = login_data['data']['secret_access_key']\n        self.session_token = login_data['data']['session_token']\n        return self.access_key, self.secret_key, self.session_token\n\n    def _upload_auth(self):\n        \"\"\"Get upload authorization\"\"\"\n        if isinstance(self.audio_path, bytes):\n            file_size = len(self.audio_path)\n        else:\n            file_size = os.path.getsize(self.audio_path)\n        request_parameters = f'Action=ApplyUploadInner&FileSize={file_size}&FileType=object&IsInner=1&SpaceName=lv-mac-recognition&Version=2020-11-19&s=5y0udbjapi'\n\n        t = datetime.datetime.utcnow()\n        amz_date = t.strftime('%Y%m%dT%H%M%SZ')\n        datestamp = t.strftime('%Y%m%d')\n        headers = {\n            \"x-amz-date\": amz_date,\n            \"x-amz-security-token\": self.session_token\n        }\n        signature = aws_signature(self.secret_key, request_parameters, headers, region=\"cn\", service=\"vod\")\n        authorization = f\"AWS4-HMAC-SHA256 Credential={self.access_key}/{datestamp}/cn/vod/aws4_request, SignedHeaders=x-amz-date;x-amz-security-token, Signature={signature}\"\n        headers[\"authorization\"] = authorization\n        response = requests.get(f\"https://vod.bytedanceapi.com/?{request_parameters}\", headers=headers)\n        store_infos = response.json()\n\n        self.store_uri = store_infos['Result']['UploadAddress']['StoreInfos'][0]['StoreUri']\n        self.auth = store_infos['Result']['UploadAddress']['StoreInfos'][0]['Auth']\n        self.upload_id = store_infos['Result']['UploadAddress']['StoreInfos'][0]['UploadID']\n        self.session_key = store_infos['Result']['UploadAddress']['SessionKey']\n        self.upload_hosts = store_infos['Result']['UploadAddress']['UploadHosts'][0]\n        self.store_uri = store_infos['Result']['UploadAddress']['StoreInfos'][0]['StoreUri']\n        return store_infos\n\n    def _upload_file(self):\n        \"\"\"Upload the file\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?partNumber=1&uploadID={self.upload_id}\"\n        headers = self._uplosd_headers()\n        response = requests.put(url, data=self.file_binary, headers=headers)\n        resp_data = response.json()\n        assert resp_data['success'] == 0, f\"File upload failed: {response.text}\"\n        return resp_data\n\n    def _upload_check(self):\n        \"\"\"Check upload result\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?uploadID={self.upload_id}\"\n        payload = f\"1:{self.crc32_hex}\"\n        headers = self._uplosd_headers()\n        response = requests.post(url, data=payload, headers=headers)\n        resp_data = response.json()\n        return resp_data\n\n    def _upload_commit(self):\n        \"\"\"Commit the uploaded file\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?uploadID={self.upload_id}&partNumber=1&x-amz-security-token={self.session_token}\"\n        headers = self._uplosd_headers()\n        response = requests.put(url, data=self.file_binary, headers=headers)\n        return self.store_uri\n\n\ndef sign(key: bytes, msg: str) -> bytes:\n    \"\"\"使用HMAC-SHA256生成签名\"\"\"\n    return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()\n\n\ndef get_signature_key(secret_key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:\n    \"\"\"生成用于AWS签名的密钥\"\"\"\n    k_date = sign(('AWS4' + secret_key).encode('utf-8'), date_stamp)\n    k_region = sign(k_date, region_name)\n    k_service = sign(k_region, service_name)\n    k_signing = sign(k_service, 'aws4_request')\n    return k_signing\n\n\ndef aws_signature(secret_key: str, request_parameters: str, headers: Dict[str, str],\n                  method: str = \"GET\", payload: str = '', region: str = \"cn\", service: str = \"vod\") -> str:\n    \"\"\"生成AWS签名\"\"\"\n    canonical_uri = '/'\n    canonical_querystring = request_parameters\n    canonical_headers = '\\n'.join([f\"{key}:{value}\" for key, value in headers.items()]) + '\\n'\n    signed_headers = ';'.join(headers.keys())\n    payload_hash = hashlib.sha256(payload.encode('utf-8')).hexdigest()\n    canonical_request = f\"{method}\\n{canonical_uri}\\n{canonical_querystring}\\n{canonical_headers}\\n{signed_headers}\\n{payload_hash}\"\n\n    amzdate = headers[\"x-amz-date\"]\n    datestamp = amzdate.split('T')[0]\n\n    algorithm = 'AWS4-HMAC-SHA256'\n    credential_scope = f\"{datestamp}/{region}/{service}/aws4_request\"\n    string_to_sign = f\"{algorithm}\\n{amzdate}\\n{credential_scope}\\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}\"\n\n    signing_key = get_signature_key(secret_key, datestamp, region, service)\n    signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()\n    return signature\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/KuaiShouASR.py",
    "content": "import requests\n\nfrom .ASRData import ASRDataSeg\nfrom .BaseASR import BaseASR\n\n\nclass KuaiShouASR(BaseASR):\n    def __init__(self, audio_path: [str, bytes], use_cache: bool = False):\n        super().__init__(audio_path, use_cache)\n\n    def _run(self) -> dict:\n        return self._submit()\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        return [ASRDataSeg(u['text'], u['start_time'], u['end_time']) for u in resp_data['data']['text']]\n\n    def _submit(self) -> dict:\n        payload = {\n            \"typeId\": \"1\"\n        }\n        files = [('file', ('test.mp3', self.file_binary, 'audio/mpeg'))]\n        result = requests.post(\"https://ai.kuaishou.com/api/effects/subtitle_generate\", data=payload, files=files)\n        return result.json()\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/WhisperASR.py",
    "content": "import os\n\nfrom openai import OpenAI\n\nfrom .ASRData import ASRDataSeg\nfrom .BaseASR import BaseASR\n\n\n\nclass WhisperASR(BaseASR):\n    def __init__(self, audio_path: [str, bytes], model: str = MODEL, use_cache: bool = False):\n        super().__init__(audio_path, use_cache)\n        self.base_url = os.getenv('OPENAI_BASE_URL')\n        self.api_key = os.getenv('OPENAI_API_KEY')\n        if not self.base_url or not self.api_key:\n            raise ValueError(\"环境变量 OPENAI_BASE_URL 和 OPENAI_API_KEY 必须设置\")\n        self.model = model\n        self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)\n\n    def _run(self) -> dict:\n        return self._submit()\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        return [ASRDataSeg(u['text'], u['start'], u['end']) for u in resp_data['segments']]\n\n    def _get_key(self) -> str:\n        return f\"{self.__class__.__name__}-{self.model}-{self.crc32_hex}-{self.model}\"\n\n    def _submit(self) -> dict:\n        completion = self.client.audio.transcriptions.create(\n            model=self.model,\n            temperature=0,\n            response_format=\"verbose_json\",\n            file=(\"test.mp3\", self.file_binary, \"audio/mp3\"),\n            prompt=\"\",\n            language=\"zh\"\n        )\n        return completion.to_dict()\n\n\n"
  },
  {
    "path": "SonicVale/app/core/subtitle/__init__.py",
    "content": ""
  },
  {
    "path": "SonicVale/app/core/subtitle/subtitle_engine.py",
    "content": "from app.core.subtitle.BcutASR import BcutASR\nfrom app.core.subtitle.JianYingASR import JianYingASR\nfrom app.core.prompts import get_subtitle_correction_prompt\nfrom app.core.llm_engine import LLMEngine\n\n\ndef generate_subtitle(audio_file,save_path):\n    # asr = JianYingASR(audio_file)\n    asr = BcutASR(audio_file)\n    result = asr.run()\n    result.to_srt(save_path)\n    return result\n\n\n\n# 字幕矫正\nimport re\nimport difflib\nimport shutil\nimport logging\nfrom pypinyin import lazy_pinyin\n\n# -------------------- 基础工具 --------------------\n\ndef is_same_char(c1: str, c2: str) -> bool:\n    \"\"\"字面相同或拼音相同（处理同音字）\"\"\"\n    if c1 == c2:\n        return True\n    return lazy_pinyin(c1) == lazy_pinyin(c2)\n\ndef correct_text_with_pinyin(original: str, recognized: str) -> str:\n    \"\"\"全局文本纠正：把 recognized 纠正到 original\"\"\"\n    sm = difflib.SequenceMatcher(None, recognized, original, autojunk=False)\n    out = []\n    for tag, i1, i2, j1, j2 in sm.get_opcodes():\n        if tag == \"equal\":\n            out.append(recognized[i1:i2])\n        else:\n            r = recognized[i1:i2]\n            o = original[j1:j2]\n            seg = []\n            L = max(len(r), len(o))\n            for k in range(L):\n                c1 = r[k] if k < len(r) else \"\"\n                c2 = o[k] if k < len(o) else \"\"\n                if c1 and c2 and is_same_char(c1, c2):\n                    seg.append(c2)\n                elif c2:\n                    seg.append(c2)\n            out.append(\"\".join(seg))\n    return \"\".join(out)\n\n# -------------------- SRT 读写 --------------------\n\nSRT_BLOCK = re.compile(\n    r\"(\\d+)\\s+([\\d:,]+ --> [\\d:,]+)\\s+([\\s\\S]*?)(?=\\n\\n|\\Z)\", re.MULTILINE\n)\n\ndef read_srt(path: str):\n    with open(path, \"r\", encoding=\"utf-8\") as f:\n        content = f.read()\n    blocks = SRT_BLOCK.findall(content)\n    entries = []\n    for idx, ts, txt in blocks:\n        text_raw = txt.strip(\"\\r\\n\")\n        entries.append((int(idx), ts, text_raw))\n    return entries\n\ndef write_srt(path: str, entries):\n    with open(path, \"w\", encoding=\"utf-8\") as f:\n        for idx, ts, text in entries:\n            f.write(f\"{idx}\\n{ts}\\n{text}\\n\\n\")\n\n# -------------------- 对齐切分 --------------------\n\ndef flatten_for_align(text: str) -> str:\n    return text.replace(\"\\r\", \"\").replace(\"\\n\", \"\")\n\ndef segment_corrected_by_recognized_boundaries(recognized_full: str,\n                                               corrected_full: str,\n                                               line_lengths: list[int]):\n    boundaries = [0]\n    acc = 0\n    for L in line_lengths:\n        acc += L\n        boundaries.append(acc)\n\n    sm = difflib.SequenceMatcher(None, recognized_full, corrected_full, autojunk=False)\n    ops = sm.get_opcodes()\n\n    out_lines, buf = [], []\n    r_pos, next_bi = 0, 1\n    next_boundary = boundaries[next_bi] if next_bi < len(boundaries) else len(recognized_full)\n\n    def flush_line():\n        nonlocal buf, out_lines, next_bi, next_boundary\n        out_lines.append(\"\".join(buf))\n        buf = []\n        next_bi += 1\n        next_boundary = boundaries[next_bi] if next_bi < len(boundaries) else boundaries[-1]\n\n    for tag, i1, i2, j1, j2 in ops:\n        if tag in (\"equal\", \"replace\"):\n            while r_pos < i2:\n                take = min(i2 - r_pos, next_boundary - r_pos)\n                recog_len_total = i2 - i1\n                corr_len_total  = j2 - j1\n                start_ratio = (r_pos - i1) / max(1, recog_len_total)\n                end_ratio   = (r_pos + take - i1) / max(1, recog_len_total)\n                cj_start = j1 + round(start_ratio * corr_len_total)\n                cj_end   = j1 + round(end_ratio   * corr_len_total)\n                if cj_start < cj_end:\n                    buf.append(corrected_full[cj_start:cj_end])\n                r_pos += take\n                if r_pos == next_boundary:\n                    flush_line()\n\n        elif tag == \"delete\":\n            while r_pos < i2:\n                take = min(i2 - r_pos, next_boundary - r_pos)\n                r_pos += take\n                if r_pos == next_boundary:\n                    flush_line()\n\n        elif tag == \"insert\":\n            buf.append(corrected_full[j1:j2])\n\n    if buf:\n        while len(out_lines) < len(line_lengths) - 1:\n            out_lines.append(\"\")\n        out_lines.append(\"\".join(buf))\n\n    if len(out_lines) < len(line_lengths):\n        out_lines += [\"\"] * (len(line_lengths) - len(out_lines))\n    elif len(out_lines) > len(line_lengths):\n        extra = \"\".join(out_lines[len(line_lengths)-1:])\n        out_lines = out_lines[:len(line_lengths)-1] + [extra]\n\n    cleaned = []\n    for line in out_lines:\n        # line = re.sub(r\"\\s+\", \"\", line)\n        # line = re.sub(r'^(…{1,2}|\\.{3,}|[，。！？；：、”])+', '', line)\n        # line = re.sub(r'(…{1,2}|\\.{3,}|[，。！？；：、“])+$', '', line)\n        # 同时匹配中英文符号\n        line = re.sub(r\"\\s+\", \"\", line)\n        line = re.sub(r'^(…{1,2}|\\.{3,}|[，,。.!！？?；;：:、”“\"“])+', '', line)\n        line = re.sub(r'(…{1,2}|\\.{3,}|[，,。.!！？?；;：:、”“\"“])+$', '', line)\n\n        cleaned.append(line)\n\n    return cleaned\n\n# -------------------- 外部调用 --------------------\n\ndef correct_srt_file(original_text: str, srt_path: str,\n                     overwrite: bool = True, backup: bool = False,\n                     out_path: str = None):\n    \"\"\"\n    original_text: 原始完整文本（直接传字符串）\n    srt_path: 输入字幕文件路径\n    overwrite: 是否覆盖原文件（默认 True）\n    backup: 覆盖时是否先生成 .bak 文件（默认 True）\n    out_path: 如果不覆盖，可以指定输出文件路径\n    \"\"\"\n    original_full = original_text.replace(\"\\r\", \"\").replace(\"\\n\", \"\").strip()\n    entries = read_srt(srt_path)\n\n    recognized_lines = [flatten_for_align(txt) for _, _, txt in entries]\n    recognized_full = \"\".join(recognized_lines)\n\n    corrected_full = correct_text_with_pinyin(original_full, recognized_full)\n\n    line_lengths = [len(s) for s in recognized_lines]\n    corrected_lines = segment_corrected_by_recognized_boundaries(\n        recognized_full, corrected_full, line_lengths\n    )\n\n    corrected_entries = []\n    for (idx, ts, _), line_text in zip(entries, corrected_lines):\n        corrected_entries.append((idx, ts, line_text))\n\n    # 目标路径\n    if overwrite:\n        if backup:\n            shutil.copy(srt_path, srt_path + \".bak\")\n            logging.info(\"已生成备份文件：%s.bak\", srt_path)\n        target_path = srt_path\n    else:\n        target_path = out_path or (srt_path + \".corrected.srt\")\n\n    write_srt(target_path, corrected_entries)\n    logging.info(\"已生成 %s （逐行对齐修正完成）\", target_path)\n\nif __name__ == '__main__':\n    generate_subtitle(\"C:\\\\Users\\\\lxc18\\\\SonicVale\\\\1\\\\1\\\\audio\\\\id_2.wav\",\"C:\\\\Users\\\\lxc18\\\\SonicVale\\\\1\\\\1\\\\audio\\\\id_1.srt\")\n\n\n# -------------------- LLM 字幕矫正 --------------------\n\ndef correct_srt_file_with_llm(\n    original_text: str,\n    srt_path: str,\n    llm_engine: LLMEngine,\n    batch_size: int = 20,\n    overwrite: bool = True,\n    backup: bool = False,\n    out_path: str = None\n):\n    \"\"\"\n    使用LLM进行字幕矫正，分批处理\n    \n    original_text: 原始完整文本\n    srt_path: 输入字幕文件路径\n    llm_engine: LLM引擎实例\n    batch_size: 每批处理的字幕条数（默认20条）\n    overwrite: 是否覆盖原文件\n    backup: 覆盖时是否生成.bak文件\n    out_path: 如果不覆盖，可以指定输出文件路径\n    \"\"\"\n    original_full = original_text.replace(\"\\r\", \"\").replace(\"\\n\", \"\").strip()\n    entries = read_srt(srt_path)\n    \n    if not entries:\n        logging.warning(\"字幕文件为空：%s\", srt_path)\n        return\n    \n    # 分批处理\n    corrected_entries = []\n    total_batches = (len(entries) + batch_size - 1) // batch_size\n    \n    for batch_idx in range(total_batches):\n        start_idx = batch_idx * batch_size\n        end_idx = min(start_idx + batch_size, len(entries))\n        batch_entries = entries[start_idx:end_idx]\n        \n        logging.info(\"处理字幕批次 %d/%d (第%d-%d条)\", \n                     batch_idx + 1, total_batches, start_idx + 1, end_idx)\n        \n        # 准备当前批次的字幕数据\n        subtitle_lines = [\n            {\"index\": idx, \"text\": txt.replace(\"\\n\", \" \").replace('\"', '\\\\\"')}\n            for idx, ts, txt in batch_entries\n        ]\n        \n        # 调用LLM进行矫正\n        prompt = get_subtitle_correction_prompt(original_full, subtitle_lines)\n        \n        try:\n            response = llm_engine.generate_text(prompt)\n            corrected_batch = llm_engine.save_load_json(response)\n            \n            # 构建索引映射\n            corrected_map = {item[\"index\"]: item[\"corrected_text\"] for item in corrected_batch}\n            \n            # 处理当前批次的结果\n            for idx, ts, original_txt in batch_entries:\n                corrected_text = corrected_map.get(idx, original_txt)\n                # 清理文本\n                corrected_text = clean_subtitle_text(corrected_text)\n                corrected_entries.append((idx, ts, corrected_text))\n                \n        except Exception as e:\n            logging.error(\"批次 %d 矫正失败，使用原始文本: %s\", batch_idx + 1, str(e))\n            # 失败时保留原始文本\n            for idx, ts, txt in batch_entries:\n                corrected_entries.append((idx, ts, txt))\n    \n    # 确定目标路径\n    if overwrite:\n        if backup:\n            shutil.copy(srt_path, srt_path + \".bak\")\n            logging.info(\"已生成备份文件：%s.bak\", srt_path)\n        target_path = srt_path\n    else:\n        target_path = out_path or (srt_path + \".corrected.srt\")\n    \n    write_srt(target_path, corrected_entries)\n    logging.info(\"已生成 %s （LLM字幕矫正完成）\", target_path)\n\n\ndef clean_subtitle_text(text: str) -> str:\n    \"\"\"清理字幕文本\"\"\"\n    # 去除空白字符\n    text = re.sub(r\"\\s+\", \"\", text)\n    # 清理首尾标点\n    text = re.sub(r'^(…{1,2}|\\.{3,}|[，,。.!！？?;；:：、\"“”\"\"])+', '', text)\n    text = re.sub(r'(…{1,2}|\\.{3,}|[，,。.!！？?;；:：、\"“”\"\"])+$', '', text)\n    return text\n"
  },
  {
    "path": "SonicVale/app/core/text_correct_engine.py",
    "content": "import re\nimport json\nimport difflib\nimport logging\nfrom typing import List, Dict, Tuple, Optional\n\n\nclass TextCorrectorFinal:\n    # 默认配置参数\n    DEFAULT_BASE_THRESHOLD = 0.65  # 基础相似度阈值\n    DEFAULT_BASE_WINDOW = 30  # 基础搜索窗口\n    DEFAULT_EXTENDED_WINDOW = 80  # 扩展搜索窗口（匹配失败时使用）\n\n    def __init__(self, base_threshold: float = None, base_window: int = None):\n        \"\"\"初始化文本校正器\n        \n        Args:\n            base_threshold: 基础相似度阈值，默认0.65\n            base_window: 基础搜索窗口大小，默认30\n        \"\"\"\n        self.base_threshold = base_threshold or self.DEFAULT_BASE_THRESHOLD\n        self.base_window = base_window or self.DEFAULT_BASE_WINDOW\n        self.extended_window = self.DEFAULT_EXTENDED_WINDOW\n\n    def clean_text(self, text: str) -> str:\n        \"\"\"清理文本用于最终输出，移除换行符和全角空格，保留引号。\"\"\"\n        text = re.sub(r'[\\n\\r\\u3000]', '', text)\n        text = re.sub(r'\\s+', ' ', text)\n        return text.strip()\n\n    def clean_for_compare(self, text: str) -> str:\n        \"\"\"清理文本用于相似度比较，移除换行符、全角空格和引号。\"\"\"\n        text = re.sub(r'[\\n\\r\\u3000]', '', text)\n        text = re.sub(r'[\"\"\"「」『』]', '', text)  # 仅在比较时移除引号\n        text = re.sub(r'\\s+', ' ', text)\n        return text.strip()\n\n    def get_adaptive_threshold(self, sentence: str) -> float:\n        \"\"\"根据句子长度自适应调整相似度阈值。\n        \n        短句子容易误匹配，需要更高阈值；长句子可以适当降低阈值。\n        \"\"\"\n        length = len(self.clean_for_compare(sentence))\n        \n        if length <= 5:\n            # 非常短的句子，需要很高的阈值防止误匹配\n            return max(self.base_threshold, 0.85)\n        elif length <= 10:\n            # 短句子\n            return max(self.base_threshold, 0.75)\n        elif length <= 20:\n            # 中等长度\n            return self.base_threshold\n        else:\n            # 长句子可以适当降低阈值\n            return max(self.base_threshold - 0.05, 0.55)\n\n    def _looks_like_abbreviation(self, sentence_with_dot: str) -> bool:\n        \"\"\"\n        判断当前这一个 '.' 更像是缩写的一部分，而不是句子结束。\n        sentence_with_dot: 当前已经累积的句子（包含这个点）\n        \"\"\"\n        s = sentence_with_dot.rstrip()\n        # 找到以 . 结尾的最后一个 token（字母/数字/点）\n        m = re.search(r'([A-Za-z0-9\\.]+)\\.$', s)\n        if not m:\n            return False\n\n        token = m.group(1)  # 不包含最后这个点，但可能包含内部的 .\n\n        # 1) 小写长度很短的缩写，例如 Mr. Dr. etc.\n        #    这里简单认为：1~4 个字母，首字母大写\n        if re.fullmatch(r'[A-Za-z]{1,4}', token) and token[0].isupper():\n            return True\n\n        # 2) 多点缩写：U.S.A / F.B.I 这种（至少 3 个字母、2 个点）\n        #    U.S.A -> token 为 'U.S.A'\n        if re.fullmatch(r'[A-Za-z](?:\\.[A-Za-z]){2,}', token):\n            return True\n\n        # 3) 你如果有特殊缩写，可以在这里硬编码\n        # if token in {\"etc\", \"e.g\", \"i.e\"}:\n        #     return True\n\n        return False\n\n    def split_sentences(self, text: str) -> List[str]:\n        \"\"\"按照标点符号进行细粒度分句，同时尽量保护英文缩写和数字。\n        保留换行作为候选分句符。如果产生了“只有标点/引号”的句子，则直接丢弃。\n        \"\"\"\n        # 规范化换行：把 \\r\\n 和 \\r 统一为 \\n\n        text = text.replace('\\r\\n', '\\n').replace('\\r', '\\n')\n\n        # 替换全角空格为普通空格，保留换行\n        text = text.replace('\\u3000', ' ').strip()\n\n        # 分割：中文标点、特殊点号、或换行\n        sentences = re.split(r'([。！？!?：；]|(?<!\\d)\\.(?!\\d)|\\n+)', text)\n\n        result = []\n        current_sentence = \"\"\n\n        for part in sentences:\n            if not part:\n                continue\n\n            current_sentence += part\n\n            # 遇到句子结束符号时：\n            if re.fullmatch(r'[。！？!?：；]', part) or part == '.' or re.fullmatch(r'\\n+', part):\n                if part == '.':\n                    # 缩写保护\n                    if self._looks_like_abbreviation(current_sentence):\n                        continue\n\n                # 清理末尾换行\n                sent = current_sentence.strip()\n                if re.fullmatch(r'\\n+', part):\n                    sent = re.sub(r'\\n+$', '', sent).strip()\n\n                # **关键：如果只有标点或引号，则直接跳过**\n                if sent and not re.fullmatch(r'^[\\W_]+$', sent):\n                    result.append(sent)\n\n                current_sentence = \"\"\n\n        # 末尾残余\n        tail = current_sentence.strip()\n        if tail and not re.fullmatch(r'^[\\W_]+$', tail):\n            result.append(tail)\n\n        return result\n\n    def find_best_sentence_match(self, ai_sentence: str, original_sentences: List[str],\n                                 start_index: int = 0, use_extended: bool = False) -> Tuple[Optional[int], float]:\n        \"\"\"在原文句子列表中找到与AI句子最匹配的单个句子。\n        \n        Args:\n            ai_sentence: AI生成的句子\n            original_sentences: 原文句子列表\n            start_index: 搜索起始位置\n            use_extended: 是否使用扩展搜索窗口\n        \n        Returns:\n            (匹配索引, 相似度) 或 (None, 最高相似度)\n        \"\"\"\n        # 预处理 - 使用专门的比较清理方法\n        processed_ai_sentence = self.clean_for_compare(ai_sentence)\n        if not processed_ai_sentence:\n            return None, 0\n\n        # 根据句子长度获取自适应阈值\n        threshold = self.get_adaptive_threshold(ai_sentence)\n        \n        # 选择搜索窗口大小\n        search_window = self.extended_window if use_extended else self.base_window\n\n        best_match_index = None\n        best_similarity = 0\n\n        # 向前搜索\n        end_index = min(start_index + search_window, len(original_sentences))\n        for i in range(start_index, end_index):\n            original_sentence = original_sentences[i]\n            processed_original_sentence = self.clean_for_compare(original_sentence)\n\n            if not processed_original_sentence:\n                continue\n\n            matcher = difflib.SequenceMatcher(None, processed_ai_sentence, processed_original_sentence)\n            similarity = matcher.ratio()\n\n            if similarity > best_similarity:\n                best_similarity = similarity\n                best_match_index = i\n\n        # 如果没找到匹配且未使用扩展窗口，尝试向后搜索（处理乱序情况）\n        if best_similarity < threshold and not use_extended and start_index > 0:\n            backward_start = max(0, start_index - 10)\n            for i in range(backward_start, start_index):\n                original_sentence = original_sentences[i]\n                processed_original_sentence = self.clean_for_compare(original_sentence)\n\n                if not processed_original_sentence:\n                    continue\n\n                matcher = difflib.SequenceMatcher(None, processed_ai_sentence, processed_original_sentence)\n                similarity = matcher.ratio()\n\n                if similarity > best_similarity:\n                    best_similarity = similarity\n                    best_match_index = i\n\n        if best_similarity < threshold:\n            return None, best_similarity\n\n        return best_match_index, best_similarity\n\n    def correct_ai_text(self, original_text: str, ai_data: List[Dict]) -> List[Dict]:\n        \"\"\"使用分句匹配 + difflib 的方式校正AI文本。\n        \n        改进的算法：\n        1. 记录每个校正后item对应的原文句子索引范围\n        2. 基于实际索引位置插入遗漏句子\n        3. 支持自适应搜索窗口\n        \"\"\"\n        original_sentences = self.split_sentences(original_text)\n\n        # 存储校正后的数据，以及每个item对应的原文索引范围\n        corrected_data = []  # List of (item_dict, matched_indices_list)\n        used_original_indices = set()\n        current_original_index = 0\n\n        for ai_item in ai_data:\n            ai_text = ai_item.get('text_content', '')\n            ai_sentences = self.split_sentences(ai_text)\n\n            corrected_sentences_for_item = []\n            matched_indices_for_item = []  # 记录这个item匹配到的所有原文索引\n\n            logging.info(\"处理角色: %s (AI原文: '%s')\", ai_item.get('role_name', '未知'), ai_text[:50] if ai_text else '')\n\n            for ai_sentence in ai_sentences:\n                # 首先尝试基础窗口搜索\n                match_index, similarity = self.find_best_sentence_match(\n                    ai_sentence, original_sentences, current_original_index, use_extended=False\n                )\n\n                # 如果基础窗口没找到，尝试扩展窗口\n                if match_index is None:\n                    match_index, similarity = self.find_best_sentence_match(\n                        ai_sentence, original_sentences, current_original_index, use_extended=True\n                    )\n\n                if match_index is not None:\n                    original_match = original_sentences[match_index]\n                    corrected_sentences_for_item.append(original_match)\n                    matched_indices_for_item.append(match_index)\n                    used_original_indices.add(match_index)\n                    current_original_index = match_index + 1\n                    logging.info(\"匹配成功 (相似度: %.2f): AI='%s' -> 原文='%s'\", similarity, ai_sentence, original_match)\n                else:\n                    corrected_sentences_for_item.append(ai_sentence)\n                    logging.warning(\"匹配失败 (最高相似度: %.2f)，保留AI原句: '%s'\", similarity, ai_sentence)\n\n            # 最终清理 - 保留原始格式（包括引号）\n            corrected_text = self.clean_text(\" \".join(corrected_sentences_for_item))\n\n            if corrected_text:\n                corrected_item = ai_item.copy()\n                corrected_item['text_content'] = corrected_text\n                corrected_data.append((corrected_item, matched_indices_for_item))\n\n        # 处理遗漏的原文句子 - 改进的插入逻辑\n        missing_indices = set(range(len(original_sentences))) - used_original_indices\n        \n        if not missing_indices:\n            # 没有遗漏，直接返回校正数据\n            return [item for item, _ in corrected_data]\n\n        logging.info(\"发现 %d 个遗漏句子，正在插入...\", len(missing_indices))\n\n        # 构建原文索引到校正item的映射\n        # index_to_item_map: {原文索引: (corrected_item, item在corrected_data中的位置)}\n        index_to_item = {}\n        for item_idx, (item, matched_indices) in enumerate(corrected_data):\n            for orig_idx in matched_indices:\n                index_to_item[orig_idx] = (item, item_idx)\n\n        # 按原文顺序构建最终结果\n        final_data = []\n        inserted_items = set()  # 记录已插入的item索引，避免重复插入\n\n        for orig_idx in range(len(original_sentences)):\n            if orig_idx in missing_indices:\n                # 插入遗漏的句子\n                missing_sentence = self.clean_text(original_sentences[orig_idx])\n                if missing_sentence:\n                    logging.info(\"插入遗漏句子 (位置%d): '%s'\", orig_idx, missing_sentence)\n                    final_data.append({\n                        'role_name': '旁白',\n                        'text_content': missing_sentence,\n                        'emotion_name': '',\n                        'strength_name': ''\n                    })\n            elif orig_idx in index_to_item:\n                item, item_idx = index_to_item[orig_idx]\n                # 只在第一次遇到这个item的匹配索引时插入\n                if item_idx not in inserted_items:\n                    final_data.append(item)\n                    inserted_items.add(item_idx)\n\n        # 处理可能没有匹配到任何原文索引但仍需要保留的item（纯AI生成内容）\n        for item_idx, (item, matched_indices) in enumerate(corrected_data):\n            if item_idx not in inserted_items:\n                final_data.append(item)\n                logging.warning(\"Item未匹配到原文，追加到末尾: %s\", item.get('text_content', '')[:30])\n\n        return final_data\n\n\ndef read_files():\n    \"\"\"读取原文和AI输出文件\"\"\"\n    try:\n        with open('原文3.txt', 'r', encoding='utf-8') as f:\n            original_text = f.read()\n        with open('AI输出的包含错误的文本3.json', 'r', encoding='utf-8') as f:\n            ai_data = json.load(f)\n        return original_text, ai_data\n    except FileNotFoundError as e:\n        logging.error(\"文件读取错误: %s\", e)\n        return None, None\n    except json.JSONDecodeError as e:\n        logging.error(\"JSON解析错误: %s\", e)\n        return None, None\n\n\ndef save_corrected_data(corrected_data: List[Dict]):\n    \"\"\"保存校正后的数据\"\"\"\n    try:\n        with open('校正后的文本_final.json', 'w', encoding='utf-8') as f:\n            json.dump(corrected_data, f, ensure_ascii=False, indent=4)\n        logging.info(\"校正结果已保存到: 校正后的文本_final.json\")\n    except Exception as e:\n        logging.error(\"保存文件时出错: %s\", e)\n\n\ndef main():\n    original_text, ai_data = read_files()\n    if original_text is None or ai_data is None:\n        return\n\n    logging.info(\"文件读取成功！开始校正...\")\n\n    corrector = TextCorrectorFinal()\n    corrected_data = corrector.correct_ai_text(original_text, ai_data)\n\n    save_corrected_data(corrected_data)\n\n    logging.info(\"校正完成！\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "SonicVale/app/core/tts_engine.py",
    "content": "import requests\nfrom typing import Optional, List\nimport os\nimport logging\n\nclass TTSEngine:\n    def __init__(self, base_url: str):\n        \"\"\"\n        初始化 TTS 引擎\n        :param base_url: TTS 服务的基础 URL，如 http://127.0.0.1:8000\n        \"\"\"\n        self.base_url = base_url.rstrip(\"/\")\n\n    def synthesize(\n        self,\n        text: str,\n        filename: str,\n        emo_text: Optional[str] = None,\n        emo_vector: Optional[List[float]] = None,\n        save_path: Optional[str] = None\n    ) -> bytes:\n        \"\"\"\n        调用 /v2/synthesize 接口进行语音合成\n        :param text: 要合成的文本\n        :param filename: 参考音频文件名（服务端已存在）\n        :param emo_text: 情绪文本（可选）\n        :param emo_vector: 8维情绪向量（可选，优先级高于 emo_text）\n        :param save_path: 如果指定，将保存生成的音频文件到本地\n        :return: 音频二进制数据\n        \"\"\"\n        url = f\"{self.base_url}/v2/synthesize\"\n\n        payload = {\"text\": text, \"audio_path\": filename}\n\n        if emo_vector is not None:\n            payload[\"emo_vector\"] = emo_vector\n        elif emo_text:\n            payload[\"emo_text\"] = emo_text\n\n        try:\n            resp = requests.post(url, json=payload, timeout=120)\n            if resp.status_code != 200:\n                # 尝试解析错误信息\n                try:\n                    error_data = resp.json()\n                    error_msg = error_data.get('detail') or error_data.get('message') or error_data.get('msg') or resp.text\n                except:\n                    error_msg = resp.text\n                raise Exception(f\"TTS服务返回错误({resp.status_code}): {error_msg}\")\n\n            audio_bytes = resp.content\n            \n            # 检查返回的内容是否为有效音频\n            if len(audio_bytes) < 100:\n                raise Exception(f\"TTS服务返回的音频数据无效，大小: {len(audio_bytes)} 字节\")\n\n            if save_path:\n                with open(save_path, \"wb\") as f:\n                    f.write(audio_bytes)\n\n            return audio_bytes\n            \n        except requests.exceptions.ConnectionError:\n            raise Exception(f\"TTS服务连接失败，请检查TTS服务是否已启动 ({self.base_url})\")\n        except requests.exceptions.Timeout:\n            raise Exception(f\"TTS服务请求超时，请检查TTS服务是否正常运行\")\n        except requests.exceptions.RequestException as e:\n            raise Exception(f\"TTS服务请求异常: {str(e)}\")\n\n    def get_models(self) -> dict:\n        \"\"\"\n        调用 /v1/models 获取模型列表\n        :return: 模型信息\n        \"\"\"\n        url = f\"{self.base_url}/v1/models\"\n        resp = requests.get(url)\n        resp.raise_for_status()\n        return resp.json()\n\n    def check_audio_exists(self, filename: str) -> bool:\n        \"\"\"\n        调用 /v1/check/audio 检查参考音频是否存在\n        :param filename: 原始文件名\n        :return: True or False\n        \"\"\"\n        url = f\"{self.base_url}/v1/check/audio\"\n        params = {\"file_name\": filename}\n        resp = requests.get(url, params=params)\n        resp.raise_for_status()\n        return resp.json().get(\"exists\", False)\n\n    def upload_audio(self, file_path: str,full_path=None) -> dict:\n        \"\"\"\n                调用 /v1/upload_audio 上传音频\n                :param file_path: 本地音频文件路径\n                :param full_path: 用于唯一标识的全路径（可选，如果不传则使用 file_path）\n                :return: 服务端响应 JSON\n                \"\"\"\n        if not os.path.isfile(file_path):\n            return {\"code\": 400, \"msg\": f\"文件不存在: {file_path}\"}\n\n        url = f\"{self.base_url}/v1/upload_audio\"\n        try:\n            with open(file_path, \"rb\") as f:\n                files = {\n                    \"audio\": (os.path.basename(file_path), f, \"audio/wav\")\n                }\n                # 如果需要额外传 fullpath 参数\n                data = {}\n                if full_path:\n                    data[\"full_path\"] = full_path\n\n                resp = requests.post(url, files=files, data=data, timeout=30)\n                resp.raise_for_status()\n                return resp.json()\n        except requests.exceptions.RequestException as e:\n            return {\"code\": 500, \"msg\": f\"请求失败: {str(e)}\"}\n        except Exception as e:\n            return {\"code\": 500, \"msg\": f\"上传异常: {str(e)}\"}\nif __name__ == \"__main__\":\n    # 示例使用\n    engine = TTSEngine(\"https://eihh5fmon4-8200.cnb.run/\")\n\n    # 1. 上传音频\n    upload_res = engine.upload_audio(\"C:\\\\Users\\\\lxc18\\\\Music\\\\多情绪\\\\吴泽\\\\解说\\\\中等.wav\",full_path=\"C:\\\\Users\\\\lxc18\\\\Music\\\\多情绪\\\\吴泽\\\\解说\\\\中等.wav\")\n    # print(\"上传结果:\", upload_res)\n\n    # 2. 检查音频是否存在\n    exists = engine.check_audio_exists(\"C:\\\\Users\\\\lxc18\\\\Music\\\\多情绪\\\\吴泽\\\\解说\\\\中等.wav\")\n    logging.info(\"音频存在: %s\", exists)\n\n    # 3. 获取模型列表\n    models = engine.get_models()\n    logging.info(\"模型信息: %s\", models)\n\n    # 4. 合成语音\n    if exists:\n        audio = engine.synthesize(\"萧炎，斗之力，三段！级别：低级！\", \"C:\\\\Users\\\\lxc18\\\\Music\\\\多情绪\\\\吴泽\\\\解说\\\\中等.wav\",emo_text=\"愤怒\", save_path=\"output.wav\")\n        logging.info(\"语音已保存到 output.wav, 大小 %s 字节\", len(audio))\n"
  },
  {
    "path": "SonicVale/app/core/tts_runtime.py",
    "content": "# app/tts_worker.py\nimport asyncio\nfrom fastapi import FastAPI\nfrom markdown_it.rules_block import reference\n\nfrom app.core.ws_manager import manager\nfrom app.db.database import SessionLocal\nfrom app.routers.chapter_router import get_voice_service, get_emotion_service, get_strength_service\nfrom app.routers.multi_emotion_voice_router import get_multi_emotion_voice_service\nfrom app.routers.role_router import get_line_service, get_role_service, get_project_service\n\nTTS_TIMEOUT_SECONDS = 1200  # 可调\ndef emotion_text_to_vector(emotion: str, intensity: str) -> list[float]:\n    \"\"\"\n    将情绪(文本) + 强度(文本) 转换成 8维向量\n    8维分别对应: [高兴, 生气, 伤心, 害怕, 厌恶, 低落, 惊喜, 平静]\n    基础情绪为 one-hot，复合情绪为多维加权混合\n    :param emotion: 情绪名称\n    :param intensity: \"微弱\" / \"稍弱\" / \"中等\" / \"较强\" / \"强烈\"\n    :return: 长度为8的向量\n    \"\"\"\n    # 8维基础情绪索引: 高兴=0, 生气=1, 伤心=2, 害怕=3, 厌恶=4, 低落=5, 惊喜=6, 平静=7\n    BASE_EMOTIONS = [\"高兴\", \"生气\", \"伤心\", \"害怕\", \"厌恶\", \"低落\", \"惊喜\", \"平静\"]\n\n    # 复合情绪 → 基础情绪权重（各维度满强度，由 intensity 统一缩放）\n    COMPOSITE_MAP = {\n        \"嘲讽\":   {\"高兴\": 0.5, \"厌恶\": 1.0},  # 讽刺语气\n        \"悲愤\":   {\"伤心\": 1.0, \"生气\": 1.0},  # 悲愤交加\n    }\n\n    INTENSITY_MAP = {\n        \"微弱\": 0.2,\n        \"稍弱\": 0.4,\n        \"中等\": 0.6,\n        \"较强\": 0.8,\n        \"强烈\": 1.0\n    }\n\n    scale = INTENSITY_MAP.get(intensity, 0.5)\n    vec = [0.0] * 8\n\n    if emotion in BASE_EMOTIONS:\n        # 基础情绪: one-hot\n        vec[BASE_EMOTIONS.index(emotion)] = scale\n    elif emotion in COMPOSITE_MAP:\n        # 复合情绪: 多维加权混合\n        for base_name, weight in COMPOSITE_MAP[emotion].items():\n            vec[BASE_EMOTIONS.index(base_name)] = round(scale * weight, 4)\n    # 未知情绪返回全零向量（静默降级）\n    return vec\nasync def tts_worker(app: FastAPI):\n    q = app.state.tts_queue\n    ex = app.state.tts_executor\n    while True:\n        project_id, dto = await q.get()\n        db = SessionLocal()\n        try:\n            line_service = get_line_service(db)\n            role_service = get_role_service(db)\n            voice_service = get_voice_service(db)\n            multi_emotion_service = get_multi_emotion_voice_service(db)\n            project_service = get_project_service(db)\n            emotion_service = get_emotion_service(db)\n            strength_service = get_strength_service(db)\n\n\n            # line_service.update_line(dto.id, {\"status\": \"processing\"})\n            await manager.broadcast({\n                \"event\": \"line_update\",\n                \"line_id\": dto.id,\n                \"status\": \"processing\",\n                \"progress\": q.qsize() + 1,  # +1 包含当前正在处理的任务\n                \"meta\": f\"角色 {dto.role_id} 开始生成\"\n            })\n\n            role = role_service.get_role(dto.role_id)\n            voice = voice_service.get_voice(role.default_voice_id)\n            reference_path = voice.reference_path\n\n\n            # if voice.is_multi_emotion == 1:\n            #     # 使用多音色\n            #     multi_emotion = multi_emotion_service.get_multi_emotion_voice_by_voice_id_emotion_id_strength_id(voice.id, dto.emotion_id, dto.strength_id)\n            #     if multi_emotion is not None:\n            #         reference_path = multi_emotion.reference_path\n\n            # 9.13\n            emotion = emotion_service.get_emotion(dto.emotion_id)\n            strength = strength_service.get_strength(dto.strength_id)\n            # 拼接\n            # emo_text = f\"{strength.name}的{emotion.name} \"\n            # if emotion.name is \"解说\":\n            #     emo_text = None\n            emo_text = None\n            emo_vector = emotion_text_to_vector(emotion.name, strength.name)\n\n            project = project_service.get_project(project_id)\n\n            loop = asyncio.get_running_loop()\n            await asyncio.wait_for(\n                loop.run_in_executor(\n                    ex,\n                    line_service.generate_audio,\n                    reference_path,\n                    project.tts_provider_id,\n                    dto.text_content,\n                    emo_text,\n                    emo_vector,\n                    dto.audio_path\n                ),\n                timeout=TTS_TIMEOUT_SECONDS\n            )\n\n            line_service.update_line(dto.id, {\"status\": \"done\"})\n            await manager.broadcast({\n                \"event\": \"line_update\",\n                \"line_id\": dto.id,\n                \"status\": \"done\",\n                \"progress\":  q.qsize(),\n                \"meta\": \"生成完成\",\n                \"audio_path\": dto.audio_path\n            })\n            # 发送给前端，队列中剩余的数量\n            await manager.broadcast({\n                \"event\": \"tts_queue_rest\",\n                \"queue_rest\": q.qsize(),\n                \"project_id\": project_id\n            })\n\n        except Exception as e:\n            try:\n                line_service.update_line(dto.id, {\"status\": \"failed\"})\n            except Exception:\n                pass\n            await manager.broadcast({\n                \"event\": \"line_update\",\n                \"line_id\": dto.id,\n                \"status\": \"failed\",\n                \"progress\":  q.qsize(),\n                \"meta\": f\"失败: {e}\"\n            })\n\n        finally:\n\n            db.close()\n            q.task_done()\n"
  },
  {
    "path": "SonicVale/app/core/ws_manager.py",
    "content": "# ws_manager.py\nfrom fastapi import WebSocket\nfrom typing import List\n\nclass WSManager:\n    def __init__(self):\n        self.conns: List[WebSocket] = []\n\n    async def connect(self, ws: WebSocket):\n        await ws.accept()\n        self.conns.append(ws)\n\n    def disconnect(self, ws: WebSocket):\n        if ws in self.conns:\n            self.conns.remove(ws)\n\n    async def broadcast(self, data: dict):\n        dead = []\n        for ws in self.conns:\n            try:\n                await ws.send_json(data)\n            except:\n                dead.append(ws)\n        for d in dead:\n            self.disconnect(d)\n\nmanager = WSManager()\n"
  },
  {
    "path": "SonicVale/app/db/database.py",
    "content": "from typing import Any, Generator\n\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker, declarative_base, Session\nfrom app.core.config import *\n\nconfig_path = getConfigPath()\n# SQLite 数据库文件，存储在用户目录下的 SonicVale\nSQLALCHEMY_DATABASE_URL = f\"sqlite:///{os.path.join(config_path, 'app_test.db')}\"\n\n\n\n# echo=True 会打印执行的 SQL 语句，调试用\nengine = create_engine(\n    SQLALCHEMY_DATABASE_URL, connect_args={\"check_same_thread\": False}, echo=False\n)\n\n# SessionLocal 用于依赖注入\nSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n\n# Base 类，所有 ORM 模型继承它\nBase = declarative_base()\n\n# 依赖函数\ndef get_db() -> Generator[Session, Any, None]:\n    db = SessionLocal()\n    try:\n        yield db\n    finally:\n        db.close()"
  },
  {
    "path": "SonicVale/app/dto/chapter_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass ChapterCreateDTO(BaseModel):\n    title: str\n    project_id: int\n    order_index: Optional[int] = None\n    id: Optional[int] = None\n    text_content : Optional[str] = None\n\nclass ChapterResponseDTO(BaseModel):\n    title: str\n    project_id: int\n    order_index: Optional[int] = None\n    id: Optional[int] = None\n    text_content: Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n"
  },
  {
    "path": "SonicVale/app/dto/emotion_dto.py",
    "content": "\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass EmotionCreateDTO(BaseModel):\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n\n\nclass EmotionResponseDTO(BaseModel):\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n\n"
  },
  {
    "path": "SonicVale/app/dto/line_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\nclass LineInitDTO(BaseModel):\n    role_name: Optional[str] = None\n    text_content: str\n    emotion_name: Optional[str] = None\n    strength_name: Optional[str] = None\n\n\nclass LineOrderDTO(BaseModel):\n    id: int\n    line_order: int\nclass LineAudioProcessDTO(BaseModel):\n    # 默认是1\n    speed: Optional[float] = 1.0\n    # 默认是1\n    volume: Optional[float] = 1.0\n    start_ms: Optional[int] = None\n    end_ms: Optional[int] = None\n#     静止时间\n    silence_sec: Optional[float] = 0.0\n    current_ms: Optional[int] = None\n\nclass LineCreateDTO(BaseModel):\n    chapter_id: int\n    role_id:Optional[int] = None\n    voice_id : Optional[int] = None\n    line_order: Optional[int] = None\n    id: Optional[int] = None\n    text_content: Optional[str] = None\n\n    emotion_id: Optional[int] = None\n    strength_id: Optional[int] = None\n\n    audio_path : Optional[str] = None\n    status : Optional[str] = None\n    is_done : Optional[int] = 0\n    subtitle_path : Optional[str] = None\n\nclass LineResponseDTO(BaseModel):\n    chapter_id: int\n    role_id:Optional[int] = None\n    voice_id : Optional[int] = None\n    line_order: Optional[int] = None\n    id: Optional[int] = None\n    text_content: Optional[str] = None\n\n    emotion_id: Optional[int] = None\n    strength_id: Optional[int] = None\n\n    audio_path : Optional[str] = None\n    status : Optional[str] = None\n    is_done: Optional[int] = 0\n    subtitle_path : Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n\n"
  },
  {
    "path": "SonicVale/app/dto/llm_provider_dto.py",
    "content": "from dataclasses import Field\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional, Dict, Any\n\n\nfrom pydantic import BaseModel, Field as PydField\n\n\nclass LLMProviderCreateDTO(BaseModel):\n    \"\"\"业务实体：LLM\"\"\"\n    name: str\n    id: Optional[int] = None\n    api_base_url : Optional[str] = None\n    api_key: Optional[str] = None\n    model_list: Optional[str] = None\n    status : Optional[int] = None\n\n    # ✅ 默认自定义参数\n    custom_params: Optional[str] = None\n\n\nclass LLMProviderResponseDTO(BaseModel):\n    \"\"\"业务实体：LLM\"\"\"\n    name: str\n    id: Optional[int] = None\n    api_base_url : Optional[str] = None\n    api_key: Optional[str] = None\n    model_list: Optional[str] = None\n    status : Optional[int] = None\n    updated_at: Optional[datetime] = None\n    created_at: Optional[datetime] = None\n\n    # ✅ 默认自定义参数\n    custom_params: Optional[str] = None"
  },
  {
    "path": "SonicVale/app/dto/multi_emotion_voice_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass MultiEmotionVoiceCreateDTO(BaseModel):\n    emotion_id: int\n    voice_id: int\n    strength_id: int\n    id: Optional[int] = None\n    reference_path: Optional[str] = None\n\n\n\nclass MultiEmotionVoiceResponseDTO(BaseModel):\n    emotion_id: int\n    voice_id: int\n    strength_id: int\n    id: Optional[int] = None\n    reference_path: Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/dto/project_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass ProjectCreateDTO(BaseModel):\n    name: str\n    description: Optional[str] = None\n    llm_provider_id: Optional[int] = None\n    llm_model: Optional[str] = None\n    tts_provider_id: Optional[int] = None\n    prompt_id: Optional[int] = None\n    # 精准填充\n    is_precise_fill: Optional[int] = None\n    # 项目路径\n    project_root_path : Optional[str] = None\n\nclass ProjectResponseDTO(BaseModel):\n    id: int\n    name: str\n    description: Optional[str] = None\n    llm_provider_id: Optional[int] = None\n    llm_model: Optional[str] = None\n    tts_provider_id: Optional[int] = None\n    prompt_id: Optional[int] = None\n    # 精准填充\n    is_precise_fill : Optional[int] = None\n    # 项目路径\n    project_root_path : Optional[str] = None\n    created_at: datetime\n    updated_at: datetime\n\n\nclass ProjectImportDTO(BaseModel):\n    id : int\n    content: str\n"
  },
  {
    "path": "SonicVale/app/dto/prompt_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass PromptCreateDTO(BaseModel):\n\n    \"\"\"业务实体：提示词\"\"\"\n    name: str\n    task: str\n    description: Optional[str] = None\n    content: Optional[str] = None\n    id: Optional[int] = None\n\n\nclass PromptResponseDTO(BaseModel):\n\n    \"\"\"业务实体：提示词\"\"\"\n    name: str\n    task: str\n    description: Optional[str] = None\n    content: Optional[str] = None\n    id: Optional[int] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None"
  },
  {
    "path": "SonicVale/app/dto/role_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass RoleCreateDTO(BaseModel):\n    name: str\n    project_id: int\n    id: Optional[int] = None\n    default_voice_id: Optional[int] = None\n\nclass RoleResponseDTO(BaseModel):\n    name: str\n    project_id: int\n    id: Optional[int] = None\n    default_voice_id: Optional[int] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/dto/strength_dto.py",
    "content": "\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\n\nclass StrengthCreateDTO(BaseModel):\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n\n\n\nclass StrengthResponseDTO(BaseModel):\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None"
  },
  {
    "path": "SonicVale/app/dto/tts_provider_dto.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\nfrom typing import Optional\n\n\nclass TTSProviderCreateDTO(BaseModel):\n    name: Optional[str] = None\n    id: Optional[int] = None\n    api_base_url: Optional[str] = None\n    api_key: Optional[str] = None\n    status: Optional[int] = None\n\n\n\nclass TTSProviderResponseDTO(BaseModel):\n    \"\"\"业务实体：tts_provider\"\"\"\n    name: str\n    id: Optional[int] = None\n    api_base_url : Optional[str] = None\n    api_key: Optional[str] = None\n    status : Optional[int] = None\n    updated_at: Optional[datetime] = None\n    created_at: Optional[datetime] = None"
  },
  {
    "path": "SonicVale/app/dto/voice_dto.py",
    "content": "from datetime import datetime\nfrom typing import Optional, List\n\nfrom pydantic import BaseModel, Field, AliasChoices\n\n\nclass VoiceCreateDTO(BaseModel):\n    name: str\n    tts_provider_id: int\n    id: Optional[int] = None\n    reference_path: Optional[str] = None\n    description: Optional[str] = None\n    is_multi_emotion: Optional[int] = 0\n\n\nclass VoiceResponseDTO(BaseModel):\n    name: str\n    tts_provider_id: int\n    id: Optional[int] = None\n    reference_path : Optional[str] = None\n    description : Optional[str] = None\n    is_multi_emotion : Optional[int] = 0\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n\nclass VoiceExportDTO(BaseModel):\n    \"\"\"导出音色库请求DTO\"\"\"\n    tts_provider_id: int\n    export_path: str\n    ids: Optional[List[int]] = Field(default=None, validation_alias=AliasChoices(\"ids\", \"voice_ids\"))\n\n\nclass VoiceImportDTO(BaseModel):\n    \"\"\"导入音色库请求DTO\"\"\"\n    tts_provider_id: int\n    zip_path: str\n    target_dir: str\n\n\nclass VoiceImportResultDTO(BaseModel):\n    \"\"\"导入音色库结果DTO\"\"\"\n    success_count: int\n    skipped_count: int\n    skipped_names: List[str]\n\n\nclass VoiceAudioProcessDTO(BaseModel):\n    \"\"\"音色参考音频处理DTO\"\"\"\n    audio_path: str\n    speed: Optional[float] = 1.0\n    volume: Optional[float] = 1.0\n    start_ms: Optional[int] = None\n    end_ms: Optional[int] = None\n    silence_sec: Optional[float] = 0.0\n    current_ms: Optional[int] = None\n\n\nclass VoiceCopyDTO(BaseModel):\n    \"\"\"复制音色请求DTO\"\"\"\n    source_voice_id: int\n    new_name: str\n    target_dir: Optional[str] = None  # 为空则使用原音色同目录\n\n"
  },
  {
    "path": "SonicVale/app/entity/chapter_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass ChapterEntity:\n    \"\"\"业务实体：章节\"\"\"\n    title: str\n    project_id: int\n    order_index: Optional[int] = None\n    id: Optional[int] = None\n    text_content : Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n\n"
  },
  {
    "path": "SonicVale/app/entity/emotion_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n@dataclass\nclass EmotionEntity:\n    \"\"\"业务实体：情绪枚举\"\"\"\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/entity/line_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass LineEntity:\n    \"\"\"业务实体：台词\"\"\"\n    chapter_id: int\n    id: Optional[int] = None\n    role_id : Optional[ int] = None\n    voice_id : Optional[int] = None\n    line_order : Optional[int] = None\n    text_content : Optional[str] = None\n\n    emotion_id : Optional[int] = None\n    strength_id : Optional[int] = None\n\n    audio_path : Optional[str] = None\n    subtitle_path : Optional[str] = None\n    status : Optional[str] = None\n    # 是否完成\n    is_done : Optional[int] = 0\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n"
  },
  {
    "path": "SonicVale/app/entity/llm_provider_entity.py",
    "content": "\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom typing import Optional, Dict, Any\n\n\n@dataclass\nclass LLMProviderEntity:\n    \"\"\"业务实体：LLM\"\"\"\n    name: str\n    id: Optional[int] = None\n    api_base_url : Optional[str] = None\n    api_key: Optional[str] = None\n    model_list : Optional[str] = None\n    status : Optional[int] = None\n    updated_at: Optional[datetime] = None\n    created_at: Optional[datetime] = None\n\n    # ✅ 自定义参数字段（默认值与数据库一致）\n    custom_params: Optional[str] = None\n"
  },
  {
    "path": "SonicVale/app/entity/multi_emotion_voice_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n# class MultiEmotionVoicePO(Base):\n#     __tablename__ = \"multi_emotion\"\n#     id = Column(Integer, primary_key=True, autoincrement=True, index=True)\n#     emotion_id = Column(Integer, nullable=False)\n#     voice_id = Column(Integer, nullable=False)\n#     strength_id = Column(Integer, nullable=True)\n#     reference_path = Column(String(255), nullable=True)\n#     created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n#     updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n#                         nullable=False)\n\n@dataclass\nclass MultiEmotionVoiceEntity:\n    \"\"\"业务实体：多情感音色\"\"\"\n\n    emotion_id: int\n    voice_id: int\n    strength_id: int\n    id: Optional[int] = None\n    reference_path: Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/entity/project_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass ProjectEntity:\n    \"\"\"业务实体：项目\"\"\"\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    llm_provider_id: Optional[int] = None\n    llm_model: Optional[str] = None\n    tts_provider_id: Optional[int] = None\n    prompt_id: Optional[int] = None # 提示词\n    # 精准填充\n    is_precise_fill: Optional[int] = None\n    # 项目保存地址\n    project_root_path: Optional[str] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n\n\n\n\n"
  },
  {
    "path": "SonicVale/app/entity/prompt_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n# class PromptPO(Base):\n#     __tablename__ = \"prompt\"\n#     id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n#     name = Column(String(255), nullable=False)\n#     description = Column(Text, nullable=True)\n#     content = Column(Text, nullable=True)\n#     created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n#     updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),nullable=False)\n\n@dataclass\nclass PromptEntity:\n    \"\"\"业务实体：提示词\"\"\"\n    name: str\n    task: str\n    description: Optional[str] = None\n    content: Optional[str] = None\n    id: Optional[int] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n"
  },
  {
    "path": "SonicVale/app/entity/role_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass RoleEntity:\n    \"\"\"业务实体：角色\"\"\"\n    name: str\n    project_id: int\n    id: Optional[int] = None\n    default_voice_id : Optional[int] = None\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/entity/strength_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n    \n@dataclass\nclass StrengthEntity:\n    \"\"\"业务实体：情绪强弱枚举\"\"\"\n    name: str\n    id: Optional[int] = None\n    description: Optional[str] = None\n    is_active: Optional[int] = 1\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n"
  },
  {
    "path": "SonicVale/app/entity/tts_provider_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass TTSProviderEntity:\n    \"\"\"业务实体：TTS\"\"\"\n    name: str\n    id: Optional[int] = None\n    api_base_url : Optional[str] = None\n    api_key: Optional[str] = None\n    status : Optional[int] = None\n    updated_at: Optional[datetime] = None\n    created_at: Optional[datetime] = None\n\n"
  },
  {
    "path": "SonicVale/app/entity/voice_entity.py",
    "content": "\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Optional\n\n\n\n@dataclass\nclass VoiceEntity:\n    \"\"\"业务实体：音色\"\"\"\n    name: str\n    tts_provider_id: int\n    id: Optional[int] = None\n    reference_path : Optional[str] = None\n    description : Optional[str] = None\n    is_multi_emotion : Optional[int] = 0\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n"
  },
  {
    "path": "SonicVale/app/main.py",
    "content": "# app/main.py\nimport asyncio\nimport logging\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport uvicorn\nfrom fastapi import FastAPI, Depends\nfrom sqlalchemy.orm import Session\nfrom starlette.middleware.cors import CORSMiddleware\n\nfrom app.core.config import getConfigPath\nfrom app.core.prompts import get_prompt_str\nfrom app.core.tts_runtime import tts_worker\nfrom app.core.ws_manager import manager\nfrom app.db.database import Base, engine, SessionLocal, get_db\nfrom app.entity.emotion_entity import EmotionEntity\nfrom app.entity.strength_entity import StrengthEntity\nfrom app.models.po import *\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.routers import project_router, chapter_router, role_router, voice_router, llm_provider_router, \\\n    tts_provider_router, line_router, emotion_router, strength_router, multi_emotion_voice_router, prompt_router\nfrom app.routers.chapter_router import get_strength_service, get_prompt_service, get_project_service\nfrom app.routers.emotion_router import get_emotion_service\nfrom app.routers.llm_provider_router import get_llm_service\nfrom app.services.llm_provider_service import LLMProviderService\n\nfrom app.services.tts_provider_service import TTSProviderService\n\nimport os\nimport sys\n\nroot_path = os.getcwd()\nsys.path.append(root_path)\n\n# =========================\n# 日志配置（同时输出到控制台和文件）\n# =========================\nlog_file_path = os.path.join(getConfigPath(), \"app.log\")\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s - %(levelname)s - %(message)s',\n    handlers=[\n        logging.StreamHandler(),  # 控制台输出\n        logging.FileHandler(log_file_path, encoding='utf-8')  # 文件输出\n    ]\n)\nlogging.info(f\"日志文件路径: {log_file_path}\")\n\n# =========================\n# FastAPI 实例\n# =========================\napp = FastAPI(\n    title=\"音墟 (YinXu) - AI多角色小说配音\",\n    description=\"桌面端小说多角色配音系统，支持 TTS、GPT 提取角色、台词管理及字幕生成\",\n    version=\"1.0.0\",\n)\n# 跨域\n# 允许的前端地址\norigins = [\n    \"http://localhost:5173\",  # Vue 开发服务器\n    \"http://127.0.0.1:5173\"   # 有些浏览器可能会用这个\n]\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=origins,        # 允许的源\n    allow_credentials=True,\n    allow_methods=[\"*\"],          # 允许所有方法（GET, POST, DELETE...）\n    allow_headers=[\"*\"],          # 允许所有请求头\n)\n\n\n\n# =========================\n# 数据库初始化（创建表）\n# =========================\n\n# 启动时创建表\n# @app.on_event(\"startup\")\n# def startup():\n#     Base.metadata.create_all(bind=engine)\n\nWORKERS = 1\nQUEUE_CAPACITY = 0\n\nfrom sqlalchemy import text\n\ndef add_prompt_id_column():\n    with engine.connect() as conn:\n        # 检查 project 表是否已有 prompt_id\n        result = conn.execute(text(\"PRAGMA table_info(projects)\"))\n        columns = [row[1] for row in result.fetchall()]\n        if \"prompt_id\" not in columns:\n            conn.execute(text(\"ALTER TABLE projects ADD COLUMN prompt_id INTEGER\"))\n            conn.commit()\n\n# 添加line表中is_done字段\ndef add_is_done_column():\n    with engine.connect() as conn:\n        result = conn.execute(text(\"PRAGMA table_info(lines)\"))\n        columns = [row[1] for row in result.fetchall()]\n        if \"is_done\" not in columns:\n            # ✅ 添加列并设置默认值 0\n            conn.execute(text(\"ALTER TABLE lines ADD COLUMN is_done INTEGER DEFAULT 0\"))\n            conn.commit()\n\n# 添加LLM自定义参数字段\ndef add_custom_params_column():\n    with engine.begin() as conn:  # ✅ 用 begin() 自动提交事务\n        result = conn.execute(text(\"PRAGMA table_info(llm_provider)\"))\n        columns = [row[1] for row in result.fetchall()]\n        if \"custom_params\" not in columns:\n            # ✅ 添加列\n            conn.execute(text(\"ALTER TABLE llm_provider ADD COLUMN custom_params TEXT\"))\n\n            # ✅ 可选：为历史数据填入默认 JSON（推荐）\n            import json\n            default_json = json.dumps({\n                \"response_format\": {\"type\": \"json_object\"},\n                \"temperature\": 0.7,\n                \"top_p\": 0.9\n            }, ensure_ascii=False)\n            conn.execute(\n                text(\"UPDATE llm_provider SET custom_params = :val\"),\n                {\"val\": default_json}\n            )\n\n            logging.info(\"已添加 custom_params 列并写入默认值。\")\n        else:\n            logging.info(\"custom_params 列已存在，跳过。\")\n\n# 添加精准填充字段】\ndef add_is_precise_fill_column():\n    with engine.begin() as conn:  # ✅ 用 begin() 自动提交事务\n        result = conn.execute(text(\"PRAGMA table_info(projects)\"))\n        columns = [row[1] for row in result.fetchall()]\n        if \"is_precise_fill\" not in columns:\n            # ✅ 添加列\n            conn.execute(text(\"ALTER TABLE projects ADD COLUMN is_precise_fill INTEGER DEFAULT 0\"))\n\n            conn.commit()\n\n# 添加项目保存路径字段（project_path）\ndef add_project_root_path_column():\n    with engine.begin() as conn:  # ✅ 用 begin() 自动提交事务\n        result = conn.execute(text(\"PRAGMA table_info(projects)\"))\n        columns = [row[1] for row in result.fetchall()]\n        if \"project_root_path\" not in columns:\n            # ✅ 添加列\n            conn.execute(text(\"ALTER TABLE projects ADD COLUMN project_root_path TEXT\"))\n\n            conn.commit()\n\ndef get_tts_service(db: Session = Depends(get_db)) -> TTSProviderService:\n    return TTSProviderService(TTSProviderRepository(db))\n\n@app.on_event(\"startup\")\nasync def startup_event():\n    # 1) 建表\n    try:\n        Base.metadata.create_all(bind=engine)\n    except Exception as e:\n        logging.exception(\"❌ 数据库建表失败: %s\", e)\n\n    # 更改数据库表字段\n    add_prompt_id_column()\n    # v1.0.6添加字段 is_done\n    add_is_done_column()\n    # v1.0.7 添加字段 custom_params\n    add_custom_params_column()\n    # v1.0.7 添加项目的字段 is_precise_fill\n    add_is_precise_fill_column()\n    # v1.0.7 添加项目的字段 project_root_path\n    add_project_root_path_column()\n\n    # 2) 初始化共享运行时\n    try:\n        app.state.tts_queue = asyncio.Queue(maxsize=QUEUE_CAPACITY)\n        app.state.tts_executor = ThreadPoolExecutor(max_workers=WORKERS)\n    except Exception as e:\n        logging.exception(\"❌ 初始化队列/线程池失败: %s\", e)\n\n    # 3) 启动后台 worker\n    try:\n        app.state.tts_workers = [\n            asyncio.create_task(tts_worker(app)) for _ in range(WORKERS)\n        ]\n    except Exception as e:\n        logging.exception(\"❌ 启动 worker 失败: %s\", e)\n\n    # 4) 初始化默认数据\n    db = SessionLocal()\n    try:\n        try:\n            tts_service = get_tts_service(db)\n            tts_service.create_default_tts_provider()\n        except Exception as e:\n            logging.warning(\"⚠️ 默认 TTS provider 初始化失败: %s\", e)\n\n        try:\n            emotion_service = get_emotion_service(db)\n            for name in [\n                # 8种基础情绪\n                \"高兴\", \"生气\", \"伤心\", \"害怕\", \"厌恶\", \"低落\", \"惊喜\", \"平静\",\n                # 2种独特复合情绪\n                \"嘲讽\", \"悲愤\",\n            ]:\n                try:\n                    emotion_service.create_emotion(EmotionEntity(name=name))\n                except Exception as e:\n                    logging.debug(\"情绪 %s 已存在或创建失败: %s\", name, e)\n        except Exception as e:\n            logging.warning(\"⚠️ 情绪初始化失败: %s\", e)\n\n        try:\n            strength_service = get_strength_service(db)\n            for name in [\"微弱\",\"稍弱\",\"中等\",\"较强\",\"强烈\"]:\n                try:\n                    strength_service.create_strength(StrengthEntity(name=name))\n                except Exception as e:\n                    logging.debug(\"强度 %s 已存在或创建失败: %s\", name, e)\n        except Exception as e:\n            logging.warning(\"⚠️ 强度初始化失败: %s\", e)\n\n    #     创建默认提示词\n        try:\n            prompt_service = get_prompt_service(db)\n            if not prompt_service.get_all_prompts():\n                logging.info(\"创建默认提示词\")\n                prompt_service.create_default_prompt()\n            else:\n                default_prompt =  prompt_service.get_prompt_by_name(\"默认拆分台词提示词\")\n                if not default_prompt:\n                    prompt_service.create_default_prompt()\n                else:\n                    #修改默认提示词\n                    default_prompt_content = get_prompt_str()\n                    default_prompt.content = default_prompt_content\n                    prompt_service.update_prompt(default_prompt.id, default_prompt.__dict__)\n\n        except Exception as e:\n            logging.warning(\"⚠️ 默认提示词创建失败: %s\", e)\n    # 兼容之前版本，已有的项目的project_root_path 为 getConfigPath()\n        try:\n            project_service = get_project_service(db)\n            for project in project_service.get_all_projects():\n                if not project.project_root_path:\n                    project.project_root_path = getConfigPath()\n                    project_service.update_project(project.id, project.__dict__)\n                    logging.info(\"项目 %s 默认项目路径已修改为 %s\", project.name, project.project_root_path)\n\n        #             todo:修改所有的保存路径，然后前端请求添加保存路径（利用electron读取文件夹路径）\n        except Exception as e:\n            logging.warning(\"⚠️ 项目默认项目路径初始化失败: %s\", e)\n\n    except Exception as e:\n        logging.exception(\"❌ 默认数据初始化异常: %s\", e)\n    finally:\n        db.close()\n\n@app.on_event(\"shutdown\")\nasync def shutdown_event():\n    # 优雅退出\n    for t in getattr(app.state, \"tts_workers\", []):\n        t.cancel()\n    ex = getattr(app.state, \"tts_executor\", None)\n    if ex:\n        ex.shutdown(wait=False, cancel_futures=True)\n# =========================\n# 注册路由\n# =========================\napp.include_router(project_router.router)\napp.include_router(chapter_router.router)\napp.include_router(role_router.router)\napp.include_router(voice_router.router)\napp.include_router(llm_provider_router.router)\napp.include_router(tts_provider_router.router)\napp.include_router(line_router.router)\napp.include_router(emotion_router.router)\napp.include_router(strength_router.router)\napp.include_router(multi_emotion_voice_router.router)\napp.include_router(prompt_router.router)\n# =========================\n# 健康检查接口\n# =========================\n@app.get(\"/\")\ndef read_root():\n    return {\"msg\": \"音墟 (YinXu) 后端服务运行中！\"}\n\n# =========================\n# 小测试接口：插入并查询 ProjectPO\n# =========================\n@app.get(\"/test-db\")\ndef test_db():\n    session: Session = SessionLocal()\n    try:\n        # 使用时间戳生成唯一名称，避免 UNIQUE 冲突\n        name = f\"测试项目_{int(datetime.now().timestamp())}\"\n\n        test_project = ProjectPO(name=name, description=\"测试用项目\")\n        session.add(test_project)\n        session.commit()\n        session.refresh(test_project)\n\n        return {\n            \"msg\": \"插入成功\",\n            \"id\": test_project.id,\n            \"name\": test_project.name,\n            \"created_at\": test_project.created_at,\n            \"updated_at\": test_project.updated_at\n        }\n\n    except Exception as e:\n        session.rollback()\n        return {\"error\": str(e)}\n\n    finally:\n        session.close()\n\n\nimport json\nfrom fastapi import WebSocket, WebSocketDisconnect\n\n@app.websocket(\"/ws\")\nasync def ws_endpoint(ws: WebSocket):\n    await manager.connect(ws)\n    logging.info(\"WebSocket 客户端已连接\")\n    try:\n        while True:\n            msg_text = await ws.receive_text()\n            try:\n                data = json.loads(msg_text)\n            except json.JSONDecodeError:\n                data = {}\n\n            # 👇 心跳处理：收到 ping 立即回复 pong\n            if data.get(\"type\") == \"ping\":\n                logging.debug(\"receive ping\")\n                await ws.send_text(json.dumps({\"type\": \"pong\"}))\n                continue\n\n            # 这里可以扩展处理订阅/其他消息\n\n    except WebSocketDisconnect:\n        logging.info(\"WebSocket 客户端主动断开\")\n        manager.disconnect(ws)\n    except Exception as e:\n        logging.warning(f\"WebSocket 连接异常: {e}\")\n        manager.disconnect(ws)\n\n\n\nif __name__ == \"__main__\":\n\n    # uvicorn.run(app, host=\"127.0.0.1\", port=8200)\n    # 使用自定义 logger，避免 uvicorn 自动配置失败\n    # logging.basicConfig(level=logging.INFO)\n    uvicorn.run(\"app.main:app\", host=\"127.0.0.1\", port=8200, log_config=None)\n"
  },
  {
    "path": "SonicVale/app/models/po.py",
    "content": "\nfrom sqlalchemy import Column, Integer, Integer, String, Text, Enum, ForeignKey, DateTime, JSON, Index\nfrom datetime import datetime, timezone\n\nfrom app.db.database import Base\n\n\n# ------------------------------\n# 1. 项目表 projects\n# ------------------------------\nclass ProjectPO(Base):\n    __tablename__ = \"projects\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True,index=True)\n    name = Column(String(255), nullable=False, unique=True, index=True)\n    description = Column(Text, nullable=True)\n    llm_provider_id = Column(Integer, nullable=True)  # LLM提供商\n    llm_model = Column(String(255), nullable=True)  # 指定模型\n    tts_provider_id = Column(Integer, nullable=True)  # TTS提供商\n    prompt_id = Column(Integer, nullable=True) # 关联的prompt\n    # 是否开启精准填充\n    is_precise_fill = Column(Integer, default=0, nullable=False)\n    # 项目根地址\n    project_root_path = Column(String(255), nullable=True)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)\n\n\n# ------------------------------\n# 2. 项目的全局角色表 roles\n# ------------------------------\nclass RolePO(Base):\n    __tablename__ = \"roles\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True,index=True)\n    project_id = Column(Integer,  nullable=False)\n    name = Column(String(100), nullable=False)\n    default_voice_id = Column(Integer, ForeignKey(\"voices.id\"), nullable=True)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)\n\n\n# ------------------------------\n# 3. 音色表 voices\n# ------------------------------\nclass VoicePO(Base):\n    __tablename__ = \"voices\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True, index=True)\n    tts_provider_id = Column(Integer, nullable=True)\n    name = Column(String(100), nullable=False)\n    reference_path = Column(String(255), nullable=True)\n    description = Column(Text, nullable=True)\n    # 是否包含多情绪\n    is_multi_emotion = Column(Integer, default=0, nullable=False)\n\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n                        nullable=False)\n\n# 多情绪表\nclass MultiEmotionVoicePO(Base):\n    __tablename__ = \"multi_emotion\"\n    id = Column(Integer, primary_key=True, autoincrement=True, index=True)\n    voice_id = Column(Integer, nullable=False)\n    emotion_id = Column(Integer, nullable=False)\n    strength_id = Column(Integer, nullable=True)\n    reference_path = Column(String(255), nullable=True)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n                        nullable=False)\n\n# ------------------------------\n# 4. 章节表 chapters\n# ------------------------------\nclass ChapterPO(Base):\n    __tablename__ = \"chapters\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True,index=True)\n    project_id = Column(Integer, nullable=False)\n    title = Column(String(255), nullable=False)\n    order_index = Column(Integer, nullable=True)\n    text_content = Column(Text, nullable=True)  # SQLite 没有 LongText，用 Text 替代\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n                        nullable=False)\n\n\n\n# ------------------------------\n# 5. 台词表 lines\n# ------------------------------\n# 情绪枚举表\nclass EmotionPO(Base):\n    __tablename__ = \"emotions\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True, index=True)\n    name = Column(String(100), nullable=False)\n    description = Column(Text, nullable=True)\n    is_active = Column(Integer, default=1, nullable=False)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now())\n\n# 情绪强弱枚举表\nclass StrengthPO(Base):\n    __tablename__ = \"strengths\"\n\n    id = Column(Integer, primary_key=True, autoincrement=True, index=True)\n    name = Column(String(100), nullable=False)\n    description = Column(Text, nullable=True)\n    is_active = Column(Integer, default=1, nullable=False)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now())\n\n\nclass LinePO(Base):\n    __tablename__ = \"lines\"\n\n    id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n\n    # 外键\n    chapter_id = Column(Integer, nullable=False, index=True)\n    role_id = Column(Integer, nullable=True)\n    voice_id = Column(Integer,  nullable=True)\n\n    # 核心信息\n    line_order = Column(Integer, nullable=True, index=True)\n    text_content = Column(Text, nullable=True)\n    # 情绪 和 强弱\n    emotion_id = Column(Integer, nullable=True)\n    strength_id = Column(Integer, nullable=True)\n\n    # 9.1 新增\n\n\n    # 输出资源\n    audio_path = Column(String(500), nullable=True)\n    subtitle_path = Column(String(500), nullable=True)\n\n    # 间隔停留时间（秒）\n    # wait_time = Column(Integer, default=0, nullable=True)\n\n    # 状态\n    status = Column(\n        Enum(\"pending\", \"processing\", \"done\", \"failed\", name=\"line_status\"),\n        default=\"pending\",\n        nullable=False\n    )\n    # 是否完成\n    is_done = Column(Integer, default=0, nullable=False)\n\n    # 时间戳\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)\n    __table_args__ = (\n        Index(\"idx_chapter_order\", \"chapter_id\", \"line_order\"),\n    )\n\n# -------------------------\n# LLMProviderPO\n# -------------------------\nclass LLMProviderPO(Base):\n    __tablename__ = \"llm_provider\"\n\n    id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n    name = Column(String(255), nullable=False, unique=True)           # 提供商名称\n    api_base_url = Column(String(500), nullable=False)\n    api_key = Column(String(500), nullable=True)                      # 可加密存储\n    model_list = Column(JSON, nullable=True)                           # 支持的模型列表\n    status = Column(Integer, default=1, nullable=False)               # 启用/禁用\n\n    # ✅ 自定义参数（默认包含 response_format、temperature、top_p）\n    custom_params = Column(\n        Text,\n        nullable=False,\n        default=lambda: {\n            \"response_format\": {\"type\": \"json_object\"},\n            \"temperature\": 0.7,\n            \"top_p\": 0.9\n\n        }\n    )\n    # 时间戳\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n                        nullable=False)\n\n\n# -------------------------\n# TTSProviderPO\n# -------------------------\nclass TTSProviderPO(Base):\n    __tablename__ = \"tts_provider\"\n\n    id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n    name = Column(String(255), nullable=False, unique=True)\n    api_base_url = Column(String(500), nullable=False)\n    api_key = Column(String(500), nullable=True)\n    # voice_list = Column(JSON, nullable=True)\n    status = Column(Integer, default=1, nullable=False)\n\n    # 时间戳\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n                        nullable=False)\n\n\nclass PromptPO(Base):\n    __tablename__ = \"prompts\"\n    id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n    name = Column(String(255), nullable=False)\n    task = Column(String(255), nullable=False)\n    description = Column(Text, nullable=True)\n    content = Column(Text, nullable=True)\n    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),nullable=False)\n\n\n# -------------------------\n# ProjectSettings\n# -------------------------\n# class ProjectSettings(Base):\n#     __tablename__ = \"project_settings\"\n#\n#     id = Column(Integer, primary_key=True, index=True, autoincrement=True)\n#     project_id = Column(Integer, nullable=False)                  # 所属项目\n#     llm_provider_id = Column(Integer, nullable=True)              # LLM提供商\n#     llm_model = Column(String(255), nullable=True)                   # 指定模型\n#     tts_provider_id = Column(Integer, nullable=True)              # TTS提供商\n#\n#     # 时间戳\n#     created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)\n#     updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),\n#                         nullable=False)\n"
  },
  {
    "path": "SonicVale/app/repositories/chapter_repository.py",
    "content": "from typing import Optional, Sequence\n\nfrom sqlalchemy import select\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import ChapterPO\n\n\nclass ChapterRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, chapter_id: int) -> Optional[ChapterPO]:\n        \"\"\"根据 ID 查询项目\"\"\"\n        return self.db.get(ChapterPO, chapter_id)\n\n    def get_all(self, project_id: int) -> Sequence[ChapterPO]:\n        \"\"\"获取指定项目下的所有章节\"\"\"\n        stmt = select(ChapterPO).where(ChapterPO.project_id == project_id)\n        return self.db.execute(stmt).scalars().all()\n\n    def create(self, chapter_data: ChapterPO) -> ChapterPO:\n        \"\"\"新建项目\"\"\"\n        self.db.add(chapter_data)\n        self.db.commit()\n        self.db.refresh(chapter_data)\n        return chapter_data\n\n    def update(self, chapter_id: int, chapter_data: dict) -> Optional[ChapterPO]:\n        \"\"\"更新项目\"\"\"\n        chapter = self.get_by_id(chapter_id)\n        if not chapter:\n            return None\n        for key, value in chapter_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(chapter, key, value)\n\n        self.db.commit()\n        self.db.refresh(chapter)\n        return chapter\n\n    def delete(self, chapter_id: int) -> bool:\n        \"\"\"删除章节\"\"\"\n        project = self.get_by_id(chapter_id)\n        if not project:\n            return False\n        self.db.delete(project)\n        self.db.commit()\n        return True\n    # def delete_all_by_project_id(self, project_id: int) -> bool:\n    #     \"\"\"删除指定项目下的所有章节\"\"\"\n    #     pos = self.get_all(project_id)\n    #     for po in pos:\n    #         self.db.delete(po)\n    #     self.db.commit()\n    #     return True\n\n    def get_by_name(self, name: str, project_id: int) -> Optional[ChapterPO]:\n        \"\"\"根据项目ID和章节名称查找章节\"\"\"\n        stmt = (\n            select(ChapterPO)\n            .where(ChapterPO.title == name)\n            .where(ChapterPO.project_id == project_id)\n        )\n        return self.db.execute(stmt).scalar_one_or_none()\n\n    def search(self, keyword: str) -> Sequence[ChapterPO]:\n        \"\"\"模糊搜索\"\"\"\n        stmt = select(ChapterPO).where(ChapterPO.title.ilike(f\"%{keyword}%\"))\n        return self.db.execute(stmt).scalars().all()"
  },
  {
    "path": "SonicVale/app/repositories/emotion_repository.py",
    "content": "from typing import Optional, Sequence\n\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import EmotionPO\n\n\nclass EmotionRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[EmotionPO]:\n        \"\"\"通过id获取情绪\"\"\"\n        return self.db.query(EmotionPO).filter(EmotionPO.id == id).first()\n\n    def get_by_name(self, name: str) -> Optional[EmotionPO]:\n        \"\"\"通过名称获取情绪\"\"\"\n        return self.db.query(EmotionPO).filter(EmotionPO.name == name).first()\n\n    def get_all(self) -> list[type[EmotionPO]]:\n        \"\"\"获取所有情绪\"\"\"\n        return self.db.query(EmotionPO).all()\n\n    def create(self, emotion: EmotionPO) -> EmotionPO:\n        \"\"\"创建情绪\"\"\"\n\n        self.db.add(emotion)\n        self.db.commit()\n        self.db.refresh(emotion)\n        return emotion\n\n    def update(self, id: int, data: dict) -> Optional[EmotionPO]:\n        \"\"\"更新情绪\"\"\"\n        emotion = self.get_by_id(id)\n        if not emotion:\n            return None\n        for key, value in data.items():\n            if value is not None:\n                setattr(emotion, key, value)\n        self.db.commit()\n        self.db.refresh(emotion)\n        return emotion\n\n    def delete(self, id: int) -> bool:\n        \"\"\"删除情绪\"\"\"\n        emotion = self.get_by_id(id)\n        if not emotion:\n            return False\n        self.db.delete(emotion)\n        self.db.commit()\n        return True\n\n\n"
  },
  {
    "path": "SonicVale/app/repositories/line_repository.py",
    "content": "from typing import Optional, List\n\nfrom sqlalchemy import Sequence, select, update\nfrom sqlalchemy.orm import Session\n\nfrom app.dto.line_dto import LineOrderDTO\nfrom app.models.po import LinePO\n\n\nclass LineRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[LinePO]:\n        \"\"\"根据 ID 查询单行台词\"\"\"\n        return self.db.get(LinePO, id)\n\n    def get_all(self, chapter_id: int) -> Sequence[LinePO]:\n        \"\"\"获取章节下所有单行台词，按 line_order 排序\"\"\"\n        stmt = (\n            select(LinePO)\n            .where(LinePO.chapter_id == chapter_id)\n            .order_by(LinePO.line_order.asc())  # 升序\n        )\n        return self.db.execute(stmt).scalars().all()\n\n\n    def create(self, data: LinePO) -> LinePO:\n        \"\"\"新增单行台词\"\"\"\n        self.db.add(data)\n        self.db.commit()\n        self.db.refresh(data)\n        return data\n\n\n    def update(self, line_id: int, line_data: dict) -> Optional[LinePO]:\n        \"\"\"更新单行台词信息\"\"\"\n        line = self.get_by_id(line_id)\n        if not line:\n            return None\n        for key, value in line_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(line, key, value)\n\n        self.db.commit()\n        self.db.refresh(line)\n        return line\n\n    def delete(self, line_id: int) -> bool:\n        \"\"\"删除台词\"\"\"\n        line = self.get_by_id(line_id)\n        if not line:\n            return False\n        self.db.delete(line)\n        self.db.commit()\n        return True\n    def delete_all_by_chapter_id(self, chapter_id: int) -> bool:\n        \"\"\"删除章节下的所有台词\"\"\"\n        lines = self.get_all(chapter_id)\n        for line in lines:\n            self.db.delete(line)\n        self.db.commit()\n        return True\n\n    def get_lines_by_role_id(self, role_id: int):\n        return self.db.execute(select(LinePO).where(LinePO.role_id == role_id)).scalars().all()\n\n    def batch_update_line_order(self, line_orders:List[LineOrderDTO])-> int:\n        \"\"\"批量更新台词的顺序\"\"\"\n        if not line_orders:\n            return 0\n\n        from sqlalchemy import bindparam\n        stmt = (\n            update(LinePO)\n            .where(LinePO.id == bindparam(\"id\"))\n            .values(line_order=bindparam(\"line_order\"))\n        )\n        params = [{\"id\": it.id, \"line_order\": it.line_order} for it in line_orders]\n        res = self.db.execute(stmt, params)  # executemany\n        self.db.commit()\n        return res.rowcount if res.rowcount not in (None, -1) else len(params)\n"
  },
  {
    "path": "SonicVale/app/repositories/llm_provider_repository.py",
    "content": "from typing import List, Optional, Sequence, Any\nfrom sqlalchemy.orm import Session\nfrom sqlalchemy import select, Row, RowMapping\nfrom app.models.po import LLMProviderPO\n\n\nclass LLMProviderRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, llm_provider_id: int) -> Optional[LLMProviderPO]:\n        \"\"\"根据 ID 查询LLM供应商\"\"\"\n        return self.db.get(LLMProviderPO, llm_provider_id)\n\n    def get_all(self) -> Sequence[LLMProviderPO]:\n        \"\"\"获取所有LLM供应商\"\"\"\n        return self.db.execute(select(LLMProviderPO)).scalars().all()\n\n    def create(self, llm_provider_data: LLMProviderPO) -> LLMProviderPO:\n        \"\"\"新建LLM供应商\"\"\"\n        self.db.add(llm_provider_data)\n        self.db.commit()\n        self.db.refresh(llm_provider_data)\n        return llm_provider_data\n\n    def update(self, llm_provider_id: int, llm_provider_data: dict) -> Optional[LLMProviderPO]:\n        \"\"\"更新LLM供应商\"\"\"\n        llm_provider = self.get_by_id(llm_provider_id)\n        if not llm_provider:\n            return None\n        for key, value in llm_provider_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(llm_provider, key, value)\n        self.db.commit()\n        self.db.refresh(llm_provider)\n        return llm_provider\n\n    def delete(self, llm_provider_id: int) -> bool:\n        \"\"\"删除LLM供应商\"\"\"\n        llm_provider = self.get_by_id(llm_provider_id)\n        if not llm_provider:\n            return False\n        self.db.delete(llm_provider)\n        self.db.commit()\n        return True\n\n    def get_by_name(self, name: str) -> Optional[LLMProviderPO]:\n        \"\"\"根据名称查找LLM供应商\"\"\"\n        stmt = select(LLMProviderPO).where(LLMProviderPO.name == name)\n        return self.db.execute(stmt).scalar_one_or_none()\n\n    def search(self, keyword: str) -> Sequence[LLMProviderPO]:\n        \"\"\"模糊搜索\"\"\"\n        stmt = select(LLMProviderPO).where(LLMProviderPO.name.ilike(f\"%{keyword}%\"))\n        return self.db.execute(stmt).scalars().all()\n"
  },
  {
    "path": "SonicVale/app/repositories/multi_emotion_voice_repository.py",
    "content": "from typing import Optional, Sequence, Any\n\nfrom sqlalchemy.orm import Session, Query\n\nfrom app.models.po import MultiEmotionVoicePO\n\n\nclass MultiEmotionVoiceRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[MultiEmotionVoicePO]:\n        \"\"\"通过id获取多情绪音色\"\"\"\n        return self.db.query(MultiEmotionVoicePO).filter(MultiEmotionVoicePO.id == id).first()\n\n    # 根据voice_id,emotion_id,strength_id获取多情绪音色\n    def get_by_voice_id_emotion_id_strength_id(self, voice_id: int, emotion_id: int, strength_id: int) -> type[MultiEmotionVoicePO] | None:\n        \"\"\"根据voice_id,emotion_id,strength_id获取多情绪音色\"\"\"\n        return self.db.query(MultiEmotionVoicePO).filter(MultiEmotionVoicePO.voice_id == voice_id,\n                                                         MultiEmotionVoicePO.emotion_id == emotion_id,\n                                                         MultiEmotionVoicePO.strength_id == strength_id).one_or_none()\n    # 根据voice_id获取多情绪音色\n    def get_by_voice_id(self, voice_id: int) -> Sequence[type[MultiEmotionVoicePO]]:\n        \"\"\"根据voice_id获取多情绪音色\"\"\"\n        return self.db.query(MultiEmotionVoicePO).filter(MultiEmotionVoicePO.voice_id == voice_id).all()\n\n    def get_all(self) -> list[type[MultiEmotionVoicePO]]:\n        \"\"\"获取所有多情绪音色\"\"\"\n        return self.db.query(MultiEmotionVoicePO).all()\n\n    def create(self, multi_emotion_voice: MultiEmotionVoicePO) -> MultiEmotionVoicePO:\n        \"\"\"创建多情绪音色\"\"\"\n\n        self.db.add(multi_emotion_voice)\n        self.db.commit()\n        self.db.refresh(multi_emotion_voice)\n        return multi_emotion_voice\n\n    def update(self, id: int, data: dict) -> Optional[MultiEmotionVoicePO]:\n        \"\"\"更新多情绪音色\"\"\"\n        multi_emotion_voice = self.get_by_id(id)\n        if not multi_emotion_voice:\n            return None\n        for key, value in data.items():\n            if value is not None:\n                setattr(multi_emotion_voice, key, value)\n        self.db.commit()\n        self.db.refresh(multi_emotion_voice)\n        return multi_emotion_voice\n\n    def delete(self, id: int) -> bool:\n        \"\"\"删除多情绪音色\"\"\"\n        multi_emotion_voice = self.get_by_id(id)\n        if not multi_emotion_voice:\n            return False\n        self.db.delete(multi_emotion_voice)\n        self.db.commit()\n        return True\n\n    def delete_multi_emotion_voice_by_voice_id(self, voice_id):\n        \"\"\"通过音色id删除所有的多音色\"\"\"\n        multi_voices = self.get_by_voice_id(voice_id)\n        for multi_voice in multi_voices:\n            self.db.delete(multi_voice)\n        self.db.commit()\n        return True\n\n\n"
  },
  {
    "path": "SonicVale/app/repositories/project_repository.py",
    "content": "from typing import List, Optional, Sequence, Any\nfrom sqlalchemy.orm import Session\nfrom sqlalchemy import select, Row, RowMapping\nfrom app.models.po import ProjectPO\n\n\nclass ProjectRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, project_id: int) -> Optional[ProjectPO]:\n        \"\"\"根据 ID 查询项目\"\"\"\n        return self.db.get(ProjectPO, project_id)\n\n    def get_all(self) -> Sequence[ProjectPO]:\n        \"\"\"获取所有项目\"\"\"\n        return self.db.execute(select(ProjectPO)).scalars().all()\n\n    def create(self, project_data: ProjectPO) -> ProjectPO:\n        \"\"\"新建项目\"\"\"\n        self.db.add(project_data)\n        self.db.commit()\n        self.db.refresh(project_data)\n        return project_data\n\n    def update(self, project_id: int, project_data: dict) -> Optional[ProjectPO]:\n        \"\"\"更新项目\"\"\"\n        project = self.get_by_id(project_id)\n        if not project:\n            return None\n        for key, value in project_data.items():\n            setattr(project, key, value)\n        self.db.commit()\n        self.db.refresh(project)\n        return project\n\n    def delete(self, project_id: int) -> bool:\n        \"\"\"删除项目\"\"\"\n        project = self.get_by_id(project_id)\n        if not project:\n            return False\n        self.db.delete(project)\n        self.db.commit()\n        return True\n\n    def get_by_name(self, name: str) -> Optional[ProjectPO]:\n        \"\"\"根据名称查找项目\"\"\"\n        stmt = select(ProjectPO).where(ProjectPO.name == name)\n        return self.db.execute(stmt).scalar_one_or_none()\n\n    def search(self, keyword: str) -> Sequence[ProjectPO]:\n        \"\"\"模糊搜索\"\"\"\n        stmt = select(ProjectPO).where(ProjectPO.name.ilike(f\"%{keyword}%\"))\n        return self.db.execute(stmt).scalars().all()\n"
  },
  {
    "path": "SonicVale/app/repositories/prompt_repository.py",
    "content": "from typing import List, Optional, Sequence, Any\nfrom sqlalchemy.orm import Session\nfrom sqlalchemy import select, Row, RowMapping\nfrom app.models.po import PromptPO\n\n\nclass PromptRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, prompt_id: int) -> Optional[PromptPO]:\n        \"\"\"根据 ID 查询提示词\"\"\"\n        return self.db.get(PromptPO, prompt_id)\n\n    def get_all(self) -> Sequence[PromptPO]:\n        \"\"\"获取所有提示词\"\"\"\n        return self.db.execute(select(PromptPO)).scalars().all()\n\n    def create(self, prompt_data: PromptPO) -> PromptPO:\n        \"\"\"新建提示词\"\"\"\n        self.db.add(prompt_data)\n        self.db.commit()\n        self.db.refresh(prompt_data)\n        return prompt_data\n\n    def update(self, prompt_id: int, prompt_data: dict) -> Optional[PromptPO]:\n        \"\"\"更新提示词\"\"\"\n        prompt = self.get_by_id(prompt_id)\n        if not prompt:\n            return None\n        for key, value in prompt_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(prompt, key, value)\n        self.db.commit()\n        self.db.refresh(prompt)\n        return prompt\n\n    def delete(self, prompt_id: int) -> bool:\n        \"\"\"删除提示词\"\"\"\n        prompt = self.get_by_id(prompt_id)\n        if not prompt:\n            return False\n        self.db.delete(prompt)\n        self.db.commit()\n        return True\n\n    def get_by_name(self, name: str) -> Optional[PromptPO]:\n        \"\"\"根据名称查找提示词\"\"\"\n        stmt = select(PromptPO).where(PromptPO.name == name)\n        return self.db.execute(stmt).scalar_one_or_none()\n\n    # 根据任务查询，返回多个提示词\n    def get_by_task(self, task: str) -> Sequence[PromptPO]:\n        stmt = select(PromptPO).where(PromptPO.task == task)\n        return self.db.execute(stmt).scalars().all()\n\n\n    def search(self, keyword: str) -> Sequence[PromptPO]:\n        \"\"\"模糊搜索\"\"\"\n        stmt = select(PromptPO).where(PromptPO.name.ilike(f\"%{keyword}%\"))\n        return self.db.execute(stmt).scalars().all()\n"
  },
  {
    "path": "SonicVale/app/repositories/role_repository.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy import Sequence, select\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import RolePO\n\n\nclass RoleRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[RolePO]:\n        \"\"\"根据 ID 查询角色\"\"\"\n        return self.db.get(RolePO, id)\n\n    def get_all(self,project_id: int) -> Sequence[RolePO]:\n        \"\"\"获取项目下所有角色\"\"\"\n        return self.db.execute(select(RolePO).where(RolePO.project_id == project_id)).scalars().all()\n\n\n    def create(self, data: RolePO) -> RolePO:\n        \"\"\"新增角色\"\"\"\n        self.db.add(data)\n        self.db.commit()\n        self.db.refresh(data)\n        return data\n\n\n    def update(self, role_id: int, role_data: dict) -> Optional[RolePO]:\n        \"\"\"更新角色信息\"\"\"\n        role = self.get_by_id(role_id)\n        if not role:\n            return None\n        for key, value in role_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(role, key, value)\n\n        self.db.commit()\n        self.db.refresh(role)\n        return role\n\n    def delete(self, role_id: int) -> bool:\n        \"\"\"删除项目\"\"\"\n        role = self.get_by_id(role_id)\n        if not role:\n            return False\n        self.db.delete(role)\n        self.db.commit()\n        return True\n\n\n    def get_by_name(self, name: str,project_id: int) -> Optional[RolePO]:\n        \"\"\"根据名称查找项目下的角色信息\"\"\"\n        return self.db.execute(select(RolePO).where(RolePO.name == name,RolePO.project_id == project_id)).scalars().first()\n\n\n"
  },
  {
    "path": "SonicVale/app/repositories/strength_repository.py",
    "content": "from typing import Optional, Sequence\n\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import StrengthPO\n\n\nclass StrengthRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[StrengthPO]:\n        \"\"\"通过id获取情绪强弱\"\"\"\n        return self.db.query(StrengthPO).filter(StrengthPO.id == id).first()\n\n    def get_by_name(self, name: str) -> Optional[StrengthPO]:\n        \"\"\"通过名称获取情绪强弱\"\"\"\n        return self.db.query(StrengthPO).filter(StrengthPO.name == name).first()\n\n    def get_all(self) -> list[type[StrengthPO]]:\n        \"\"\"获取所有情绪强弱\"\"\"\n        return self.db.query(StrengthPO).all()\n\n    def create(self, strength: StrengthPO) -> StrengthPO:\n        \"\"\"创建情绪强弱\"\"\"\n        self.db.add(strength)\n        self.db.commit()\n        self.db.refresh(strength)\n        return strength\n\n    def update(self, id: int, data: dict) -> Optional[StrengthPO]:\n        \"\"\"更新情绪强弱\"\"\"\n        strength = self.get_by_id(id)\n        if not strength:\n            return None\n        for key, value in data.items():\n            if value is not None:\n                setattr(strength, key, value)\n        self.db.commit()\n        self.db.refresh(strength)\n        return strength\n\n    def delete(self, id: int) -> bool:\n        \"\"\"删除情绪强弱\"\"\"\n        strength = self.get_by_id(id)\n        if not strength:\n            return False\n        self.db.delete(strength)\n        self.db.commit()\n        return True\n\n\n"
  },
  {
    "path": "SonicVale/app/repositories/tts_provider_repository.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy import Sequence, select\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import TTSProviderPO\n\n\nclass TTSProviderRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[TTSProviderPO]:\n        \"\"\"根据 ID 查询tts供应商\"\"\"\n        return self.db.get(TTSProviderPO, id)\n\n    def get_all(self) -> Sequence[TTSProviderPO]:\n        \"\"\"获取tts下所有tts供应商\"\"\"\n        return self.db.execute(select(TTSProviderPO)).scalars().all()\n\n\n    def create(self, data: TTSProviderPO) -> TTSProviderPO:\n\n        \"\"\"新增tts供应商\"\"\"\n        self.db.add(data)\n        self.db.commit()\n        self.db.refresh(data)\n        return data\n\n\n    def update(self, tts_provider_id: int, voice_data: dict) -> Optional[TTSProviderPO]:\n        \"\"\"更新tts供应商信息\"\"\"\n        voice = self.get_by_id(tts_provider_id)\n        if not voice:\n            return None\n        for key, value in voice_data.items():\n            if value is not None:  # 只更新不为空的字段\n                setattr(voice, key, value)\n\n        self.db.commit()\n        self.db.refresh(voice)\n        return voice\n\n    # def delete(self, voice_id: int) -> bool:\n    #     \"\"\"删除项目\"\"\"\n    #     voice = self.get_by_id(voice_id)\n    #     if not voice:\n    #         return False\n    #     self.db.delete(voice)\n    #     self.db.commit()\n    #     return True\n    #\n    #\n    def get_by_name(self, name: str) -> Optional[TTSProviderPO]:\n        \"\"\"根据名称查找项目下的tts供应商信息\"\"\"\n        return self.db.execute(select(TTSProviderPO).where(TTSProviderPO.name == name)).scalars().first()\n\n\n"
  },
  {
    "path": "SonicVale/app/repositories/voice_repository.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy import Sequence, select\nfrom sqlalchemy.orm import Session\n\nfrom app.models.po import VoicePO\n\n\nclass VoiceRepository:\n    def __init__(self, db: Session):\n        self.db = db\n\n    def get_by_id(self, id: int) -> Optional[VoicePO]:\n        \"\"\"根据 ID 查询音色\"\"\"\n        return self.db.get(VoicePO, id)\n\n    def get_all(self,tts_id: int) -> Sequence[VoicePO]:\n        \"\"\"获取tts下所有音色\"\"\"\n        return self.db.execute(select(VoicePO).where(VoicePO.tts_provider_id == tts_id)).scalars().all()\n\n    def get_by_ids(self, tts_id: int, ids: list[int]) -> Sequence[VoicePO]:\n        \"\"\"根据ids获取tts下的音色\"\"\"\n        if not ids:\n            return []\n        return self.db.execute(\n            select(VoicePO).where(VoicePO.tts_provider_id == tts_id, VoicePO.id.in_(ids))\n        ).scalars().all()\n\n\n    def create(self, data: VoicePO) -> VoicePO:\n        \"\"\"新增音色\"\"\"\n        self.db.add(data)\n        self.db.commit()\n        self.db.refresh(data)\n        return data\n\n\n    def update(self, voice_id: int, voice_data: dict) -> Optional[VoicePO]:\n        \"\"\"更新音色信息\"\"\"\n        voice = self.get_by_id(voice_id)\n        if not voice:\n            return None\n        for key, value in voice_data.items():\n            setattr(voice, key, value)\n\n        self.db.commit()\n        self.db.refresh(voice)\n        return voice\n\n    def delete(self, voice_id: int) -> bool:\n        \"\"\"删除项目\"\"\"\n        voice = self.get_by_id(voice_id)\n        if not voice:\n            return False\n        self.db.delete(voice)\n        self.db.commit()\n        return True\n\n\n    def get_by_name(self, name: str,tts_id: int) -> Optional[VoicePO]:\n        \"\"\"根据名称查找项目下的音色信息\"\"\"\n        return self.db.execute(select(VoicePO).where(VoicePO.name == name,VoicePO.tts_provider_id == tts_id)).scalars().first()\n\n\n"
  },
  {
    "path": "SonicVale/app/routers/chapter_router.py",
    "content": "# 初始化 router\nimport asyncio\nimport io\nimport json\nimport logging\nimport os\nimport traceback\n\nfrom typing import List\n\n\nfrom sqlalchemy.orm import Session\nfrom fastapi import APIRouter, Depends, HTTPException, Form\n\n\nfrom app.core.response import Res\nfrom app.core.text_correct_engine import TextCorrectorFinal\nfrom app.core.ws_manager import manager\nfrom app.db.database import get_db, SessionLocal\nfrom app.dto.chapter_dto import ChapterResponseDTO, ChapterCreateDTO\nfrom app.dto.line_dto import LineInitDTO, LineCreateDTO, LineResponseDTO\nfrom app.entity.chapter_entity import ChapterEntity\nfrom app.repositories.chapter_repository import ChapterRepository\nfrom app.repositories.emotion_repository import EmotionRepository\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.repositories.prompt_repository import PromptRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.repositories.strength_repository import StrengthRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.repositories.voice_repository import VoiceRepository\n\nfrom app.services.chapter_service import ChapterService\nfrom app.services.emotion_service import EmotionService\nfrom app.services.line_service import LineService\nfrom app.services.multi_emotion_voice_service import MultiEmotionVoiceService\nfrom app.services.project_service import ProjectService\nfrom app.services.prompt_service import PromptService\nfrom app.services.role_service import RoleService\nfrom app.services.strength_service import StrengthService\nfrom app.services.voice_service import VoiceService\n\nrouter = APIRouter(prefix=\"/chapters\", tags=[\"Chapters\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_chapter_service(db: Session = Depends(get_db)) -> ChapterService:\n    repository = ChapterRepository(db)  # ✅ 传入 db\n    return ChapterService(repository)\n\ndef get_line_service(db: Session = Depends(get_db)) -> LineService:\n    repository = LineRepository(db)\n    role_repository = RoleRepository(db)\n    tts_provider_repository = TTSProviderRepository(db)\n    llm_provider_repository = LLMProviderRepository(db)\n    return LineService(repository, role_repository, tts_provider_repository, llm_provider_repository)\n\ndef get_project_service(db: Session = Depends(get_db)) -> ProjectService:\n    repository = ProjectRepository(db)\n    return ProjectService(repository)\n\ndef get_voice_service(db: Session = Depends(get_db)) -> VoiceService:\n    repository = VoiceRepository(db)\n    multi_emotion_voice_repository = MultiEmotionVoiceRepository(db)\n    return VoiceService(repository,multi_emotion_voice_repository)\n\ndef get_role_service(db: Session = Depends(get_db)) -> RoleService:\n    repository = RoleRepository(db)\n    return RoleService(repository)\n\ndef get_emotion_service(db: Session = Depends(get_db)) -> EmotionService:\n    repository = EmotionRepository(db)\n    return EmotionService(repository)\n\ndef get_strength_service(db: Session = Depends(get_db)) -> StrengthService:\n    repository = StrengthRepository(db)\n    return StrengthService(repository)\n\ndef get_multi_emotion_voice_service(db: Session = Depends(get_db)) -> MultiEmotionVoiceService:\n    repository = MultiEmotionVoiceRepository(db)\n    return MultiEmotionVoiceService(repository)\n\ndef get_prompt_service(db: Session = Depends(get_db)) -> PromptService:\n    repository = PromptRepository(db)\n    return PromptService(repository)\n\n@router.post(\"\", response_model=Res[ChapterResponseDTO],\n             summary=\"创建章节\",\n             description=\"根据项目ID创建章节，章节名称在同一项目下不可重复\" )\nasync def create_chapter(dto: ChapterCreateDTO, chapter_service: ChapterService = Depends(get_chapter_service),\n                   project_service: ProjectService = Depends(get_project_service)):\n    \"\"\"创建章节\"\"\"\n    try:\n        # DTO → Entity\n        entity = ChapterEntity(**dto.__dict__)\n        # 判断project_id是否存在\n        project = project_service.get_project(dto.project_id)\n        if project is None:\n            return Res(data=None, code=400, message=f\"项目 '{dto.project_id}' 不存在\")\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes = chapter_service.create_chapter(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = ChapterResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"章节 '{entity.title}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n@router.get(\"/{chapter_id}\", response_model=Res[ChapterResponseDTO],\n            summary=\"查询章节\",\n            description=\"根据章节id查询章节信息\")\nasync def get_chapter(chapter_id: int, chapter_service: ChapterService = Depends(get_chapter_service)):\n    entity = chapter_service.get_chapter(chapter_id)\n    if entity:\n        res = ChapterResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"项目不存在\")\n\n@router.get(\"/project/{project_id}\", response_model=Res[List[ChapterResponseDTO]],\n            summary=\"查询项目下的所有章节\",\n            description=\"根据项目id查询项目下的所有章节信息\")\nasync def get_all_chapters(project_id: int, chapter_service: ChapterService = Depends(get_chapter_service)):\n    entities = chapter_service.get_all_chapters(project_id)\n    if entities:\n        res = [ChapterResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=404, message=\"项目不存在章节\")\n\n# 修改，传入的参数是id\n@router.put(\"/{chapter_id}\", response_model=Res[ChapterCreateDTO],\n            summary=\"修改章节\",\n            description=\"根据章节id修改章节信息,并且不能修改项目id\")\nasync def update_chapter(chapter_id: int, dto: ChapterCreateDTO, chapter_service: ChapterService = Depends(get_chapter_service)):\n    chapter = chapter_service.get_chapter(chapter_id)\n    if chapter is None:\n        return Res(data=None, code=404, message=\"章节不存在\")\n    res = chapter_service.update_chapter(chapter_id, dto.dict(exclude_unset=True))\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败\")\n\n\n# 根据id，删除\n@router.delete(\"/{chapter_id}\", response_model=Res,\n               summary=\"删除章节\",\n               description=\"根据章节id删除章节信息,并且级联删除\")\nasync def delete_chapter(chapter_id: int, chapter_service: ChapterService = Depends(get_chapter_service)):\n    success = chapter_service.delete_chapter(chapter_id)\n\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或章节不存在\")\n\n\n# 根据内容进行解析得到json,初次解析，然后可编辑角色昵称以及内容，以及可以合并上下或者增加。（json都是多条，角色+台词）\n@router.get(\n    \"/get-lines/{project_id}/{chapter_id}\",\n    response_model=Res[str],\n    summary=\"根据内容进行解析得到json\",\n    description=\"根据内容进行解析得到json\"\n)\nasync def get_lines(\n    project_id: int,\n    chapter_id: int,\n    chapter_service: ChapterService = Depends(get_chapter_service),\n    line_service: LineService = Depends(get_line_service),\n    role_service: RoleService = Depends(get_role_service),\n    emotion_service: EmotionService = Depends(get_emotion_service),\n    strength_service: StrengthService = Depends(get_strength_service),\n    prompt_service: PromptService = Depends(get_prompt_service),\n    project_service: ProjectService = Depends(get_project_service)\n):\n    # 判断章节内容是否存在\n    chapter = chapter_service.get_chapter(chapter_id)\n    if chapter.text_content is None:\n        return Res(data=None, code=400, message=\"章节内容不存在\")\n    try:\n        contents = chapter_service.split_text(chapter_id, 1500)\n        logging.info(\"内容划分为 %s 段\", len(contents))\n    except Exception as e:\n        logging.error(f\"章节拆分失败: {e}\\n{traceback.format_exc()}\")\n        return Res(data=None, code=500, message=\"章节拆分失败\")\n\n    all_line_data = []\n\n    try:\n        roles = role_service.get_all_roles(project_id)\n        roles = set(role.name for role in roles)\n        emotions = emotion_service.get_all_emotions()\n        strengths = strength_service.get_all_strengths()\n\n        emotion_names = [emotion.name for emotion in emotions]\n        strength_names = [strength.name for strength in strengths]\n        emotions_dict = {emotion.name: emotion.id for emotion in emotions}\n        strengths_dict = {strength.name: strength.id for strength in strengths}\n    except Exception as e:\n        logging.error(f\"初始化角色/情绪/强度失败: {e}\\n{traceback.format_exc()}\")\n        return Res(data=None, code=500, message=\"初始化角色/情绪/强度失败\")\n\n    project = project_service.get_project(project_id)\n    # 精准填充\n    is_precise_fill = project.is_precise_fill\n    # 判断tts，llm，model是否存在\n    if project.tts_provider_id is None or project.llm_provider_id is None or project.llm_model is None:\n        return Res(data=None, code=500, message=\"tts/llm/model不存在\")\n\n\n    prompt = prompt_service.get_prompt(project.prompt_id) if project else None\n    if prompt is None:\n        return Res(data=None, code=500, message=\"提示词不存在\")\n\n    for idx, content in enumerate(contents):\n        logging.info(f\"解析第 {idx + 1}/{len(contents)} 段...\")\n\n        try:\n            roles_list = list(roles)\n            result = chapter_service.para_content(\n                prompt.content, chapter_id, content,\n                roles_list, emotion_names, strength_names,is_precise_fill\n            )\n\n            if not result[\"success\"]:\n                return Res(\n                data=None,\n                code=500,\n                message=result[\"message\"]\n                )\n\n            # 提取lines_data中的角色\n            lines_data = result[\"data\"]\n            for line_data in lines_data:\n                roles.add(line_data.role_name)\n\n            all_line_data.extend(lines_data)\n\n        except Exception as e:\n            logging.error(\n                f\"解析第 {idx + 1} 段失败: {e}\\n{traceback.format_exc()}\"\n            )\n            return Res(data=None, code=500, message=f\"解析失败：第 {idx + 1} 段处理出错，错误信息：{e}\")\n\n    try:\n        audio_path = os.path.join(project.project_root_path,str(project_id),str(chapter_id),\"audio\")\n        os.makedirs(audio_path, exist_ok=True)\n        line_service.update_init_lines(\n            all_line_data, project_id, chapter_id, emotions_dict, strengths_dict,audio_path\n        )\n    except Exception as e:\n        logging.error(f\"写入数据库失败: {e}\\n{traceback.format_exc()}\")\n        return Res(data=None, code=500, message=\"写入数据库失败\")\n\n    return Res(data=None, code=200, message=\"解析成功\")\n\n\n# 导出LLM prompt指令\n@router.get(\"/export-llm-prompt/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"导出LLM prompt指令\",description=\"导出LLM prompt指令\")\nasync def export_llm_prompt(project_id:int,chapter_id: int, chapter_service: ChapterService = Depends(get_chapter_service),\n                            project_service = Depends(get_project_service),\n                            prompt_service: PromptService = Depends(get_prompt_service),\n                            role_service: RoleService = Depends(get_role_service),\n                            emotion_service: EmotionService = Depends(get_emotion_service),\n                            strength_service: StrengthService = Depends(get_strength_service)):\n    try:\n        roles = role_service.get_all_roles(project_id)\n        roles = [role.name for role in roles]\n        emotions = emotion_service.get_all_emotions()\n        strengths = strength_service.get_all_strengths()\n\n        emotion_names = [emotion.name for emotion in emotions]\n        strength_names = [strength.name for strength in strengths]\n    except Exception as e:\n        return Res(data=None, code=500, message=\"初始化角色/情绪/强度失败\")\n\n    project = project_service.get_project(project_id)\n    prompt = prompt_service.get_prompt(project.prompt_id) if project else None\n    chapter = chapter_service.get_chapter(chapter_id)\n    content = chapter.text_content\n    res = chapter_service.fill_prompt(prompt.content, roles, emotion_names, strength_names, content)\n    # record\n    return Res(data=res, code=200, message=\"导出成功\")\n\n# 解析第三方的json\n@router.post(\"/import-lines/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"导入第三方json\",description=\"导入第三方json\")\nasync def import_lines(project_id: int,chapter_id: int,data:str=Form( ...),line_service: LineService = Depends(get_line_service),\n                       emotion_service: EmotionService = Depends(get_emotion_service),\n                       strength_service: StrengthService = Depends(get_strength_service),\n                       project_service: ProjectService = Depends(get_project_service),\n                       chapter_service: ChapterService = Depends(get_chapter_service)):\n    # 解析data\n    lines_data = json.loads(data)\n    # 转化成List[LineInitDTO]\n    emotions = emotion_service.get_all_emotions()\n    strengths = strength_service.get_all_strengths()\n\n    emotions_dict = {emotion.name: emotion.id for emotion in emotions}\n    strengths_dict = {strength.name: strength.id for strength in strengths}\n    # 精准填充\n    project = project_service.get_project(project_id)\n    is_precise_fill = project.is_precise_fill\n    \n    if is_precise_fill == 1:\n        # 获取章节内容\n        content = chapter_service.get_chapter(chapter_id).text_content\n        if not content:\n            return Res(data=None, code=500, message=\"章节内容为空\")\n        corrector = TextCorrectorFinal()\n        lines_data = corrector.correct_ai_text(content, lines_data)\n    lines_data = [LineInitDTO(**line) for line in lines_data]\n\n\n    audio_path = os.path.join(project.project_root_path,str(project_id),str(chapter_id),\"audio\")\n    os.makedirs(audio_path, exist_ok=True)\n    line_service.update_init_lines(lines_data, project_id, chapter_id, emotions_dict, strengths_dict,audio_path)\n    return Res(data=None, code=200, message=\"导入成功\")\n\n\n\n# @router.post(\"/save-init-lines/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"保存初始化调整后的解析内容\",description=\"保存初始化调整后的解析内容\")\n# async def update_init_lines(project_id: int,chapter_id: int,lines: List[LineInitDTO], chapter_service: ChapterService = Depends(get_chapter_service)):\n#     chapter_service.update_init_lines(lines,project_id,chapter_id)\n#     return Res(data=None, code=200, message=\"保存成功\")\n\n# 绑定音色就是采用的修改角色信息\n\n# 获取章节下所有台词\n\n\n\n# 传入台词实体，然后生成音频\n# @router.post(\"/generate-audio/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"生成音频\",description=\"生成音频\")\n# async def generate_audio(project_id: int,chapter_id: int,\n#                          dto: LineCreateDTO, chapter_service: ChapterService = Depends(get_chapter_service),\n#                          voice_service: VoiceService = Depends(get_voice_service),\n#                          role_service: RoleService = Depends(get_role_service),\n#                          project_service: ProjectService = Depends(get_project_service)):\n#     \"\"\"生成音频\"\"\"\n#     # 获取角色绑定的音色的reference_path\n#     role = role_service.get_role(dto.role_id)\n#     voice = voice_service.get_voice(role.default_voice_id)\n#     project = project_service.get_project(project_id)\n#     save_path = dto.audio_path\n#     res = chapter_service.generate_audio(voice.reference_path,project.tts_provider_id,dto.text_content,save_path=save_path)\n#     return Res(data=None, code=200, message=\"生成成功\")\n\n# 合并结果并导出\n# @router.get(\"/export-audio/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"合并结果并导出\",description=\"合并结果并导出\")\n# async def export_audio(project_id: int,chapter_id: int, chapter_service: ChapterService = Depends(get_chapter_service))\n#     res = chapter_service.export_audio(project_id,chapter_id)\n\n# 添加智能匹配角色和音色的功能\n@router.post(\"/add-smart-role-and-voice/{project_id}/{chapter_id}\",response_model=Res[List],summary=\"添加智能匹配角色和音色的功能\",description=\"添加智能匹配角色和音色的功能\")\nasync def add_smart_role_and_voice(project_id: int,chapter_id: int,\n                                   chapter_service: ChapterService = Depends(get_chapter_service),\n                                   project_service: ProjectService = Depends(get_project_service),\n                                   voice_service: VoiceService = Depends(get_voice_service),\n                                   role_service: RoleService = Depends(get_role_service)):\n    # 获取项目信息\n    project = project_service.get_project(project_id)\n    # 首先获取项目下所有角色\n    roles = role_service.get_all_roles(project_id)\n#     将所有角色未绑定音色的角色提取出来\n    roles_no_voice = [role for role in roles if role.default_voice_id is None]\n    # 只要角色name\n    role_names = [role.name for role in roles_no_voice]\n    # 获取所有音色\n    voices = voice_service.get_all_voices(project.tts_provider_id)\n    # 只要音色的名字和描述\n    voice_names = [\n        {\n            \"name\": voice.name,\n            \"description\": voice.description\n        }\n        for voice in voices\n    ]\n    # 获取原文内容\n    content = chapter_service.get_chapter(chapter_id).text_content\n    res,data = chapter_service.add_smart_role_and_voice(project,content,role_names,voice_names)\n    # 将data中的每一个元素转化为RoleBindVoiceDTO\n    # data = [RoleBindVoiceDTO(**item) for item in data]\n    if res:\n        return Res(data=data, code=200, message=\"智能匹配成功\")\n    else:\n        return Res(data=None, code=500, message=\"智能匹配失败\")\n"
  },
  {
    "path": "SonicVale/app/routers/emotion_router.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.emotion_dto import EmotionResponseDTO, EmotionCreateDTO\nfrom app.entity.emotion_entity import EmotionEntity\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.repositories.emotion_repository import EmotionRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.services.line_service import LineService\nfrom app.services.project_service import ProjectService\nfrom app.services.emotion_service import EmotionService\n\nrouter = APIRouter(prefix=\"/emotions\", tags=[\"Emotions\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_emotion_service(db: Session = Depends(get_db)) -> EmotionService:\n    repository = EmotionRepository(db)\n    return EmotionService(repository)\n\n@router.post(\"\", response_model=Res[EmotionResponseDTO],\n             summary=\"创建情绪枚举\",\n             description=\"根据项目ID创建情绪枚举，情绪枚举名称在同一项目下不可重复\" )\ndef create_emotion(dto: EmotionCreateDTO, emotion_service: EmotionService = Depends(get_emotion_service)):\n    \"\"\"创建情绪枚举\"\"\"\n    try:\n        # DTO → Entity\n        entity = EmotionEntity(**dto.__dict__)\n\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes = emotion_service.create_emotion(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = EmotionResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"情绪枚举 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n@router.get(\"/{emotion_id}\", response_model=Res[EmotionResponseDTO],\n            summary=\"查询情绪枚举\",\n            description=\"根据情绪枚举id查询情绪枚举信息\")\ndef get_emotion(emotion_id: int, emotion_service: EmotionService = Depends(get_emotion_service)):\n    entity = emotion_service.get_emotion(emotion_id)\n    if entity:\n        res = EmotionResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"情绪枚举不存在\")\n\n@router.get(\"\", response_model=Res[List[EmotionResponseDTO]],\n            summary=\"查询所有情绪枚举\",\n            description=\"根据所有情绪枚举信息\")\ndef get_all_emotions(emotion_service: EmotionService = Depends(get_emotion_service)):\n    entities = emotion_service.get_all_emotions()\n    if entities:\n        res = [EmotionResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=404, message=\"项目不存在情绪枚举\")\n\n# 修改，传入的参数是id\n@router.put(\"/{emotion_id}\", response_model=Res[EmotionCreateDTO],\n            summary=\"修改情绪枚举信息\",\n            description=\"根据情绪枚举id修改情绪枚举信息,并且不能修改项目id\")\ndef update_emotion(emotion_id: int, dto: EmotionCreateDTO, emotion_service: EmotionService = Depends(get_emotion_service)):\n    emotion = emotion_service.get_emotion(emotion_id)\n    if emotion is None:\n        return Res(data=None, code=404, message=\"情绪枚举不存在\")\n    res = emotion_service.update_emotion(emotion_id, dto.dict(exclude_unset=True))\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败,情绪枚举已存在\")\n\n\n# 根据id，删除，不开放\n@router.delete(\"/{emotion_id}\", response_model=Res,\n               summary=\"删除情绪枚举\",\n               description=\"根据情绪枚举id删除情绪枚举信息\")\ndef delete_emotion(emotion_id: int, emotion_service: EmotionService = Depends(get_emotion_service)):\n    success = emotion_service.delete_emotion(emotion_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或情绪枚举不存在\")\n\n\n\n"
  },
  {
    "path": "SonicVale/app/routers/line_router.py",
    "content": "import asyncio\nimport os\nimport logging\nimport shutil\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import List, Optional\n\nfrom fastapi import APIRouter, Depends, HTTPException, Body, Request, Query\nfrom sqlalchemy.orm import Session\n\nfrom app.core.config import getConfigPath\nfrom app.core.response import Res\nfrom app.core.ws_manager import manager\nfrom app.db.database import get_db, SessionLocal\nfrom app.dto.line_dto import LineResponseDTO, LineCreateDTO, LineOrderDTO, LineAudioProcessDTO\nfrom app.entity.line_entity import LineEntity\nfrom app.repositories.chapter_repository import ChapterRepository\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.repositories.voice_repository import VoiceRepository\nfrom app.services.chapter_service import ChapterService\nfrom app.services.project_service import ProjectService\nfrom app.services.line_service import LineService\nfrom app.services.role_service import RoleService\nfrom app.services.voice_service import VoiceService\n\nrouter = APIRouter(prefix=\"/lines\", tags=[\"Lines\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_line_service(db: Session = Depends(get_db)) -> LineService:\n    repository = LineRepository(db)\n    role_repository = RoleRepository(db)\n    tts_repository = TTSProviderRepository(db)\n    llm_repository = LLMProviderRepository(db)\n    return LineService(repository, role_repository, tts_repository, llm_repository)\ndef get_project_service(db: Session = Depends(get_db)) -> ProjectService:\n    repository = ProjectRepository(db)\n    return ProjectService(repository)\n\ndef get_chapter_service(db: Session = Depends(get_db)) -> ChapterService:\n    repository = ChapterRepository(db)\n    return ChapterService(repository)\n\ndef get_voice_service(db: Session = Depends(get_db)) -> VoiceService:\n    repository = VoiceRepository(db)\n    multi_emotion_voice_repository = MultiEmotionVoiceRepository(db)\n    return VoiceService(repository, multi_emotion_voice_repository)\n\ndef get_role_service(db: Session = Depends(get_db)) -> RoleService:\n    repository = RoleRepository(db)\n    return RoleService(repository)\n@router.post(\"/{project_id}\", response_model=Res[LineResponseDTO],\n             summary=\"创建台词\",\n             description=\"根据项目ID创建台词\" )\ndef create_line(project_id:int,dto: LineCreateDTO, line_service: LineService = Depends(get_line_service),\n                   project_service: ProjectService = Depends(get_project_service),\n                    chapter_service : ChapterService = Depends(get_chapter_service)):\n    \"\"\"创建台词\"\"\"\n    try:\n        # DTO → Entity\n        entity = LineEntity(**dto.__dict__)\n        # 判断project_id是否存在\n        project = project_service.get_project(project_id)\n        if project is None:\n            return Res(data=None, code=400, message=f\"项目 '{project_id}' 不存在\")\n\n        chapter = chapter_service.get_chapter(dto.chapter_id)\n        if chapter is None:\n            return Res(data=None, code=400, message=f\"章节 '{dto.chapter_id}' 不存在\")\n        # 调用 Service 创建项目（返回 True/False）\n\n        entityRes = line_service.create_line(entity)\n\n        # 新增台词,这里搞个audio_path\n        audio_path = os.path.join(project.project_root_path, str(project_id), str(dto.chapter_id), \"audio\")\n        os.makedirs(audio_path, exist_ok=True)\n        res_path = os.path.join(audio_path, \"id_\" + str(entityRes.id) + \".wav\")\n        line_service.update_line(entityRes.id, {\"audio_path\": res_path})\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = LineResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"台词 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n@router.get(\"/{line_id}\", response_model=Res[LineResponseDTO],\n            summary=\"查询台词\",\n            description=\"根据台词id查询台词信息\")\ndef get_line(line_id: int, line_service: LineService = Depends(get_line_service)):\n    entity = line_service.get_line(line_id)\n    if entity:\n        res = LineResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"项目不存在\")\n\n@router.get(\"/lines/{chapter_id}\", response_model=Res[List[LineResponseDTO]],\n            summary=\"查询章节下的所有台词\",\n            description=\"根据章节id查询章节下的所有台词信息\")\ndef get_all_lines(chapter_id: int, line_service: LineService = Depends(get_line_service)):\n    entities = line_service.get_all_lines(chapter_id)\n    if entities:\n        res = [LineResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=200, message=\"章节不存在台词\")\n\n# 修改，传入的参数是id\n@router.put(\"/{line_id}\", response_model=Res[LineCreateDTO],\n            summary=\"修改台词信息\",\n            description=\"根据台词id修改台词信息,并且不能修改章节id\")\ndef update_line(line_id: int, dto: LineCreateDTO, line_service: LineService = Depends(get_line_service)):\n    line = line_service.get_line(line_id)\n    if line is None:\n        return Res(data=None, code=404, message=\"台词不存在\")\n    res = line_service.update_line(line_id, dto.dict(exclude_unset=True))\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败\")\n\n\n# 根据id，删除\n@router.delete(\"/{line_id}\", response_model=Res,\n               summary=\"删除台词\",\n               description=\"根据台词id删除台词信息\")\ndef delete_line(line_id: int, line_service: LineService = Depends(get_line_service)):\n    success = line_service.delete_line(line_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或台词不存在\")\n\n# 删除章节下所有台词\n@router.delete(\"/lines/{chapter_id}\", response_model=Res,summary=\"删除章节下所有台词\",description=\"根据章节id删除章节下的所有台词信息\")\ndef delete_all_lines(chapter_id: int, line_service: LineService = Depends(get_line_service)):\n    success = line_service.delete_all_lines(chapter_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或台词不存在\")\n\n\n\n\n\n@router.put(\"/batch/orders\", response_model=Res[bool])\ndef batch_update_line_order(\n    line_orders: List[LineOrderDTO] = Body(...),  # 关键：明确从 body 读取“数组”\n    line_service: LineService = Depends(get_line_service),\n):\n    res = line_service.batch_update_line_order(line_orders)\n    return Res(data=res, code=200, message=\"更新成功\")\n\n# 完成配音时候，更新音频路径，保证顺序一致\n@router.put(\"/{line_id}/audio_path\", response_model=Res[bool])\ndef update_line_audio_path(\n        line_id: int,\n    dto: LineCreateDTO,  # 关键：明确从 body 读取“数组”\n    line_service: LineService = Depends(get_line_service),\n):\n    res = line_service.update_audio_path(line_id,dto)\n    if not res:\n        return Res(data=None, code=400, message=\"更新失败\")\n    return Res(data=res, code=200, message=\"更新成功\")\n\n\n\n@router.post(\"/generate-audio/{project_id}/{chapter_id}\")\nasync def generate_audio(request: Request, project_id: int, dto: LineCreateDTO,line_service: LineService = Depends(get_line_service)):\n    q = request.app.state.tts_queue  # 👈 永远拿到已初始化的同一份队列\n    if q.full():\n        # 可选：带上 Retry-After 头\n        raise HTTPException(status_code=429, detail=\"队列已满，请稍后重试\")\n    q.put_nowait((project_id, dto))\n    queue_size = q.qsize()  # 入队后的队列大小\n    line_service.update_line(dto.id, {\"status\": \"processing\"})\n    \n    # 入队后立即广播队列大小，让前端实时看到更新\n    await manager.broadcast({\n        \"event\": \"line_update\",\n        \"line_id\": dto.id,\n        \"status\": \"queued\",\n        \"progress\": queue_size,\n        \"meta\": f\"已入队，等待生成\"\n    })\n    \n    logging.info(\"队列剩余数量: %s\", queue_size)\n    return {\"code\": 200, \"message\": \"已入队\", \"data\": {\"line_id\": dto.id}}\n\n\n# 改为异步任务\n\n# @router.post(\"/generate-audio/{project_id}/{chapter_id}\")\n# async def generate_audio(project_id : int, chapter_id: int, dto: LineCreateDTO):\n#     # 立即返回，不阻塞\n#     asyncio.create_task(_run_line_tts(project_id,dto))\n#     return {\"code\": 200, \"message\": \"已入队\", \"data\": {\"line_id\": dto.id}}\n#\n#\n# TTS_EXECUTOR = ThreadPoolExecutor(max_workers=4)  # 线程池大小\n# TTS_SEMAPHORE = asyncio.Semaphore(1)              # 最多 4 个并行 TTS\n# async def _run_line_tts(project_id:int,dto: LineCreateDTO):\n#     db = SessionLocal()\n#     line_service = get_line_service(db)\n#     role_service = get_role_service( db)\n#     voice_service = get_voice_service(db)\n#     project_service = get_project_service(db)\n#     try:\n#         # 1) 更新为 running\n#         line_service.update_line(dto.id, {\"status\": \"processing\"})\n#         print(\"开始生成\")\n#         await manager.broadcast({\n#             \"event\": \"line_update\",\n#             \"line_id\": dto.id,\n#             \"status\": \"processing\",\n#             \"progress\": 0,\n#             \"meta\": f\"角色 {dto.role_id} 开始生成\"\n#         })\n#\n#         # 2) 模拟进度\n#         # 获取角色绑定的音色的reference_path\n#         role = role_service.get_role(dto.role_id)\n#         voice = voice_service.get_voice(role.default_voice_id)\n#         project = project_service.get_project(project_id)\n#         save_path = dto.audio_path\n#         loop = asyncio.get_running_loop()\n#         async with TTS_SEMAPHORE:\n#             # 可选：设置超时，防挂死\n#             try:\n#                 res = await asyncio.wait_for(\n#                     loop.run_in_executor(\n#                         TTS_EXECUTOR,                 # ✅ 用自建线程池\n#                         line_service.generate_audio,\n#                         voice.reference_path,\n#                         project.tts_provider_id,      # 若引擎需要 base_url，就换成 project.tts_base_url\n#                         dto.text_content,\n#                         save_path\n#                     ),\n#                     timeout=120  # 例：最多等 5 分钟\n#                 )\n#             except asyncio.TimeoutError:\n#                 raise RuntimeError(\"TTS 超时\")\n#\n#         # res = chapter_service.generate_audio(voice.reference_path,project.tts_provider_id,dto.text_content,save_path=save_path)\n#         # 3) 真正合成\n#         line_service.update_line(dto.id, {\"status\": \"done\"})\n#\n#         # 4) 广播完成\n#         await manager.broadcast({\n#             \"event\": \"line_update\",\n#             \"line_id\": dto.id,\n#             \"status\": \"done\",\n#             \"progress\": 100,\n#             \"meta\": \"生成完成\",\n#             \"audio_path\": dto.audio_path\n#         })\n#     except Exception as e:\n#         line_service.update_line(dto.id, {\"status\": \"failed\"})\n#         await manager.broadcast({\n#             \"event\": \"line_update\",\n#             \"line_id\": dto.id,\n#             \"status\": \"failed\",\n#             \"progress\": 0,\n#             \"meta\": f\"失败: {e}\"\n#         })\n#     finally:\n#         db.close()\n#\n#\n# # 批量更新line_order\n\n# 处理音频文件，传入倍速，音量大小，以及line_id\n@router.post(\"/process-audio/{line_id}\")\nasync def process_audio(line_id: int, dto: LineAudioProcessDTO, line_service: LineService = Depends(get_line_service)):\n    res = line_service.process_audio(line_id,dto)\n    if not res:\n        return Res(data=None, code=400, message=\"处理失败\")\n    return Res(data=res, code=200, message=\"处理成功\")\n\n# 导出音频与字幕\n@router.get(\"/export-audio/{chapter_id}\")\nasync def export_audio(chapter_id: int,\n                       single: bool = Query(False, description=\"是否导出单条音频字幕\"),\n                       line_service: LineService = Depends(get_line_service)):\n    res = line_service.export_audio(chapter_id, single)\n    # res 现在返回 dict，包含 success, message, audio_path 等字段\n    if isinstance(res, dict):\n        if res.get(\"success\"):\n            return Res(data=res, code=200, message=res.get(\"message\", \"导出成功\"))\n        else:\n            return Res(data=res, code=400, message=res.get(\"message\", \"导出失败\"))\n    # 兼容旧的返回格式\n    if not res:\n        return Res(data=None, code=400, message=\"导出失败\")\n    return Res(data=res, code=200, message=\"导出成功\")\n\n\n# 生成单条音频的字幕（已经有音频）\n#\n\n# 矫正字幕 - 拼音匹配矫正\n@router.post(\"/correct-subtitle-pinyin/{chapter_id}\")\nasync def correct_subtitle_pinyin(\n    chapter_id: int, \n    line_service: LineService = Depends(get_line_service)\n):\n    \"\"\"使用拼音匹配算法矫正字幕\"\"\"\n    lines = line_service.get_all_lines(chapter_id)\n    if not lines:\n        logging.info(\"无台词记录\")\n        return Res(data=None, code=400, message=\"无台词记录\")\n    paths = [line.audio_path for line in lines]\n    if not paths or not paths[0]:\n        logging.info(\"未找到有效音频路径\")\n        return Res(data=None, code=400, message=\"未找到有效音频路径\")\n    \n    # 读取所有台词，组成一个文本\n    text = \"\\n\".join([line.text_content for line in lines])\n    output_dir_path = os.path.join(os.path.dirname(paths[0]), \"result\")\n    output_subtitle_path = os.path.join(output_dir_path, \"result.srt\")\n    \n    if not os.path.exists(output_subtitle_path):\n        logging.info(\"请先导出音频\")\n        return Res(data=None, code=400, message=\"请先导出音频\")\n    \n    # 拼音矫正输出到独立文件\n    pinyin_subtitle_path = os.path.join(output_dir_path, \"result_pinyin.srt\")\n    shutil.copy(output_subtitle_path, pinyin_subtitle_path)\n    line_service.correct_subtitle_pinyin(text, pinyin_subtitle_path)\n    logging.info(\"整体字幕矫正完成（拼音匹配）：%s\", pinyin_subtitle_path)\n\n    # 将单条字幕也进行矫正\n    logging.info(\"开始对单条字幕进行矫正\")\n    for line in lines:\n        subtitle_path = line.subtitle_path\n        line_text = line.text_content\n        if subtitle_path is not None and line_text is not None and os.path.exists(subtitle_path):\n            # 单条字幕也输出到 _pinyin 文件\n            base, ext = os.path.splitext(subtitle_path)\n            pinyin_single_path = f\"{base}_pinyin{ext}\"\n            shutil.copy(subtitle_path, pinyin_single_path)\n            line_service.correct_subtitle_pinyin(line_text, pinyin_single_path)\n            logging.info(\"单条字幕矫正完成：%s\", line.id)\n    \n    return Res(data=None, code=200, message=\"拼音匹配矫正完成\")\n\n\n# 矫正字幕 - LLM矫正\n@router.post(\"/correct-subtitle-llm/{chapter_id}\")\nasync def correct_subtitle_llm(\n    chapter_id: int,\n    batch_size: int = Query(20, description=\"LLM分批处理时每批的条数\"),\n    line_service: LineService = Depends(get_line_service),\n    chapter_service: ChapterService = Depends(get_chapter_service),\n    project_service: ProjectService = Depends(get_project_service)\n):\n    \"\"\"使用LLM矫正字幕，自动从项目配置获取LLM信息\"\"\"\n    # 获取章节信息\n    chapter = chapter_service.get_chapter(chapter_id)\n    if not chapter:\n        return Res(data=None, code=400, message=\"章节不存在\")\n    \n    # 获取项目信息，从中读取LLM配置\n    project = project_service.get_project(chapter.project_id)\n    if not project:\n        return Res(data=None, code=400, message=\"项目不存在\")\n    \n    if not project.llm_provider_id:\n        return Res(data=None, code=400, message=\"项目未配置LLM提供商，请在项目设置中配置\")\n    \n    if not project.llm_model:\n        return Res(data=None, code=400, message=\"项目未配置LLM模型，请在项目设置中选择模型\")\n    \n    lines = line_service.get_all_lines(chapter_id)\n    if not lines:\n        logging.info(\"无台词记录\")\n        return Res(data=None, code=400, message=\"无台词记录\")\n    paths = [line.audio_path for line in lines]\n    if not paths or not paths[0]:\n        logging.info(\"未找到有效音频路径\")\n        return Res(data=None, code=400, message=\"未找到有效音频路径\")\n    \n    # 读取所有台词，组成一个文本\n    text = \"\\n\".join([line.text_content for line in lines])\n    output_dir_path = os.path.join(os.path.dirname(paths[0]), \"result\")\n    output_subtitle_path = os.path.join(output_dir_path, \"result.srt\")\n    \n    if not os.path.exists(output_subtitle_path):\n        logging.info(\"请先导出音频\")\n        return Res(data=None, code=400, message=\"请先导出音频\")\n    \n    # LLM矫正输出到独立文件\n    llm_subtitle_path = os.path.join(output_dir_path, \"result_llm.srt\")\n    shutil.copy(output_subtitle_path, llm_subtitle_path)\n    line_service.correct_subtitle_llm(\n        text, llm_subtitle_path, \n        llm_provider_id=project.llm_provider_id, \n        llm_model=project.llm_model, \n        batch_size=batch_size\n    )\n    logging.info(\"整体字幕矫正完成（LLM）：%s\", llm_subtitle_path)\n\n    # 将单条字幕也进行矫正\n    logging.info(\"开始对单条字幕进行矫正\")\n    for line in lines:\n        subtitle_path = line.subtitle_path\n        line_text = line.text_content\n        if subtitle_path is not None and line_text is not None and os.path.exists(subtitle_path):\n            # 单条字幕也输出到 _llm 文件\n            base, ext = os.path.splitext(subtitle_path)\n            llm_single_path = f\"{base}_llm{ext}\"\n            shutil.copy(subtitle_path, llm_single_path)\n            line_service.correct_subtitle_llm(\n                line_text, llm_single_path,\n                llm_provider_id=project.llm_provider_id,\n                llm_model=project.llm_model,\n                batch_size=batch_size\n            )\n            logging.info(\"单条字幕矫正完成：%s\", line.id)\n    \n    return Res(data=None, code=200, message=\"LLM矫正完成\")\n\n"
  },
  {
    "path": "SonicVale/app/routers/llm_provider_router.py",
    "content": "from fastapi import APIRouter, Depends, HTTPException\nfrom typing import List\n\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.llm_provider_dto import LLMProviderCreateDTO, LLMProviderResponseDTO\nfrom app.entity.llm_provider_entity import LLMProviderEntity\nfrom app.services.llm_provider_service import LLMProviderService\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\n\n# 初始化 router\nrouter = APIRouter(prefix=\"/llm_providers\", tags=[\"LLMProviders\"])\n\n# 依赖注入（实际LLM供应商可用 DI 容器）\n\ndef get_llm_service(db: Session = Depends(get_db)) -> LLMProviderService:\n    repository = LLMProviderRepository(db)  # ✅ 传入 db\n    return LLMProviderService(repository)\n\n\n\n\n@router.post(\"/\", response_model=Res[LLMProviderResponseDTO],\n             summary=\"创建LLM供应商\",\n             description=\"根据LLM供应商信息创建LLM供应商，LLM供应商名称不可重复\")\ndef create_llm_provider(dto: LLMProviderCreateDTO, service: LLMProviderService = Depends(get_llm_service)):\n    \"\"\"\n    创建LLM供应商\n    - dto: 前端 POST JSON 传入参数\n    - service: Service 层注入\n    \"\"\"\n    try:\n        # DTO → Entity\n        entity = LLMProviderEntity(**dto.__dict__)\n\n        # 调用 Service 创建LLM供应商（返回 True/False）\n        entityRes = service.create_llm_provider(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = LLMProviderResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"LLM供应商 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n# 按id查找\n@router.get(\"/{llm_provider_id}\", response_model=Res[LLMProviderResponseDTO],\n            summary=\"查询LLM供应商\",\n            description=\"根据LLM供应商ID查询LLM供应商信息\")\ndef get_llm_provider(llm_provider_id: int, service: LLMProviderService = Depends(get_llm_service)):\n    entity = service.get_llm_provider(llm_provider_id)\n    if entity:\n        res = LLMProviderResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"LLM供应商不存在\")\n\n@router.get(\"/\", response_model=Res[List[LLMProviderResponseDTO]],\n            summary=\"查询所有LLM供应商\",\n            description=\"查询所有LLM供应商信息\")\ndef get_all_llm_providers(service: LLMProviderService = Depends(get_llm_service)):\n    entities = service.get_all_llm_providers()\n    dtos = [LLMProviderResponseDTO(**e.__dict__) for e in entities]\n    return Res(data=dtos, code=200, message=\"查询成功\")\n\n\n# ------------------- 修改LLM供应商 -------------------\n@router.put(\"/{llm_provider_id}\", response_model=Res[LLMProviderCreateDTO],\n            summary=\"修改LLM供应商\",\n            description=\"根据LLM供应商ID修改LLM供应商信息\")\ndef update_llm_provider(llm_provider_id: int, dto: LLMProviderCreateDTO, service: LLMProviderService = Depends(get_llm_service)):\n\n    # 先根据id进行查找\n    llm_provider = service.get_llm_provider(llm_provider_id)\n    if not llm_provider:\n        return Res(data=None, code=400, message=\"LLM供应商不存在\")\n\n    success = service.update_llm_provider(llm_provider_id,dto.dict(exclude_unset=True))\n    if success:\n        return Res(data=dto, code=200, message=\"更新成功\")\n    else:\n        return Res(data=None, code=400, message=\"更新失败\")\n\n\n# ------------------- 删除LLM供应商 -------------------\n@router.delete(\"/{llm_provider_id}\", response_model=Res,\n               summary=\"删除LLM供应商\",\n               description=\"根据LLM供应商ID删除LLM供应商,并且级联删除LLM供应商下所有章节以及内容\")\ndef delete_llm_provider(llm_provider_id: int, service: LLMProviderService = Depends(get_llm_service)):\n    success = service.delete_llm_provider(llm_provider_id)\n    # todo 级联删除LLM供应商所有相关内容，比如LLM供应商下所有章节以及内容\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或LLM供应商不存在\")\n\n\n# 测试供应商\n@router.post(\"/test\", response_model=Res)\ndef test_llm_provider(dto: LLMProviderCreateDTO, service: LLMProviderService = Depends(get_llm_service)):\n    \"\"\"\n    测试供应商\n    \"\"\"\n    entity = LLMProviderEntity(**dto.__dict__)\n    res,msg = service.test_llm_provider(entity)\n    if res == True:\n        return Res(data=None, code=200, message=\"测试成功\")\n    else:\n        return Res(data=None, code=400, message= msg)"
  },
  {
    "path": "SonicVale/app/routers/multi_emotion_voice_router.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.multi_emotion_voice_dto import MultiEmotionVoiceCreateDTO, MultiEmotionVoiceResponseDTO\nfrom app.entity.multi_emotion_voice_entity import MultiEmotionVoiceEntity\nfrom app.repositories.emotion_repository import EmotionRepository\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\nfrom app.repositories.strength_repository import StrengthRepository\nfrom app.repositories.voice_repository import VoiceRepository\nfrom app.services.emotion_service import EmotionService\nfrom app.services.multi_emotion_voice_service import MultiEmotionVoiceService\nfrom app.services.strength_service import StrengthService\nfrom app.services.voice_service import VoiceService\n\nrouter = APIRouter(prefix=\"/multi_emotion_voices\", tags=[\"MultiEmotionVoice\"])\n\ndef get_multi_emotion_voice_service(db: Session = Depends(get_db)) -> MultiEmotionVoiceService:\n    repository = MultiEmotionVoiceRepository(db)\n    return MultiEmotionVoiceService(repository)\ndef get_voice_service(db: Session = Depends(get_db)) -> VoiceService:\n    repository = VoiceRepository(db)\n    multi_emotion_voice_repository = MultiEmotionVoiceRepository(db)\n    return VoiceService(repository, multi_emotion_voice_repository)\n\ndef get_emotion_service(db: Session = Depends(get_db)) -> EmotionService:\n    repository = EmotionRepository(db)\n    return EmotionService(repository)\n\ndef get_strength_service(db: Session = Depends(get_db)) -> StrengthService:\n    repository = StrengthRepository(db)\n    return StrengthService(repository)\n\n# 根据voice_id获取多音色\n@router.get(\"/voice_id/{voice_id}\", response_model=Res[List[MultiEmotionVoiceResponseDTO]],summary=\"根据voice_id获取多音色\", description=\"根据voice_id获取多音色\")\ndef get_multi_emotion_voice_by_voice_id(voice_id: int, multi_emotion_voice_service: MultiEmotionVoiceService = Depends(get_multi_emotion_voice_service),\n                                        voice_service: VoiceService = Depends(get_voice_service)):\n    # 应该查询voice\n    voice = voice_service.get_voice(voice_id)\n    if voice is None:\n        return Res(code=404, message=\"音色不存在\")\n    entities = multi_emotion_voice_service.get_multi_emotion_voice_by_voice_id(voice_id)\n    if entities is None:\n        return Res(code=404, message=\"多音色不存在\")\n    else:\n        res = [MultiEmotionVoiceResponseDTO(**entity.__dict__) for entity in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n\n# 查询所有多音色\n@router.get(\"\", response_model=Res[List[MultiEmotionVoiceResponseDTO]],summary=\"查询所有多音色\", description=\"查询所有多音色\")\ndef get_all_multi_emotion_voice(multi_emotion_voice_service: MultiEmotionVoiceService = Depends(get_multi_emotion_voice_service)):\n    entities = multi_emotion_voice_service.get_all_multi_emotion_voices()\n    if not entities:\n        return Res(data=[], code=200, message=\"查询成功\")\n    entities = [MultiEmotionVoiceResponseDTO(**e.__dict__) for e in entities]\n    return Res(data=entities, code=200, message=\"查询成功\")\n# 创建\n@router.post(\"\", response_model=Res[MultiEmotionVoiceResponseDTO],summary=\"创建多情绪音色\", description=\"创建多情绪音色\")\ndef create_multi_emotion_voice(dto: MultiEmotionVoiceCreateDTO, multi_emotion_voice_service: MultiEmotionVoiceService = Depends(get_multi_emotion_voice_service),\n                               voice_service: VoiceService = Depends(get_voice_service),\n                               emotion_service: EmotionService = Depends(get_emotion_service),\n                               strength_service: StrengthService = Depends(get_strength_service)):\n    \"\"\"创建多音色\"\"\"\n    # 先要判断voice是否存在\n    voice = voice_service.get_voice(dto.voice_id)\n    # 判断情绪枚举是否存在\n    emotion = emotion_service.get_emotion(dto.emotion_id)\n    # 判断强度枚举是否存在\n    strength = strength_service.get_strength(dto.strength_id)\n    if voice is None or emotion is None or strength is None:\n        return Res(code=500, message=\"创建失败,音色或者情绪枚举或者情绪强弱枚举不存在，不能创建多情绪音色\")\n    # DTO → Entity\n    entity = MultiEmotionVoiceEntity(**dto.__dict__)\n    entity = multi_emotion_voice_service.create_multi_emotion_voice(entity)\n    if entity is None:\n        return Res(code=500, message=\"创建失败,已存在多情绪音色\")\n    else :\n        entity = MultiEmotionVoiceResponseDTO(**entity.__dict__)\n        return Res(data=entity, code=200, message=\"创建成功\")\n\n\n# 修改\n@router.put(\"/{multi_emotion_voice_id}\", response_model=Res[MultiEmotionVoiceCreateDTO],summary=\"修改多情绪音色\", description=\"修改多情绪音色\")\ndef update_multi_emotion_voice(multi_emotion_voice_id: int, dto: MultiEmotionVoiceCreateDTO, multi_emotion_voice_service: MultiEmotionVoiceService = Depends(get_multi_emotion_voice_service)):\n    \"\"\"修改多音色\"\"\"\n    entity = multi_emotion_voice_service.get_multi_emotion_voice_by_id(multi_emotion_voice_id)\n    if entity is None:\n        return Res(code=404, message=\"多音色不存在\")\n    res = multi_emotion_voice_service.update_multi_emotion_voice(multi_emotion_voice_id, dto.dict(exclude_unset=True))\n    if res is None:\n        return Res(code=500, message=\"修改失败\")\n    else:\n        entityRes = MultiEmotionVoiceResponseDTO(**entity.__dict__)\n        return Res(data=entityRes, code=200, message=\"修改成功\")\n\n# 删除\n@router.delete(\"/{multi_emotion_voice_id}\", response_model=Res[MultiEmotionVoiceResponseDTO],summary=\"删除多情绪音色\", description=\"删除多情绪音色\")\ndef delete_multi_emotion_voice(multi_emotion_voice_id: int, multi_emotion_voice_service: MultiEmotionVoiceService = Depends(get_multi_emotion_voice_service)):\n    \"\"\"删除多音色\"\"\"\n    res = multi_emotion_voice_service.delete_multi_emotion_voice(multi_emotion_voice_id)\n    if res:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败\")\n\n#\n"
  },
  {
    "path": "SonicVale/app/routers/project_router.py",
    "content": "import os\nimport shutil\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom typing import List\n\nfrom sqlalchemy.orm import Session\n\nfrom app.core.config import getConfigPath\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.project_dto import ProjectCreateDTO, ProjectResponseDTO, ProjectImportDTO\nfrom app.entity.chapter_entity import ChapterEntity\nfrom app.entity.project_entity import ProjectEntity\nfrom app.models.po import ChapterPO\nfrom app.repositories.chapter_repository import ChapterRepository\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.services.chapter_service import ChapterService\nfrom app.services.project_service import ProjectService\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.services.role_service import RoleService\n\n# 初始化 router\nrouter = APIRouter(prefix=\"/projects\", tags=[\"Projects\"])\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_service(db: Session = Depends(get_db)) -> ProjectService:\n    repository = ProjectRepository(db)  # ✅ 传入 db\n    return ProjectService(repository)\n\ndef get_chapter_service(db: Session = Depends(get_db)) -> ChapterService:\n    repository = ChapterRepository(db)  # ✅ 传入 db\n    return ChapterService(repository)\n\ndef get_role_service(db: Session = Depends(get_db)) -> RoleService:\n    repository = RoleRepository(db)  # ✅ 传入 db\n    return RoleService(repository)\n\n\n@router.post(\"/\", response_model=Res[ProjectResponseDTO],\n             summary=\"创建项目\",\n             description=\"根据项目信息创建项目，项目名称不可重复\")\ndef create_project(dto: ProjectCreateDTO, service: ProjectService = Depends(get_service)):\n    \"\"\"\n    创建项目\n    - dto: 前端 POST JSON 传入参数\n    - service: Service 层注入\n    \"\"\"\n    try:\n        # DTO → Entity\n        entity = ProjectEntity(**dto.__dict__)\n\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes,message = service.create_project(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = ProjectResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=message)\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n# 按id查找\n@router.get(\"/{project_id}\", response_model=Res[ProjectResponseDTO],\n            summary=\"查询项目\",\n            description=\"根据项目ID查询项目信息\")\ndef get_project(project_id: int, service: ProjectService = Depends(get_service)):\n    entity = service.get_project(project_id)\n    if entity:\n        res = ProjectResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"项目不存在\")\n\n@router.get(\"/\", response_model=Res[List[ProjectResponseDTO]],\n            summary=\"查询所有项目\",\n            description=\"查询所有项目信息\")\ndef get_all_projects(service: ProjectService = Depends(get_service)):\n    entities = service.get_all_projects()\n    dtos = [ProjectResponseDTO(**e.__dict__) for e in entities]\n    return Res(data=dtos, code=200, message=\"查询成功\")\n\n\n# ------------------- 修改项目 -------------------\n@router.put(\"/{project_id}\", response_model=Res[ProjectCreateDTO],\n            summary=\"修改项目\",\n            description=\"根据项目ID修改项目信息\")\ndef update_project(project_id: int, dto: ProjectCreateDTO, service: ProjectService = Depends(get_service)):\n\n    # 先根据id进行查找\n    project = service.get_project(project_id)\n    if not project:\n        return Res(data=None, code=400, message=\"项目不存在\")\n\n    success = service.update_project(project_id,dto.dict())\n    if success:\n        return Res(data=dto, code=200, message=\"更新成功\")\n    else:\n        return Res(data=None, code=400, message=\"更新失败\")\n\n\n# ------------------- 删除项目 -------------------\n@router.delete(\"/{project_id}\", response_model=Res,\n               summary=\"删除项目\",\n               description=\"根据项目ID删除项目,并且级联删除项目下所有章节以及内容\")\ndef delete_project(project_id: int, service: ProjectService = Depends(get_service), chapter_service: ChapterService = Depends(get_chapter_service),role_service: RoleService = Depends(get_role_service)):\n\n    # 级联删除项目所有相关内容，比如项目下所有章节以及内容\n    entities = chapter_service.get_all_chapters(project_id)\n    for entity in entities:\n        chapter_service.delete_chapter(entity.id)\n    #     删除project目录\n    project = service.get_project(project_id)\n\n    project_path = os.path.join(project.project_root_path, str(project_id))\n    if os.path.exists(project_path):\n        shutil.rmtree(project_path)  # 删除整个文件夹及其所有内容\n        logging.info(\"已删除目录及内容: %s\", project_path)\n    else:\n        logging.info(\"目录不存在: %s\", project_path)\n\n    # 还要删除角色库中projet下的所有角色\n    roles = role_service.get_all_roles(project_id)\n    for role in roles:\n        role_service.delete_role(role.id)\n    success = service.delete_project(project_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或项目不存在\")\n\n# 直接导入整本小说内容，然后解析，创建章节\n@router.post(\"/{project_id}/import\")\ndef import_project(project_id: int, dto: ProjectImportDTO,service: ProjectService = Depends(get_service),\n                   chapter_service: ChapterService = Depends(get_chapter_service)):\n\n    content = dto.content\n    # 删除该项目下的所有章节\n    # chapters = chapter_service.get_all_chapters(project_id)\n    # for chapter in chapters:\n    #     chapter_service.delete_chapter(chapter.id)\n    # 解析content\n    chapter_contents = service.parse_content(content)\n    if len(chapter_contents) == 0:\n        return Res(code=400, message=\"导入失败\")\n\n    # 批量创建章节\n    for chapter_content in chapter_contents:\n        name = chapter_content[\"chapter_name\"]\n        content = chapter_content[\"content\"]\n        logging.info(\"批量创建章节 %s\", name)\n        chapter_service.create_chapter(ChapterEntity(project_id=project_id, title=name, text_content=content))\n    return Res(code=200, message=\"导入成功\")\n"
  },
  {
    "path": "SonicVale/app/routers/prompt_router.py",
    "content": "from fastapi import APIRouter, Depends, HTTPException\nfrom typing import List\n\nfrom sqlalchemy.orm import Session\n\nfrom app.core.enums import TaskEnum\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.prompt_dto import PromptCreateDTO, PromptResponseDTO\nfrom app.entity.prompt_entity import PromptEntity\nfrom app.services.prompt_service import PromptService\nfrom app.repositories.prompt_repository import PromptRepository\n\n# 初始化 router\nrouter = APIRouter(prefix=\"/prompts\", tags=[\"Prompts\"])\n\n# 依赖注入（实际提示词可用 DI 容器）\n\ndef get_service(db: Session = Depends(get_db)) -> PromptService:\n    repository = PromptRepository(db)  # ✅ 传入 db\n    return PromptService(repository)\n\n\n\n\n@router.post(\"/\", response_model=Res[PromptResponseDTO],\n             summary=\"创建提示词\",\n             description=\"根据提示词信息创建提示词，提示词名称不可重复\")\ndef create_prompt(dto: PromptCreateDTO, service: PromptService = Depends(get_service)):\n    \"\"\"\n    创建提示词\n    - dto: 前端 POST JSON 传入参数\n    - service: Service 层注入\n    \"\"\"\n    try:\n        # DTO → Entity\n        entity = PromptEntity(**dto.__dict__)\n\n        # 调用 Service 创建提示词（返回 True/False）\n        entityRes = service.create_prompt(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = PromptResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"创建失败,可能是不存在该任务或提示词数据不完整\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n# 按id查找\n@router.get(\"/{prompt_id}\", response_model=Res[PromptResponseDTO],\n            summary=\"查询提示词\",\n            description=\"根据提示词ID查询提示词信息\")\ndef get_prompt(prompt_id: int, service: PromptService = Depends(get_service)):\n    entity = service.get_prompt(prompt_id)\n    if entity:\n        res = PromptResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"提示词不存在\")\n\n@router.get(\"/\", response_model=Res[List[PromptResponseDTO]],\n            summary=\"查询所有提示词\",\n            description=\"查询所有提示词信息\")\ndef get_all_prompts(service: PromptService = Depends(get_service)):\n    entities = service.get_all_prompts()\n    dtos = [PromptResponseDTO(**e.__dict__) for e in entities]\n    return Res(data=dtos, code=200, message=\"查询成功\")\n\n\n# ------------------- 修改提示词 -------------------\n@router.put(\"/{prompt_id}\", response_model=Res[PromptCreateDTO],\n            summary=\"修改提示词\",\n            description=\"根据提示词ID修改提示词信息\")\ndef update_prompt(prompt_id: int, dto: PromptCreateDTO, service: PromptService = Depends(get_service)):\n\n    # 先根据id进行查找\n    prompt = service.get_prompt(prompt_id)\n    if not prompt:\n        return Res(data=None, code=400, message=\"提示词不存在\")\n\n    success = service.update_prompt(prompt_id,dto.dict(exclude_unset=True))\n    if success:\n        return Res(data=dto, code=200, message=\"更新成功\")\n    else:\n        return Res(data=None, code=400, message=\"更新失败,可能是不存在该任务或提示词数据不完整\")\n\n\n# ------------------- 删除提示词 -------------------\n@router.delete(\"/{prompt_id}\", response_model=Res,\n               summary=\"删除提示词\",\n               description=\"根据提示词ID删除提示词,并且级联删除提示词下所有章节以及内容\")\ndef delete_prompt(prompt_id: int, service: PromptService = Depends(get_service)):\n    success = service.delete_prompt(prompt_id)\n    # todo 级联删除提示词所有相关内容，比如提示词下所有章节以及内容\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或提示词不存在\")\n\n# 获取所有的任务列表\n@router.get(\"/tasks/all\", response_model=Res[List[str]])\ndef get_all_tasks(service: PromptService = Depends(get_service)):\n    tasks = service.get_all_tasks()\n    return Res(data=tasks, code=200, message=\"查询成功\")\n\n# 根据任务列表获取对应的提示词\n@router.get(\"/tasks/by\", response_model=Res[List[PromptResponseDTO]])\ndef get_prompt_by_task(task: TaskEnum, service: PromptService = Depends(get_service)):\n    prompts = service.get_prompt_by_task(task.value)  # 取枚举的值\n    dtos = [PromptResponseDTO(**e.__dict__) for e in prompts]\n    return Res(data=dtos, code=200, message=\"查询成功\")\n\n\n\n# 测试供应商\n# @router.post(\"/test\", response_model=Res)\n# def test_prompt(dto: PromptCreateDTO, service: PromptService = Depends(get_service)):\n#     \"\"\"\n#     测试供应商\n#     \"\"\"\n#     entity = PromptEntity(**dto.__dict__)\n#     success = service.test_prompt(entity)\n#     if success:\n#         return Res(data=None, code=200, message=\"测试成功\")\n#     else:\n#         return Res(data=None, code=400, message=\"测试失败\")"
  },
  {
    "path": "SonicVale/app/routers/role_router.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.role_dto import RoleResponseDTO, RoleCreateDTO\nfrom app.entity.role_entity import RoleEntity\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.services.line_service import LineService\nfrom app.services.project_service import ProjectService\nfrom app.services.role_service import RoleService\n\nrouter = APIRouter(prefix=\"/roles\", tags=[\"Roles\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_role_service(db: Session = Depends(get_db)) -> RoleService:\n    repository = RoleRepository(db)\n    return RoleService(repository)\ndef get_project_service(db: Session = Depends(get_db)) -> ProjectService:\n    repository = ProjectRepository(db)\n    return ProjectService(repository)\n\ndef get_line_service(db: Session = Depends(get_db)) -> LineService:\n    repository = LineRepository(db)\n    role_repository = RoleRepository(db)\n    tts_provider_repository = TTSProviderRepository(db)\n    llm_provider_repository = LLMProviderRepository(db)\n    return LineService(repository, role_repository, tts_provider_repository, llm_provider_repository)\n@router.post(\"\", response_model=Res[RoleResponseDTO],\n             summary=\"创建角色\",\n             description=\"根据项目ID创建角色，角色名称在同一项目下不可重复\" )\ndef create_role(dto: RoleCreateDTO, role_service: RoleService = Depends(get_role_service),\n                   project_service: ProjectService = Depends(get_project_service)):\n    \"\"\"创建角色\"\"\"\n    try:\n        # DTO → Entity\n        entity = RoleEntity(**dto.__dict__)\n        # 判断project_id是否存在\n        project = project_service.get_project(dto.project_id)\n        if project is None:\n            return Res(data=None, code=400, message=f\"项目 '{dto.project_id}' 不存在\")\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes = role_service.create_role(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = RoleResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"角色 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n@router.get(\"/{role_id}\", response_model=Res[RoleResponseDTO],\n            summary=\"查询角色\",\n            description=\"根据角色id查询角色信息\")\ndef get_role(role_id: int, role_service: RoleService = Depends(get_role_service)):\n    entity = role_service.get_role(role_id)\n    if entity:\n        res = RoleResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"项目不存在\")\n\n@router.get(\"/project/{project_id}\", response_model=Res[List[RoleResponseDTO]],\n            summary=\"查询项目下的所有角色\",\n            description=\"根据项目id查询项目下的所有角色信息\")\ndef get_all_roles(project_id: int, role_service: RoleService = Depends(get_role_service)):\n    entities = role_service.get_all_roles(project_id)\n    if entities:\n        res = [RoleResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=404, message=\"项目不存在角色\")\n\n# 修改，传入的参数是id\n@router.put(\"/{role_id}\", response_model=Res[RoleCreateDTO],\n            summary=\"修改角色信息\",\n            description=\"根据角色id修改角色信息,并且不能修改项目id\")\ndef update_role(role_id: int, dto: RoleCreateDTO, role_service: RoleService = Depends(get_role_service)):\n    role = role_service.get_role(role_id)\n    if role is None:\n        return Res(data=None, code=404, message=\"角色不存在\")\n    res = role_service.update_role(role_id, dto.dict(exclude_unset=True))\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败\")\n\n\n# 根据id，删除\n@router.delete(\"/{role_id}\", response_model=Res,\n               summary=\"删除角色\",\n               description=\"根据角色id删除角色信息\")\ndef delete_role(role_id: int, role_service: RoleService = Depends(get_role_service),line_service: LineService = Depends(get_line_service)):\n    success = role_service.delete_role(role_id)\n    if success:\n        # 获取改角色下所有的台词\n        line_service.clear_role_id(role_id)\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或角色不存在\")\n\n\n# 根据内容进行解析\n\n"
  },
  {
    "path": "SonicVale/app/routers/strength_router.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.strength_dto import StrengthResponseDTO, StrengthCreateDTO\nfrom app.entity.strength_entity import StrengthEntity\n\nfrom app.repositories.strength_repository import StrengthRepository\n\nfrom app.services.strength_service import StrengthService\n\nrouter = APIRouter(prefix=\"/strengths\", tags=[\"Strengths\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_strength_service(db: Session = Depends(get_db)) -> StrengthService:\n    repository = StrengthRepository(db)\n    return StrengthService(repository)\n\n@router.post(\"\", response_model=Res[StrengthResponseDTO],\n             summary=\"创建情绪强弱枚举\",\n             description=\"根据项目ID创建情绪强弱枚举，情绪强弱枚举名称在同一项目下不可重复\" )\ndef create_strength(dto: StrengthCreateDTO, strength_service: StrengthService = Depends(get_strength_service)):\n    \"\"\"创建情绪强弱枚举\"\"\"\n    try:\n        # DTO → Entity\n        entity = StrengthEntity(**dto.__dict__)\n\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes = strength_service.create_strength(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = StrengthResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"情绪强弱枚举 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n@router.get(\"/{strength_id}\", response_model=Res[StrengthResponseDTO],\n            summary=\"查询情绪强弱枚举\",\n            description=\"根据情绪强弱枚举id查询情绪强弱枚举信息\")\ndef get_strength(strength_id: int, strength_service: StrengthService = Depends(get_strength_service)):\n    entity = strength_service.get_strength(strength_id)\n    if entity:\n        res = StrengthResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"情绪强弱枚举不存在\")\n\n@router.get(\"\", response_model=Res[List[StrengthResponseDTO]],\n            summary=\"查询所有情绪强弱枚举\",\n            description=\"根据所有情绪强弱枚举信息\")\ndef get_all_strengths(strength_service: StrengthService = Depends(get_strength_service)):\n    entities = strength_service.get_all_strengths()\n    if entities:\n        res = [StrengthResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=404, message=\"项目不存在情绪强弱枚举\")\n\n# 修改，传入的参数是id\n@router.put(\"/{strength_id}\", response_model=Res[StrengthCreateDTO],\n            summary=\"修改情绪强弱枚举信息\",\n            description=\"根据情绪强弱枚举id修改情绪强弱枚举信息,并且不能修改项目id\")\ndef update_strength(strength_id: int, dto: StrengthCreateDTO, strength_service: StrengthService = Depends(get_strength_service)):\n    strength = strength_service.get_strength(strength_id)\n    if strength is None:\n        return Res(data=None, code=404, message=\"情绪强弱枚举不存在\")\n    res = strength_service.update_strength(strength_id, dto.dict(exclude_unset=True))\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败，情绪强弱枚举名已存在\")\n\n\n# 根据id，删除，不开放\n@router.delete(\"/{strength_id}\", response_model=Res,\n               summary=\"删除情绪强弱枚举\",\n               description=\"根据情绪强弱枚举id删除情绪强弱枚举信息\")\ndef delete_strength(strength_id: int, strength_service: StrengthService = Depends(get_strength_service)):\n    success = strength_service.delete_strength(strength_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或情绪强弱枚举不存在\")\n\n\n\n"
  },
  {
    "path": "SonicVale/app/routers/tts_provider_router.py",
    "content": "from fastapi import APIRouter, Depends, HTTPException\nfrom typing import List\n\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.tts_provider_dto import TTSProviderCreateDTO, TTSProviderResponseDTO\nfrom app.entity.tts_provider_entity import TTSProviderEntity\nfrom app.services.tts_provider_service import TTSProviderService\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\n\n# 初始化 router\nrouter = APIRouter(prefix=\"/tts_providers\", tags=[\"TTSProviders\"])\n\n# 依赖注入（实际TTS供应商可用 DI 容器）\n\ndef get_service(db: Session = Depends(get_db)) -> TTSProviderService:\n    repository = TTSProviderRepository(db)  # ✅ 传入 db\n    return TTSProviderService(repository)\n\n\n# 按id查找\n@router.get(\"/{tts_provider_id}\", response_model=Res[TTSProviderResponseDTO],\n            summary=\"查询TTS供应商\",\n            description=\"根据TTS供应商ID查询TTS供应商信息\")\ndef get_tts_provider(tts_provider_id: int, service: TTSProviderService = Depends(get_service)):\n    entity = service.get_tts_provider(tts_provider_id)\n    if entity:\n        res = TTSProviderResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"TTS供应商不存在\")\n\n@router.get(\"/\", response_model=Res[List[TTSProviderResponseDTO]],\n            summary=\"查询所有TTS供应商\",\n            description=\"查询所有TTS供应商信息\")\ndef get_all_tts_providers(service: TTSProviderService = Depends(get_service)):\n    entities = service.get_all_tts_providers()\n    dtos = [TTSProviderResponseDTO(**e.__dict__) for e in entities]\n    return Res(data=dtos, code=200, message=\"查询成功\")\n\n\n# ------------------- 修改TTS供应商 -------------------\n@router.put(\"/{tts_provider_id}\", response_model=Res[TTSProviderCreateDTO],\n            summary=\"修改TTS供应商\",\n            description=\"根据TTS供应商ID修改TTS供应商信息\")\ndef update_tts_provider(tts_provider_id: int, dto: TTSProviderCreateDTO, service: TTSProviderService = Depends(get_service)):\n\n    # 先根据id进行查找\n    tts_provider = service.get_tts_provider(tts_provider_id)\n    if not tts_provider:\n        return Res(data=None, code=400, message=\"TTS供应商不存在\")\n\n    success = service.update_tts_provider(tts_provider_id,dto.dict(exclude_unset=True))\n    if success:\n        return Res(data=dto, code=200, message=\"更新成功\")\n    else:\n        return Res(data=None, code=400, message=\"更新失败\")\n\n\n\n# 测试tts是否正常\n@router.post(\"/test\", response_model=Res)\ndef test_tts_provider(dto: TTSProviderCreateDTO, service: TTSProviderService = Depends(get_service)):\n    \"\"\"\n    测试tts是否正常\n    \"\"\"\n    entity  = TTSProviderEntity(**dto.dict())\n    success = service.test_tts_provider(entity)\n    if success:\n        return Res(data=None, code=200, message=\"测试成功\")\n    else:\n        return Res(data=None, code=400, message=\"测试失败\")\n\n\n\n# ------------------- 删除TTS供应商 -------------------\n# @router.delete(\"/{tts_provider_id}\", response_model=Res,\n#                summary=\"删除TTS供应商\",\n#                description=\"根据TTS供应商ID删除TTS供应商,并且级联删除TTS供应商下所有章节以及内容\")\n# def delete_tts_provider(tts_provider_id: int, service: TTSProviderService = Depends(get_service)):\n#     success = service.delete_tts_provider(tts_provider_id)\n#     # todo 级联删除TTS供应商所有相关内容，比如TTS供应商下所有章节以及内容\n#     if success:\n#         return Res(data=None, code=200, message=\"删除成功\")\n#     else:\n#         return Res(data=None, code=400, message=\"删除失败或TTS供应商不存在\")"
  },
  {
    "path": "SonicVale/app/routers/voice_router.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses import FileResponse\nfrom sqlalchemy.orm import Session\n\nfrom app.core.response import Res\nfrom app.db.database import get_db\nfrom app.dto.tts_provider_dto import TTSProviderResponseDTO\nfrom app.dto.voice_dto import VoiceResponseDTO, VoiceCreateDTO, VoiceExportDTO, VoiceImportDTO, VoiceImportResultDTO, VoiceAudioProcessDTO, VoiceCopyDTO\nfrom app.entity.voice_entity import VoiceEntity\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\n\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.repositories.voice_repository import VoiceRepository\n\nfrom app.services.tts_provider_service import TTSProviderService\nfrom app.services.voice_service import VoiceService\n\nrouter = APIRouter(prefix=\"/voices\", tags=[\"Voices\"])\n\n\n# 依赖注入（实际项目可用 DI 容器）\n\ndef get_voice_service(db: Session = Depends(get_db)) -> VoiceService:\n    repository = VoiceRepository(db)\n    multi_emotion_voice_repository = MultiEmotionVoiceRepository(db)\n    return VoiceService(repository, multi_emotion_voice_repository)\ndef get_tts_provider_service(db: Session = Depends(get_db)) -> TTSProviderService:\n    repository = TTSProviderRepository(db)\n    return TTSProviderService(repository)\n\n\n# ====== 静态路由放在动态路由之前，避免路径冲突 ======\n\n@router.post(\"/process-audio\", response_model=Res[str],\n             summary=\"处理音色参考音频\",\n             description=\"对音色的参考音频进行处理（变速、音量、裁剪等）\")\ndef process_voice_audio(dto: VoiceAudioProcessDTO, voice_service: VoiceService = Depends(get_voice_service)):\n    \"\"\"处理音色参考音频\"\"\"\n    try:\n        result = voice_service.process_audio(dto)\n        if result:\n            return Res(data=dto.audio_path, code=200, message=\"处理成功\")\n        else:\n            return Res(data=None, code=400, message=\"处理失败\")\n    except FileNotFoundError as e:\n        return Res(data=None, code=404, message=f\"音频文件不存在: {str(e)}\")\n    except Exception as e:\n        return Res(data=None, code=500, message=f\"处理失败: {str(e)}\")\n\n\n@router.post(\"/export\", response_model=Res[str],\n             summary=\"导出音色库\",\n             description=\"将指定TTS供应商下的音色打包到zip文件（可选传ids仅导出选中）\")\ndef export_voices(dto: VoiceExportDTO, voice_service: VoiceService = Depends(get_voice_service)):\n    \"\"\"导出音色库到zip文件\"\"\"\n    try:\n        result = voice_service.export_voices(dto.tts_provider_id, dto.export_path, dto.ids)\n        if result:\n            return Res(data=result, code=200, message=\"导出成功\")\n        else:\n            return Res(data=None, code=400, message=\"没有可导出的音色\")\n    except Exception as e:\n        return Res(data=None, code=500, message=f\"导出失败: {str(e)}\")\n\n\n@router.post(\"/import\", response_model=Res[VoiceImportResultDTO],\n             summary=\"导入音色库\",\n             description=\"从zip文件导入音色库，将音频文件复制到指定目录，已存在的音色会跳过\")\ndef import_voices(dto: VoiceImportDTO, voice_service: VoiceService = Depends(get_voice_service)):\n    \"\"\"从zip文件导入音色库\"\"\"\n    try:\n        success_count, skipped_count, skipped_names = voice_service.import_voices(\n            dto.tts_provider_id, dto.zip_path, dto.target_dir\n        )\n        result = VoiceImportResultDTO(\n            success_count=success_count,\n            skipped_count=skipped_count,\n            skipped_names=skipped_names\n        )\n        return Res(data=result, code=200, message=f\"导入完成：成功{success_count}个，跳过{skipped_count}个\")\n    except FileNotFoundError as e:\n        return Res(data=None, code=404, message=str(e))\n    except ValueError as e:\n        return Res(data=None, code=400, message=str(e))\n    except Exception as e:\n        return Res(data=None, code=500, message=f\"导入失败: {str(e)}\")\n\n\n@router.post(\"/copy\", response_model=Res[VoiceResponseDTO],\n             summary=\"复制音色\",\n             description=\"复制现有音色，包括音频文件，生成新的音色记录\")\ndef copy_voice(dto: VoiceCopyDTO, voice_service: VoiceService = Depends(get_voice_service)):\n    \"\"\"复制音色\"\"\"\n    try:\n        new_voice = voice_service.copy_voice(\n            dto.source_voice_id, dto.new_name, dto.target_dir\n        )\n        res = VoiceResponseDTO(**new_voice.__dict__)\n        return Res(data=res, code=200, message=\"复制成功\")\n    except ValueError as e:\n        return Res(data=None, code=400, message=str(e))\n    except Exception as e:\n        return Res(data=None, code=500, message=f\"复制失败: {str(e)}\")\n\n\n@router.get(\"/tts/{tts_provider_id}\", response_model=Res[List[VoiceResponseDTO]],\n            summary=\"查询tts供应商下的所有音色\",\n            description=\"根据tts供应商id,查询tts供应商下的所有音色信息\")\ndef get_all_voices(tts_provider_id: int, voice_service: VoiceService = Depends(get_voice_service)):\n    entities = voice_service.get_all_voices(tts_provider_id)\n    if entities:\n        res = [VoiceResponseDTO(**e.__dict__) for e in entities]\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=[], code=404, message=\"项目不存在音色\")\n\n\n@router.post(\"\", response_model=Res[VoiceResponseDTO],\n             summary=\"创建音色\",\n             description=\"根据项目ID创建音色，音色名称在同一项目下不可重复\" )\ndef create_voice(dto: VoiceCreateDTO, voice_service: VoiceService = Depends(get_voice_service),\n                   tts_provider_service: TTSProviderService = Depends(get_tts_provider_service)):\n    \"\"\"创建音色\"\"\"\n    try:\n        # DTO → Entity\n        entity = VoiceEntity(**dto.__dict__)\n        # 判断tts_id是否存在\n        tts_provider = tts_provider_service.get_tts_provider(dto.tts_provider_id)\n\n        if tts_provider is None:\n            return Res(data=None, code=400, message=f\"tts服务提供商 '{dto.tts_provider_id}' 不存在\")\n        # 调用 Service 创建项目（返回 True/False）\n        entityRes = voice_service.create_voice(entity)\n\n        # 返回统一 Response\n        if entityRes is not None:\n            # 创建成功，可以返回 DTO 或者部分字段\n            res = VoiceResponseDTO(**entityRes.__dict__)\n            return Res(data=res, code=200, message=\"创建成功\")\n        else:\n            return Res(data=None, code=400, message=f\"音色 '{entity.name}' 已存在\")\n\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n\n\n# ====== 动态路由放在最后 ======\n\n@router.get(\"/{voice_id}\", response_model=Res[VoiceResponseDTO],\n            summary=\"查询音色\",\n            description=\"根据音色id查询音色信息\")\ndef get_voice(voice_id: int, voice_service: VoiceService = Depends(get_voice_service)):\n    entity = voice_service.get_voice(voice_id)\n    if entity:\n        res = VoiceResponseDTO(**entity.__dict__)\n        return Res(data=res, code=200, message=\"查询成功\")\n    else:\n        return Res(data=None, code=404, message=\"项目不存在\")\n\n\n# 修改，传入的参数是id\n@router.put(\"/{voice_id}\", response_model=Res[VoiceCreateDTO],\n            summary=\"修改音色信息\",\n            description=\"根据音色id修改音色信息,并且不能修改项目id\")\ndef update_voice(voice_id: int, dto: VoiceCreateDTO, voice_service: VoiceService = Depends(get_voice_service)):\n    voice = voice_service.get_voice(voice_id)\n    if voice is None:\n        return Res(data=None, code=404, message=\"音色不存在\")\n    res = voice_service.update_voice(voice_id, dto.dict())\n    if res:\n        return Res(data=dto, code=200, message=\"修改成功\")\n    else:\n        return Res(data=None, code=400, message=\"修改失败\")\n\n\n# 根据 id，删除\n@router.delete(\"/{voice_id}\", response_model=Res,\n               summary=\"删除音色\",\n               description=\"根据音色id删除音色信息\")\ndef delete_voice(voice_id: int, voice_service: VoiceService = Depends(get_voice_service)):\n    success = voice_service.delete_voice(voice_id)\n    if success:\n        return Res(data=None, code=200, message=\"删除成功\")\n    else:\n        return Res(data=None, code=400, message=\"删除失败或音色不存在\")\n\n\n# tts_provider的查询和修改\n# @router.get(\"/tts/provider/{tts_provider_id}\", response_model=Res[TTSProviderResponseDTO])\n# def get_tts_provider(tts_provider_id: int, tts_provider_service: TTSProviderService = Depends(get_tts_provider_service)):\n#     tts_provider = tts_provider_service.get_tts_provider(tts_provider_id)\n#     if tts_provider:\n#         res = TTSProviderResponseDTO(**tts_provider.__dict__)\n#         return Res(data=res, code=200, message=\"查询成功\")\n#     else:\n#         return Res(data=None, code=404, message=\"tts服务提供商不存在\")\n#\n# # tts_provider的修改\n# @router.put(\"/tts/provider/{tts_provider_id}\", response_model=Res[TTSProviderResponseDTO])\n# def update_tts_provider(tts_provider_id: int, dto: TTSProviderResponseDTO, tts_provider_service: TTSProviderService = Depends(get_tts_provider_service)):\n#     # 先判断是否存在\n#     tts_provider = tts_provider_service.get_tts_provider(tts_provider_id)\n#     if tts_provider is None:\n#         return Res(data=None, code=404, message=\"tts服务提供商不存在\")\n#     tts_provider = tts_provider_service.update_tts_provider(tts_provider_id, dto.dict(exclude_unset=True))\n#     if tts_provider:\n#         return Res(data=None, code=200, message=\"修改成功\")\n#     else:\n#         return Res(data=None, code=400, message=\"修改失败\")\n\n"
  },
  {
    "path": "SonicVale/app/services/chapter_service.py",
    "content": "import json\nimport logging\nimport os\nimport re\nimport shutil\nimport threading\nfrom collections import defaultdict\nfrom typing import List\n\nfrom sqlalchemy import Sequence\n\nfrom app.core.config import getConfigPath\nfrom app.core.text_correct_engine import TextCorrectorFinal\nfrom app.core.tts_engine import TTSEngine\nfrom app.db.database import SessionLocal\nfrom app.dto.line_dto import LineInitDTO\nfrom app.entity.chapter_entity import ChapterEntity\nfrom app.entity.line_entity import LineEntity\nfrom app.models.po import ChapterPO, RolePO, LinePO\n\nfrom app.repositories.chapter_repository import ChapterRepository\nfrom app.repositories.line_repository import LineRepository\n\nfrom app.core.prompts import get_context2lines_prompt, get_add_smart_role_and_voice\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.repositories.project_repository import ProjectRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.core.llm_engine import LLMEngine\nfrom app.repositories.voice_repository import VoiceRepository\n\n\nclass ChapterService:\n\n    def __init__(self, repository: ChapterRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_chapter(self,  entity: ChapterEntity):\n        \"\"\"创建新章节\n        - 检查同名章节是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n\n        chapter = self.repository.get_by_name(entity.title, entity.project_id)\n        if chapter:\n            logging.info(\"同名章节已存在\")\n            return None\n        # 手动将entity转化为po\n        po = ChapterPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = ChapterEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_chapter(self, chapter_id: int) -> ChapterEntity | None:\n        \"\"\"根据 ID 查询章节\"\"\"\n        po = self.repository.get_by_id(chapter_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = ChapterEntity(**data)\n        return res\n\n    def get_all_chapters(self,project_id: int) -> Sequence[ChapterEntity]:\n        \"\"\"获取所有章节列表\"\"\"\n        pos = self.repository.get_all(project_id)\n        # pos -> entities\n\n        entities = [\n            ChapterEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_chapter(self, chapter_id: int, data:dict) -> bool:\n        \"\"\"更新章节\n        - 可以只更新部分字段\n        - 检查同名冲突\n        - 检查project_id不能改变\n        \"\"\"\n        title = data[\"title\"]\n        project_id = data[\"project_id\"]\n        if self.repository.get_by_name(title, project_id) and self.repository.get_by_name(title,project_id).id != chapter_id:\n            return False\n        po = self.repository.get_by_id(chapter_id)\n        # 防止改变project_id\n        if po.project_id != project_id:\n            return False\n        self.repository.update(chapter_id, data)\n        return True\n\n    def delete_chapter(self, chapter_id: int) -> bool:\n        \"\"\"删除章节\n        \"\"\"\n        db = SessionLocal()\n        try :\n            chapter = self.repository.get_by_id(chapter_id)\n\n        #     移除资源内容\n            # 删除该路径所有内容\n            project_repository = ProjectRepository(db)\n            project = project_repository.get_by_id(chapter.project_id)\n            chapter_path = os.path.join(project.project_root_path, str(chapter.project_id), str(chapter_id))\n            if os.path.exists(chapter_path):\n                shutil.rmtree(chapter_path)  # 删除整个文件夹及其所有内容\n                logging.info(\"已删除目录及内容: %s\", chapter_path)\n            else:\n                logging.info(\"目录不存在: %s\", chapter_path)\n            #     先删除资源，再删除记录\n            res = self.repository.delete(chapter_id)\n            # 删除章节下所有台词\n            line_repository = LineRepository(db)\n            line_res = line_repository.delete_all_by_chapter_id(chapter_id)\n        finally:\n            db.close()\n        return res\n\n    # 先获取章节内容\n    def split_text(self, chapter_id: int, max_length: int = 1500) -> List[str]:\n        \"\"\"\n        将文本按标点/换行断句，并按最大长度分组，确保每段以标点结束。\n        支持中英文标点和换行符。\n        \"\"\"\n        content = self.get_chapter(chapter_id).text_content\n        # 去掉空行\n        content = \"\\n\".join([line for line in content.split(\"\\n\") if line.strip()])\n\n        # 如果最后没有句号/问号/感叹号/点号，自动补一个句号\n        if not re.search(r'[。！？.!?]$', content):\n            content += \"。\"\n\n        # 使用正则分割，支持中英文标点 + 逗号 + 换行\n        # [] 里列出所有可能的结束符号\n        sentences = re.findall(r'[^。！？.!?,，\\n]*[。！？.!?,，\\n]', content, re.MULTILINE | re.DOTALL)\n\n        chunks = []\n        buffer = \"\"\n\n        for sentence in sentences:\n            if len(buffer) + len(sentence) <= max_length:\n                buffer += sentence\n            else:\n                if buffer:\n                    chunks.append(buffer.strip())\n                buffer = sentence\n\n        if buffer:\n            chunks.append(buffer.strip())\n\n        return chunks\n\n    # 然后进行划分\n\n    # 然后循环解析，并保存\n    def fill_prompt(self,template: str, characters: list[str], emotions: list[str], strengths: list[str],\n                    novel_content: str) -> str:\n        result = template\n        result = result.replace(\"{possible_characters}\", \", \".join(characters))\n        result = result.replace(\"{possible_emotions}\", \", \".join(emotions))\n        result = result.replace(\"{possible_strengths}\", \", \".join(strengths))\n        result = result.replace(\"{novel_content}\", novel_content)\n        return result\n\n    def para_content(self, prompt:str,chapter_id: int,content: str = None,role_names: List[str] = None,emotion_names: List[str] = None,strength_names: List[str] = None,is_precise_fill: int = 0):\n        db = SessionLocal()\n        try :\n    #         获取content\n            chapter = self.repository.get_by_id(chapter_id)\n            # content = chapter.text_content\n    #          获取角色列表\n    #         role_repository = RoleRepository(db)\n    #         roles = role_repository.get_all(chapter.project_id)\n    #         role_names = [role.name for role in roles]\n    #         组装prompt\n    #         prompt = get_context2lines_prompt(role_names, content,emotion_names,strength_names)\n            prompt = self.fill_prompt(prompt, role_names, emotion_names, strength_names, content)\n\n        #   获取llm_provider\n\n            project_repository = ProjectRepository(db)\n            project = project_repository.get_by_id(chapter.project_id)\n            llm_provider_id = project.llm_provider_id\n            #\n            llm_provider_repository = LLMProviderRepository(db)\n            llm_provider = llm_provider_repository.get_by_id(llm_provider_id)\n            llm = LLMEngine(llm_provider.api_key, llm_provider.api_base_url, project.llm_model, llm_provider.custom_params)\n            try:\n                llm.generate_text_test(\"请输出一份用户信息，严格使用 JSON 格式，不要包含任何额外文字。字段包括：name, age, city\")\n                logging.info(\"LLM可用\")\n            except Exception as e:\n                logging.warning(\"LLM不可用\")\n                return {\n                    \"success\": False,\n                    \"message\": f\"LLM 不可用: {str(e)}\"\n                }\n            logging.info(\"开始内容解析\")\n            try:\n                result = llm.generate_text(prompt)\n                # 解析json，并且构造为List[LineInitDTO]\n                # 解析 JSON 字符串为 Python 对象\n                parsed_data = llm.save_load_json(result)\n                if not parsed_data:\n                    return {\n                        \"success\": False,\n                        \"message\": \"JSON 解析失败或返回空对象\",\n                    }\n                \n                # 验证 parsed_data 是否为有效的字典列表\n                if not isinstance(parsed_data, list):\n                    logging.error(\"LLM返回的数据不是列表，实际类型: %s\", type(parsed_data))\n                    return {\n                        \"success\": False,\n                        \"message\": f\"LLM返回的数据格式不正确，期望列表但收到: {type(parsed_data).__name__}\",\n                    }\n                \n                # 验证列表中的每个元素是否为字典\n                for idx, item in enumerate(parsed_data):\n                    if not isinstance(item, dict):\n                        logging.error(\"列表第 %d 项不是字典，实际类型: %s, 内容: %s\", idx, type(item), str(item)[:100])\n                        return {\n                            \"success\": False,\n                            \"message\": f\"LLM返回的数据格式不正确，列表第 {idx} 项应为字典但收到: {type(item).__name__}\",\n                        }\n                \n                # 这里进行自动填充\n                if is_precise_fill == 1:\n                    logging.info(\"开始自动填充\")\n                    corrector = TextCorrectorFinal()\n                    parsed_data = corrector.correct_ai_text(content, parsed_data)\n\n                # parsed_data = json.loads(result)\n                # 构造 List[LineInitDTO]\n                line_dtos: List[LineInitDTO] = [LineInitDTO(**item) for item in parsed_data]\n                return {\n                    \"success\": True,\n                    \"data\": line_dtos\n                }\n\n            except Exception as e:\n                logging.exception(\"调用 LLM 出错: %s\", e)\n                return {\n                    \"success\": False,\n                    \"message\": f\"调用 LLM 出错: {str(e)}\"\n                }\n        finally:\n            db.close()\n\n\n    # 导出指令\n    # def get_prompt_content(self,project_id, chapter_id,prompt):\n    #     db = SessionLocal()\n    #     try:\n    #         #         获取content\n    #         chapter = self.repository.get_by_id(chapter_id)\n    #         content = chapter.text_content\n    #         #          获取角色列表\n    #         role_repository = RoleRepository(db)\n    #         roles = role_repository.get_all(chapter.project_id)\n    #         role_names = [role.name for role in roles]\n    #         #         组装prompt\n    #         # 获取project\n    #\n    #         prompt = self.fill_prompt(prompt, role_names, emotion_names, strength_names, content)\n    #         prompt = get_context2lines_prompt(role_names, content)\n    #         return  prompt\n    #     finally:\n    #         db.close()\n    def add_smart_role_and_voice(self,project,content, role_names, voice_names):\n        # 智能匹配提示词，要写死吗？\n        db = SessionLocal()\n        try:\n            llm_provider_id = project.llm_provider_id\n            llm_provider_repository = LLMProviderRepository(db)\n            llm_provider = llm_provider_repository.get_by_id(llm_provider_id)\n            llm = LLMEngine(llm_provider.api_key, llm_provider.api_base_url, project.llm_model, llm_provider.custom_params)\n            prompt = get_add_smart_role_and_voice(content,role_names, voice_names)\n            result = llm.generate_smart_text(prompt)\n            parse_data = llm.save_load_json(result)\n            # 获取项目所有音色\n            voice_repository = VoiceRepository(db)\n            voices = voice_repository.get_all(project.tts_provider_id)\n            # map name- id\n            voice_id_map = {voice.name: voice.id for voice in voices}\n\n\n            # 对角色进行update\n            role_repository = RoleRepository(db)\n            res = []\n            for item in parse_data:\n                role = role_repository.get_by_name( item[\"role_name\"],project.id)\n                if role:\n                    if item[\"voice_name\"]:\n                        logging.info(\"更新角色音色：%s %s\", item[\"role_name\"], item[\"voice_name\"])\n                        role_repository.update(role.id, {\"default_voice_id\": voice_id_map.get(item[\"voice_name\"])})\n                        res.append({\"role_name\": item[\"role_name\"], \"voice_name\": item[\"voice_name\"]})\n\n            return True,res\n        except Exception as e:\n            logging.exception(\"LLM智能匹配出错: %s\", e)\n            return False, []\n        finally:\n            db.close()\n\n\n\n\n\n"
  },
  {
    "path": "SonicVale/app/services/emotion_service.py",
    "content": "from sqlalchemy import Sequence\n\nfrom app.entity.emotion_entity import EmotionEntity\nfrom app.models.po import EmotionPO\nfrom app.repositories.emotion_repository import EmotionRepository\n\n\nclass EmotionService:\n\n    def __init__(self, repository: EmotionRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_emotion(self,  entity: EmotionEntity):\n        \"\"\"创建新情绪枚举\n        - 检查同名情绪枚举是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n\n        emotion = self.repository.get_by_name(entity.name)\n        if emotion:\n            return None\n        # 手动将entity转化为po\n        po = EmotionPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = EmotionEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_emotion(self, emotion_id: int) -> EmotionEntity | None:\n        \"\"\"根据 ID 查询情绪枚举\"\"\"\n        po = self.repository.get_by_id(emotion_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = EmotionEntity(**data)\n        return res\n\n    def get_all_emotions(self) -> Sequence[EmotionEntity]:\n        \"\"\"获取所有情绪枚举列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            EmotionEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_emotion(self, emotion_id: int, data:dict) -> bool:\n        \"\"\"更新情绪枚举\n        - 可以只更新部分字段\n        \"\"\"\n        name = data.get(\"name\")\n        if self.repository.get_by_name(name):\n            return False\n        self.repository.update(emotion_id, data)\n        return True\n\n    def delete_emotion(self, emotion_id: int) -> bool:\n        \"\"\"删除情绪枚举\n        \"\"\"\n        res = self.repository.delete(emotion_id)\n        return res\n\n    def get_emotion_by_name(self, name: str) -> EmotionEntity | None:\n        \"\"\"根据名称查询情绪枚举\"\"\"\n        po = self.repository.get_by_name(name)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = EmotionEntity(**data)\n        return res\n"
  },
  {
    "path": "SonicVale/app/services/line_service.py",
    "content": "import contextlib\nimport hashlib\nimport logging\n\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nfrom collections import defaultdict\nfrom typing import List\n\nfrom openpyxl import Workbook\nfrom sqlalchemy import Sequence\n\n\nfrom app.core.audio_engin import AudioProcessor\nfrom app.core.config import getConfigPath, getFfmpegPath\nfrom app.core.subtitle import subtitle_engine\nfrom app.core.tts_engine import TTSEngine\nfrom app.dto.line_dto import LineCreateDTO, LineOrderDTO, LineAudioProcessDTO\nfrom app.entity.line_entity import LineEntity\nfrom app.models.po import LinePO, RolePO\nfrom app.repositories.line_repository import LineRepository\nfrom app.repositories.role_repository import RoleRepository\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\nfrom app.core.llm_engine import LLMEngine\n\nimport os\n\nimport numpy as np\nimport soundfile as sf\n\ndef _lock_key(path: str) -> str:\n    return hashlib.md5(path.encode(\"utf-8\")).hexdigest()\n_file_locks = defaultdict(threading.Lock)\nclass LineService:\n\n    def __init__(self, repository: LineRepository,role_repository: RoleRepository,tts_provider_repository: TTSProviderRepository, llm_provider_repository: LLMProviderRepository = None):\n        \"\"\"注入 repository\"\"\"\n\n        self.tts_provider_repository = tts_provider_repository\n        self.llm_provider_repository = llm_provider_repository\n        self.role_repository = role_repository\n        self.repository = repository\n\n    def create_line(self,  entity: LineEntity):\n        \"\"\"创建新台词\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n        # 手动将entity转化为po\n        po = LinePO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = LineEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_line(self, line_id: int) -> LineEntity | None:\n        \"\"\"根据 ID 查询台词\"\"\"\n        po = self.repository.get_by_id(line_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = LineEntity(**data)\n        return res\n\n    def get_all_lines(self,chapter_id: int) -> Sequence[LineEntity]:\n        \"\"\"获取所有台词列表\"\"\"\n        pos = self.repository.get_all(chapter_id)\n        # pos -> entities\n\n        entities = [\n            LineEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def delete_line(self, line_id: int) -> bool:\n        \"\"\"删除台词\n        \"\"\"\n        # 还要把audio_path删除\n        po = self.repository.get_by_id(line_id)\n        if po and po.audio_path:\n            with contextlib.suppress(FileNotFoundError):\n                os.remove(po.audio_path)\n        res = self.repository.delete(line_id)\n        return res\n    # 删除章节下所有台词\n    def delete_all_lines(self, chapter_id: int) -> bool:\n        \"\"\"删除章节下所有台词\n        \"\"\"\n        # 要移除所有的音频资源\n        for line in self.get_all_lines(chapter_id):\n            if line and line.audio_path:\n                with contextlib.suppress(FileNotFoundError):\n                    os.remove(line.audio_path)\n        return self.repository.delete_all_by_chapter_id(chapter_id)\n\n    # 单个台词新增\n    def add_new_line(self, line: LineCreateDTO,project_id,chapter_id,index,emotions_dict, strengths_dict,audio_path):\n    #     先判断角色是否存在\n        role = self.role_repository.get_by_name(line.role_name,project_id)\n        if role is None:\n            #         新增角色\n            role = self.role_repository.create(RolePO(name=line.role_name, project_id=project_id))\n        # 获取情绪id\n        emotion_id = emotions_dict.get(line.emotion_name)\n        # 获取强度id\n        strength_id = strengths_dict.get(line.strength_name)\n        res = self.repository.create(LinePO(text_content=line.text_content, role_id=role.id,\n                                           chapter_id=chapter_id,line_order = index+1,emotion_id=emotion_id,strength_id=strength_id))\n\n        # 新增台词,这里搞个audio_path\n\n        # audio_path = os.path.join(getConfigPath(), str(project_id), str(chapter_id), \"audio\")\n        # os.makedirs(audio_path, exist_ok=True)\n        res_path = os.path.join(audio_path, \"id_\"+str(res.id) + \".wav\")\n        self.repository.update(res.id, {\"audio_path\": res_path})\n\n\n    def update_init_lines(self, lines: list, project_id: object, chapter_id: object,emotions_dict, strengths_dict,audio_path) -> None:\n        for index, line in enumerate(lines):\n            self.add_new_line(line,project_id,chapter_id,index,emotions_dict, strengths_dict,audio_path)\n\n    # 获取章节下所有台词\n\n    # 更新line\n    def update_line(self, line_id: int, data: dict) -> bool:\n        po = self.repository.get_by_id(line_id)\n        if po is None:\n            return False\n        res = self.repository.update(line_id, data)\n        if res is None:\n            return False\n        return True\n    # 生成音频（服务器和本地两种方式）\n\n    def generate_audio(self, reference_path: str,tts_provider_id,content,emo_text:str,emo_vector:list[float],save_path= None):\n        #\n        tts_provider = self.tts_provider_repository.get_by_id(tts_provider_id)\n        if tts_provider is None:\n            raise Exception(f\"TTS服务提供商不存在（ID: {tts_provider_id}）\")\n        \n        if not tts_provider.api_base_url:\n            raise Exception(\"TTS服务地址未配置，请先在配置中心设置TTS服务\")\n            \n        tts_engine = TTSEngine(tts_provider.api_base_url)\n        \n        # 检查参考音频路径是否有效\n        if not reference_path:\n            raise Exception(\"参考音频路径未设置，请检查角色音色配置\")\n\n        key = _lock_key(reference_path)\n        lock = _file_locks[key]\n\n        with lock:\n            try:\n                audio_exists = tts_engine.check_audio_exists(reference_path)\n            except Exception as e:\n                raise Exception(f\"检查参考音频失败: {str(e)}\")\n            \n            if not audio_exists:\n                # 检查本地文件是否存在\n                if not os.path.isfile(reference_path):\n                    raise Exception(f\"参考音频文件不存在: {reference_path}\")\n                \n                upload_result = tts_engine.upload_audio(reference_path, reference_path)\n                if upload_result.get('code') and upload_result.get('code') != 200:\n                    raise Exception(f\"上传参考音频失败: {upload_result.get('msg', '未知错误')}\")\n            \n            # 合成音频\n            return tts_engine.synthesize(content, reference_path, emo_text, emo_vector, save_path)\n\n    # 将角色role_id下所有台词的role_id都置位空\n    def clear_role_id(self, role_id: int):\n        # 先获取role_id下所有台词实体\n        pos = self.repository.get_lines_by_role_id(role_id)\n        for po in pos:\n            self.repository.update(po.id, {\"role_id\": None})\n\n    def batch_update_line_order(self,line_orders:List[LineOrderDTO]):\n        for line_order in line_orders:\n            self.update_line(line_order.id,{\"line_order\":line_order.line_order})\n        return True\n\n    def update_audio_path(self, id, dto) -> bool:\n        try:\n            po = self.get_line(id)\n            old_path = po.audio_path\n            new_path = dto.audio_path\n\n            if not old_path:\n                return False  # 原始路径为空\n\n            if not os.path.exists(old_path):\n                return False  # 原始文件不存在\n\n            if os.path.exists(new_path):\n                return False  # 目标文件已存在，避免覆盖\n\n            # 确保目标目录存在\n            os.makedirs(os.path.dirname(new_path), exist_ok=True)\n\n            # 重命名文件\n            shutil.move(old_path, new_path)\n\n            # 更新数据库\n            self.update_line(id, {\"audio_path\": new_path})\n            return True\n\n        except Exception as e:\n            logging.exception(\"[update_audio_path] 失败: %s\", e)\n            return False\n\n    def process_audio_ffmpeg(\n            self,\n            audio_path: str,\n            speed: float = 1.0,\n            volume: float = 1.0,\n            start_ms: int | None = None,\n            end_ms: int | None = None,\n            out_path: str | None = None,\n            keep_format: bool = True,  # 是否保持原文件采样率/声道\n            default_sr: int = 44100,\n            default_ch: int = 2\n    ):\n        \"\"\"\n        使用 ffmpeg 对音频进行变速 (0.5~2.0)、音量调整、可选裁剪。\n        输出 WAV PCM16。\n        如果 keep_format=True，则保持输入文件的 sr/ch 不变。\n        \"\"\"\n        ffmpeg_path = getFfmpegPath()\n        if not os.path.exists(audio_path):\n            raise FileNotFoundError(audio_path)\n\n        # 获取原始参数\n        info = sf.info(audio_path)\n        target_sr = info.samplerate if keep_format else default_sr\n        target_ch = info.channels if keep_format else default_ch\n\n        # 参数规整\n        speed = float(np.clip(speed or 1.0, 0.5, 2.0))\n        volume = 1.0 if volume is None else max(0.0, float(volume))\n\n        # 输出路径\n        target_path = out_path or audio_path\n        os.makedirs(os.path.dirname(target_path) or \".\", exist_ok=True)\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".wav\",\n                                         dir=os.path.dirname(target_path) or \".\") as tmp:\n            tmp_path = tmp.name\n\n        # 构建 ffmpeg 命令\n        filter_chain = [f\"atempo={speed}\"]\n        if abs(volume - 1.0) > 1e-6:\n            filter_chain.append(f\"volume={volume}\")\n\n        cmd = [ffmpeg_path, \"-y\"]\n        if start_ms is not None:\n            cmd.extend([\"-ss\", str(start_ms / 1000)])\n        cmd.extend([\"-i\", audio_path])\n        if end_ms is not None:\n            cmd.extend([\"-to\", str(end_ms / 1000)])\n        cmd.extend([\n            \"-af\", \",\".join(filter_chain),\n            \"-ar\", str(target_sr),\n            \"-ac\", str(target_ch),\n            \"-c:a\", \"pcm_s16le\",\n            tmp_path\n        ])\n\n        subprocess.run(cmd, check=True,\n                       creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == \"win32\" else 0)\n\n        # 软限幅：避免 clipping\n        data, sr = sf.read(tmp_path, dtype=\"float32\", always_2d=True)\n        peak = float(np.max(np.abs(data)))\n        if peak > 1.0:\n            data = data / peak\n            sf.write(tmp_path, data, sr, format=\"WAV\", subtype=\"PCM_16\")\n\n        os.replace(tmp_path, target_path)\n        return target_path\n\n\n    # 删除区间进行拼接\n    def process_audio_ffmpeg_cut(\n            self,\n            audio_path: str,\n            speed: float = 1.0,\n            volume: float = 1.0,\n            start_ms: int | None = None,\n            end_ms: int | None = None,\n            silence_sec: float = 0.0,  # 末尾静音时长，单位秒\n            out_path: str | None = None,\n            keep_format: bool = True,  # 是否保持原文件采样率/声道\n            default_sr: int = 44100,\n            default_ch: int = 2\n    ):\n        \"\"\"\n        使用 ffmpeg 对音频进行变速 (0.5~2.0)、音量调整。\n        删除 [start_ms, end_ms] 区间，并拼接前后音频。\n        输出 WAV PCM16。\n        可在末尾附加 silence_sec 秒静音。\n        \"\"\"\n        ffmpeg_path = getFfmpegPath()\n        if not os.path.exists(audio_path):\n            raise FileNotFoundError(audio_path)\n\n        # 获取原始参数\n        info = sf.info(audio_path)\n        target_sr = info.samplerate if keep_format else default_sr\n        target_ch = info.channels if keep_format else default_ch\n\n        # 参数规整\n        speed = float(np.clip(speed or 1.0, 0.5, 2.0))\n        volume = 1.0 if volume is None else max(0.0, float(volume))\n\n        # 输出路径\n        target_path = out_path or audio_path\n        os.makedirs(os.path.dirname(target_path) or \".\", exist_ok=True)\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".wav\",\n                                         dir=os.path.dirname(target_path) or \".\") as tmp:\n            tmp_path = tmp.name\n\n        # 构建 ffmpeg 命令\n        if start_ms is None or end_ms is None or end_ms <= start_ms:\n            # 无剪切\n            if silence_sec > 0:\n                # 添加静音\n                cmd = [\n                    ffmpeg_path, \"-y\",\n                    \"-i\", audio_path,\n                    \"-f\", \"lavfi\", \"-t\", str(silence_sec),\n                    \"-i\", f\"anullsrc=channel_layout={'stereo' if target_ch == 2 else 'mono'}:sample_rate={target_sr}\",\n                    \"-filter_complex\",\n                    f\"[0:a]atempo={speed},volume={volume}[main];\"\n                    f\"[main][1:a]concat=n=2:v=0:a=1[out]\",\n                    \"-map\", \"[out]\",\n                    \"-ar\", str(target_sr),\n                    \"-ac\", str(target_ch),\n                    \"-c:a\", \"pcm_s16le\",\n                    tmp_path\n                ]\n            elif silence_sec < 0:\n                # 裁掉末尾 abs(silence_sec)\n                cut_dur = info.duration + silence_sec\n                if cut_dur <= 0:\n                    cut_dur = 0  # 整段裁掉\n\n                cmd = [\n                    ffmpeg_path, \"-y\",\n                    \"-i\", audio_path,\n                    \"-filter_complex\",\n                    f\"[0:a]atempo={speed},volume={volume},atrim=0:{cut_dur}[out]\",\n                    \"-map\", \"[out]\",\n                    \"-ar\", str(target_sr),\n                    \"-ac\", str(target_ch),\n                    \"-c:a\", \"pcm_s16le\",\n                    tmp_path\n                ]\n            else:\n                # 不处理末尾\n                cmd = [\n                    ffmpeg_path, \"-y\", \"-i\", audio_path,\n                    \"-af\", f\"atempo={speed},volume={volume}\",\n                    \"-ar\", str(target_sr),\n                    \"-ac\", str(target_ch),\n                    \"-c:a\", \"pcm_s16le\",\n                    tmp_path\n                ]\n\n\n        else:\n\n            # 剪切\n\n            start_sec = start_ms / 1000\n\n            end_sec = end_ms / 1000\n\n            if silence_sec > 0:\n\n                # 拼接 + 添加静音\n\n                cmd = [\n\n                    ffmpeg_path, \"-y\",\n\n                    \"-i\", audio_path,\n\n                    \"-f\", \"lavfi\", \"-t\", str(silence_sec),\n\n                    \"-i\", f\"anullsrc=channel_layout={'stereo' if target_ch == 2 else 'mono'}:sample_rate={target_sr}\",\n\n                    \"-filter_complex\",\n\n                    f\"[0:a]atrim=0:{start_sec},asetpts=PTS-STARTPTS[first];\"\n\n                    f\"[0:a]atrim={end_sec},asetpts=PTS-STARTPTS[second];\"\n\n                    f\"[first][second]concat=n=2:v=0:a=1,atempo={speed},volume={volume}[main];\"\n\n                    f\"[main][1:a]concat=n=2:v=0:a=1[out]\",\n\n                    \"-map\", \"[out]\",\n\n                    \"-ar\", str(target_sr),\n\n                    \"-ac\", str(target_ch),\n\n                    \"-c:a\", \"pcm_s16le\",\n\n                    tmp_path\n\n                ]\n\n            elif silence_sec < 0:\n\n                # 拼接后再裁掉末尾\n\n                cut_dur = info.duration + silence_sec\n                if cut_dur <= 0:\n                    cut_dur = 0  # 整段裁掉\n\n                cmd = [\n\n                    ffmpeg_path, \"-y\", \"-i\", audio_path,\n\n                    \"-filter_complex\",\n\n                    f\"[0:a]atrim=0:{start_sec},asetpts=PTS-STARTPTS[first];\"\n\n                    f\"[0:a]atrim={end_sec},asetpts=PTS-STARTPTS[second];\"\n\n                    f\"[first][second]concat=n=2:v=0:a=1,atempo={speed},volume={volume},atrim=0:{cut_dur}[out]\",\n\n                    \"-map\", \"[out]\",\n\n                    \"-ar\", str(target_sr),\n\n                    \"-ac\", str(target_ch),\n\n                    \"-c:a\", \"pcm_s16le\",\n\n                    tmp_path\n\n                ]\n\n            else:\n\n                # 拼接但不处理末尾\n\n                cmd = [\n\n                    ffmpeg_path, \"-y\", \"-i\", audio_path,\n\n                    \"-filter_complex\",\n\n                    f\"[0:a]atrim=0:{start_sec},asetpts=PTS-STARTPTS[first];\"\n\n                    f\"[0:a]atrim={end_sec},asetpts=PTS-STARTPTS[second];\"\n\n                    f\"[first][second]concat=n=2:v=0:a=1,atempo={speed},volume={volume}[out]\",\n\n                    \"-map\", \"[out]\",\n\n                    \"-ar\", str(target_sr),\n\n                    \"-ac\", str(target_ch),\n\n                    \"-c:a\", \"pcm_s16le\",\n\n                    tmp_path\n\n                ]\n\n        # 执行 ffmpeg\n        subprocess.run(\n            cmd, check=True,\n            creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == \"win32\" else 0\n        )\n\n        # 软限幅：避免 clipping\n        data, sr = sf.read(tmp_path, dtype=\"float32\", always_2d=True)\n        peak = float(np.max(np.abs(data)))\n        if peak > 1.0:\n            data = data / peak\n            sf.write(tmp_path, data, sr, format=\"WAV\", subtype=\"PCM_16\")\n\n        os.replace(tmp_path, target_path)\n        return target_path\n\n    def process_audio(self, line_id, dto:LineAudioProcessDTO):\n        line = self.get_line(line_id)\n        if line:\n        #     读取音频文件\n        #     audio_file =self.process_audio_ffmpeg(line.audio_path, dto.speed, dto.volume,dto.start_ms,dto.end_ms)\n        # 删除拼接\n        #     audio_file = self.process_audio_ffmpeg_cut(line.audio_path, dto.speed, dto.volume, dto.start_ms, dto.end_ms, dto.tail_silence_sec,dto.current_ms)\n            processor = AudioProcessor(line.audio_path)\n            start_ms = dto.start_ms\n            end_ms = dto.end_ms\n            speed = dto.speed\n            volume = dto.volume\n            current_ms = dto.current_ms\n            silence_sec = dto.silence_sec\n            # ---------- (1) 优先裁剪 ----------\n            if start_ms is not None and end_ms is not None and end_ms > start_ms:\n                logging.info(\"裁剪\")\n                processor.cut(start_ms, end_ms)\n\n            # ---------- (2) 插入静音 ----------\n            elif current_ms is not None and silence_sec is not None and silence_sec != 0:\n                logging.info(\"插入静音\")\n                processor.insert_silence(current_ms, silence_sec)\n\n            # ---------- (3) 末尾静音/裁剪 ----------\n            elif current_ms is None and silence_sec is not None and silence_sec != 0:\n                logging.info(\"末尾静音/裁剪\")\n                processor.append_silence(silence_sec)\n\n            # ---------- (4) 音量 + 变速 ----------\n            if speed != 1.0:\n                processor.change_speed(speed)\n            if volume != 1.0:\n                processor.change_volume(volume)\n            logging.info(\"音频处理完成\")\n            return True\n\n        else:\n            return False\n\n    # 导出音频,合并音频，并且导出字幕\n    def concat_wav_files(self,paths, out_path, verify=True, block_frames=262144):\n        \"\"\"\n        按顺序把若干 WAV 合并到 out_path。\n        假设：采样率与声道一致（如需更稳，可保留 verify=True 做轻校验）。\n        \"\"\"\n        assert paths and len(paths) >= 1, \"至少提供一个文件路径\"\n        os.makedirs(os.path.dirname(out_path) or \".\", exist_ok=True)\n\n        # 以首文件格式为准\n        info0 = sf.info(paths[0])\n        sr, ch, subtype = info0.samplerate, info0.channels, info0.subtype or \"PCM_16\"\n\n        # 可选校验\n        if verify:\n            for p in paths[1:]:\n                info = sf.info(p)\n                if info.samplerate != sr or info.channels != ch:\n                    raise ValueError(\n                        f\"格式不一致：{p} (sr={info.samplerate}, ch={info.channels}) vs 首文件 (sr={sr}, ch={ch})\")\n\n        # 流式写入\n        with sf.SoundFile(out_path, mode='w', samplerate=sr, channels=ch, format='WAV', subtype=subtype) as fout:\n            for p in paths:\n                with sf.SoundFile(p, mode='r') as fin:\n                    if verify and (fin.samplerate != sr or fin.channels != ch):\n                        raise ValueError(f\"参数不一致：{p}\")\n                    while True:\n                        block = fin.read(block_frames, dtype='float32', always_2d=True)\n                        if len(block) == 0:\n                            break\n                        fout.write(block.astype(np.float32, copy=False))\n        return out_path\n\n\n\n    def export_lines_to_excel(self,lines, file_path=\"all_lines.xlsx\"):\n        # 1) 取出所有数据\n        # lines = self.repository.get_all(chapter_id)\n\n        # 2) 创建 Excel 工作簿\n        wb = Workbook()\n        ws = wb.active\n        ws.title = \"Lines\"\n\n        # 3) 写表头（根据你的数据字段调整）\n        headers = [\"序号\",\"角色\", \"台词\"]\n        ws.append(headers)\n\n        # 4) 写内容\n        for line in lines:\n            role = self.role_repository.get_by_id(line.role_id)\n            role_name = role.name if role else \"未知角色\"\n            ws.append([\n                line.line_order,\n                role_name,\n                line.text_content\n            ])\n        # 5) 保存到文件\n        wb.save(file_path)\n        return file_path\n\n    def export_audio(self, chapter_id, single=False):\n        \"\"\"导出音频与字幕\n        \n        Returns:\n            dict: 包含导出结果的详细信息\n                - success: bool, 是否成功\n                - message: str, 错误信息（如果失败）\n                - audio_path: str, 合并后的音频路径\n                - subtitle_path: str, 字幕路径\n                - missing_files: list, 缺失的音频文件列表\n        \"\"\"\n        try:\n            # 拿到所有的台词\n            lines = self.repository.get_all(chapter_id)\n            \n            if not lines:\n                return {\"success\": False, \"message\": \"该章节没有台词\"}\n            \n            # 过滤掉空路径和不存在的文件\n            valid_lines = []\n            missing_files = []\n            for line in lines:\n                if not line.audio_path:\n                    missing_files.append(f\"台词#{line.id}(序号{line.line_order}): 无音频路径\")\n                elif not os.path.exists(line.audio_path):\n                    missing_files.append(f\"台词#{line.id}(序号{line.line_order}): 文件不存在 - {line.audio_path}\")\n                else:\n                    valid_lines.append(line)\n            \n            if not valid_lines:\n                return {\n                    \"success\": False, \n                    \"message\": \"没有有效的音频文件可导出\",\n                    \"missing_files\": missing_files\n                }\n            \n            paths = [line.audio_path for line in valid_lines]\n            \n            # 把paths[0]的path去掉后面的文件名，得到文件夹路径\n            output_dir_path = os.path.join(os.path.dirname(paths[0]), \"result\")\n            # 不存在就创建\n            os.makedirs(output_dir_path, exist_ok=True)\n            \n            # 放到result目录下\n            output_path = os.path.join(output_dir_path, \"result.wav\")\n            \n            # 合并音频文件\n            try:\n                self.concat_wav_files(paths, output_path)\n            except ValueError as e:\n                return {\n                    \"success\": False,\n                    \"message\": f\"音频合并失败: {str(e)}\",\n                    \"missing_files\": missing_files\n                }\n            except Exception as e:\n                logging.exception(\"[export_audio] concat_wav_files 失败\")\n                return {\n                    \"success\": False,\n                    \"message\": f\"音频合并异常: {str(e)}\",\n                    \"missing_files\": missing_files\n                }\n            \n            # 生成字幕\n            output_subtitle_path = os.path.join(output_dir_path, \"result.srt\")\n            try:\n                subtitle_engine.generate_subtitle(output_path, output_subtitle_path)\n            except Exception as e:\n                logging.exception(\"[export_audio] 生成整体字幕失败\")\n                # 字幕生成失败不影响音频导出，继续执行\n            \n            # 生成单条字幕（如果需要）\n            if single:\n                subtitle_dir_path = os.path.join(os.path.dirname(paths[0]), \"subtitles\")\n                # 先清空这个文件夹\n                shutil.rmtree(subtitle_dir_path, ignore_errors=True)\n                os.makedirs(subtitle_dir_path, exist_ok=True)\n                \n                for line in valid_lines:\n                    try:\n                        path = line.audio_path\n                        base_name = os.path.splitext(os.path.basename(path))[0]\n                        subtitle_path = os.path.join(subtitle_dir_path, base_name + \".srt\")\n                        subtitle_engine.generate_subtitle(path, subtitle_path)\n                        # 将subtitle_path写进line.subtitle_path\n                        self.repository.update(line.id, {\"subtitle_path\": subtitle_path})\n                    except Exception as e:\n                        logging.warning(f\"[export_audio] 生成单条字幕失败 line#{line.id}: {e}\")\n                        # 单条字幕失败不影响整体导出\n            \n            # 导出所有数据到Excel\n            try:\n                self.export_lines_to_excel(lines, os.path.join(output_dir_path, \"all_lines.xlsx\"))\n            except Exception as e:\n                logging.warning(f\"[export_audio] 导出Excel失败: {e}\")\n                # Excel导出失败不影响整体导出\n            \n            result = {\n                \"success\": True,\n                \"audio_path\": output_path,\n                \"subtitle_path\": output_subtitle_path,\n                \"exported_count\": len(valid_lines),\n                \"total_count\": len(lines)\n            }\n            \n            if missing_files:\n                result[\"missing_files\"] = missing_files\n                result[\"message\"] = f\"导出成功，但有{len(missing_files)}条台词缺少音频\"\n            \n            return result\n            \n        except Exception as e:\n            logging.exception(\"[export_audio] 未预期的错误\")\n            return {\"success\": False, \"message\": f\"导出失败: {str(e)}\"}\n\n\n\n\n    def generate_subtitle(self, line_id, dto):\n        # 获取台词\n        line = self.get_line(line_id)\n        if line:\n            # 将音频文件路径的后缀改为.srt\n            dto.subtitle_path = os.path.splitext(dto.subtitle_path)[0] + \".srt\"\n            subtitle_engine.generate_subtitle(line.audio_path,dto.subtitle_path)\n            return dto.subtitle_path\n        else:\n            return None\n#     字幕矫正 - 拼音匹配\n    def correct_subtitle_pinyin(self, text, output_subtitle_path):\n        \"\"\"\n        使用拼音匹配算法矫正字幕\n        \n        text: 原始正确文本\n        output_subtitle_path: 字幕文件路径\n        \"\"\"\n        subtitle_engine.correct_srt_file(text, output_subtitle_path)\n\n#     字幕矫正 - LLM\n    def correct_subtitle_llm(self, text, output_subtitle_path, llm_provider_id: int, llm_model: str, batch_size: int = 20):\n        \"\"\"\n        使用LLM矫正字幕\n        \n        text: 原始正确文本\n        output_subtitle_path: 字幕文件路径\n        llm_provider_id: LLM提供商ID\n        llm_model: LLM模型名称\n        batch_size: 分批处理时每批的条数\n        \"\"\"\n        if not self.llm_provider_repository:\n            raise Exception(\"LLM Provider Repository 未配置\")\n        \n        llm_provider = self.llm_provider_repository.get_by_id(llm_provider_id)\n        if llm_provider is None:\n            raise Exception(f\"LLM服务提供商不存在（ID: {llm_provider_id}）\")\n        \n        llm_engine = LLMEngine(\n            api_key=llm_provider.api_key,\n            base_url=llm_provider.api_base_url,\n            model_name=llm_model,\n            custom_params=llm_provider.custom_params or \"{}\"\n        )\n        \n        subtitle_engine.correct_srt_file_with_llm(\n            text, \n            output_subtitle_path,\n            llm_engine=llm_engine,\n            batch_size=batch_size\n        )\n\n#     生成字幕\n#     def generate_subtitle(self, res_path):\n#         subtitle_engine.generate_subtitle(res_path,res_path+\".srt\")\n"
  },
  {
    "path": "SonicVale/app/services/llm_provider_service.py",
    "content": "import json\nimport logging\n\nfrom aiohttp.abc import HTTPException\nfrom sqlalchemy import Sequence\n\nfrom app.core.llm_engine import LLMEngine\nfrom app.entity.llm_provider_entity import LLMProviderEntity\nfrom app.models.po import LLMProviderPO\n\nfrom app.repositories.llm_provider_repository import LLMProviderRepository\n\n\nclass LLMProviderService:\n\n    def __init__(self, repository: LLMProviderRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_llm_provider(self,  entity: LLMProviderEntity):\n        \"\"\"创建新LLM供应商\n        - 检查同名LLM供应商是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n        llm_provider = self.repository.get_by_name(entity.name)\n        if llm_provider:\n            return None\n        # 手动将entity转化为po\n        po = LLMProviderPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = LLMProviderEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_llm_provider(self, llm_provider_id: int) -> LLMProviderEntity | None:\n        \"\"\"根据 ID 查询LLM供应商\"\"\"\n        po = self.repository.get_by_id(llm_provider_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = LLMProviderEntity(**data)\n        return res\n\n    def get_all_llm_providers(self) -> Sequence[LLMProviderEntity]:\n        \"\"\"获取所有LLM供应商列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            LLMProviderEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_llm_provider(self, llm_provider_id: int, data:dict) -> bool:\n        \"\"\"更新LLM供应商\n        - 可以只更新部分字段\n        - 检查同名冲突\n        \"\"\"\n        name = data[\"name\"]\n        if self.repository.get_by_name(name) and self.repository.get_by_name(name).id != llm_provider_id:\n            return False\n        self.repository.update(llm_provider_id, data)\n        return True\n\n    def delete_llm_provider(self, llm_provider_id: int) -> bool:\n        \"\"\"删除LLM供应商\n        - 可以添加业务校验，例如LLM供应商下有章节是否允许删除\n        - 后续需要级联删除所有章节内容\n        \"\"\"\n        res = self.repository.delete(llm_provider_id)\n        return res\n\n    def test_llm_provider(self, entity: LLMProviderEntity):\n        \"\"\"测试LLM供应商\"\"\"\n        # 按逗号划分模型名称\n        if entity.api_base_url is None or entity.api_key is None or entity.model_list is None:\n            return False\n        model_lists = entity.model_list.split(\",\")\n        custom_params = entity.custom_params\n        llm = LLMEngine(entity.api_key, entity.api_base_url, model_lists[0],custom_params)\n        try:\n            res = llm.generate_text_test(\"请输出一份用户信息，严格使用 JSON 格式，不要包含任何额外文字。字段包括：name, age, city\")\n        except Exception as e:\n            return  False,str(e)\n        logging.info(\"测试结果为：%s\", res)\n        if res is None:\n            return False,\"LLM 未返回任何内容\"\n\n        # 7. 校验返回是否为合法 JSON\n        try:\n            # res = res.replace(\"```json\",'')\n            # res = res.replace(\"```\",'')\n            json.loads(res)\n        except json.JSONDecodeError:\n            return False, \"LLM 返回的内容不是合法 JSON，请检查模型 / 提示词\"\n        return True,\"测试成功\"\n\n\n\n\n"
  },
  {
    "path": "SonicVale/app/services/multi_emotion_voice_service.py",
    "content": "from sqlalchemy import Sequence\n\nfrom app.entity.multi_emotion_voice_entity import MultiEmotionVoiceEntity\nfrom app.models.po import MultiEmotionVoicePO\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\n\n\nclass MultiEmotionVoiceService:\n\n    def __init__(self, repository: MultiEmotionVoiceRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_multi_emotion_voice(self,  entity: MultiEmotionVoiceEntity):\n        \"\"\"创建新多情绪音色变体\n        - 检查同名多情绪音色变体是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n        if entity.voice_id is None or entity.emotion_id is None or entity.strength_id is None:\n            return None\n        multi_emotion_voice = self.repository.get_by_voice_id_emotion_id_strength_id(entity.voice_id, entity.emotion_id, entity.strength_id)\n        if multi_emotion_voice:\n            return None\n        po = MultiEmotionVoicePO(**entity.__dict__)\n        res = self.repository.create(po)\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = MultiEmotionVoiceEntity(**data)\n\n        # 将po转化为entity\n        return entity\n    # 根据voice_id,emotion_id,strength_id查询多情绪音色变体\n    def get_multi_emotion_voice_by_voice_id_emotion_id_strength_id(self, voice_id: int, emotion_id: int, strength_id: int) -> MultiEmotionVoiceEntity | None:\n        \"\"\"根据voice_id,emotion_id,strength_id查询多情绪音色变体\"\"\"\n        po = self.repository.get_by_voice_id_emotion_id_strength_id(voice_id, emotion_id, strength_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = MultiEmotionVoiceEntity(**data)\n        return res\n\n    def get_multi_emotion_voice_by_voice_id(self, voice_id: int) -> list[MultiEmotionVoiceEntity] | None:\n        \"\"\"根据 voice_id 查询所有的多情绪音色变体\"\"\"\n        pos = self.repository.get_by_voice_id(voice_id)\n        entities = [\n            MultiEmotionVoiceEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def get_multi_emotion_voice_by_id(self, multi_emotion_voice_id: int) -> MultiEmotionVoiceEntity | None:\n        \"\"\"根据 ID 获取多情绪音色变体\"\"\"\n        po = self.repository.get_by_id(multi_emotion_voice_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = MultiEmotionVoiceEntity(**data)\n        return res\n    def get_all_multi_emotion_voices(self) -> list[MultiEmotionVoiceEntity]:\n        \"\"\"获取所有多情绪音色变体列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            MultiEmotionVoiceEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_multi_emotion_voice(self, multi_emotion_voice_id: int, data:dict) -> bool:\n        \"\"\"更新多情绪音色变体\n        - 可以只更新部分字段\n        - 检查同名冲突\n        - 检查project_id不能改变\n        \"\"\"\n        self.repository.update(multi_emotion_voice_id, data)\n        return True\n\n    def delete_multi_emotion_voice(self, multi_emotion_voice_id: int) -> bool:\n        \"\"\"删除多情绪音色变体\n        \"\"\"\n        res = self.repository.delete(multi_emotion_voice_id)\n        return res\n\n#     删除voice下所有多情绪音色变体\n    def delete_multi_emotion_voice_by_voice_id(self, voice_id: int) -> bool:\n        \"\"\"删除voice下所有多情绪音色变体\n        \"\"\"\n        res = self.repository.delete_multi_emotion_voice_by_voice_id(voice_id)\n        return res\n"
  },
  {
    "path": "SonicVale/app/services/project_service.py",
    "content": "import os\nimport re\nimport logging\n\nfrom sqlalchemy import Sequence\n\nfrom app.entity.project_entity import ProjectEntity\nfrom app.models.po import ProjectPO\n\nfrom app.repositories.project_repository import ProjectRepository\n\n\nclass ProjectService:\n\n    def __init__(self, repository: ProjectRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_project(self,  entity: ProjectEntity):\n        \"\"\"创建新项目\n        - 检查同名项目是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n        project = self.repository.get_by_name(entity.name)\n        if project:\n            return None, \"项目已存在\"\n        # 判断项目根路径是否存在\n        if not os.path.exists(entity.project_root_path):\n            logging.info(\"项目根路径不存在\")\n            return  None, \"项目根路径不存在\"\n        # 手动将entity转化为po\n        po = ProjectPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = ProjectEntity(**data)\n\n        # 将po转化为entity\n        return entity, \"创建成功\"\n\n\n    def get_project(self, project_id: int) -> ProjectEntity | None:\n        \"\"\"根据 ID 查询项目\"\"\"\n        po = self.repository.get_by_id(project_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = ProjectEntity(**data)\n        return res\n\n    def get_all_projects(self) -> Sequence[ProjectEntity]:\n        \"\"\"获取所有项目列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            ProjectEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_project(self, project_id: int, data:dict) -> bool:\n        \"\"\"更新项目\n        - 可以只更新部分字段\n        - 检查同名冲突\n        \"\"\"\n        name = data[\"name\"]\n        if self.repository.get_by_name(name) and self.repository.get_by_name(name).id != project_id:\n            return False\n        self.repository.update(project_id, data)\n        return True\n\n    def delete_project(self, project_id: int) -> bool:\n        \"\"\"删除项目\n        - 可以添加业务校验，例如项目下有章节是否允许删除\n        - 后续需要级联删除所有章节内容\n        \"\"\"\n        res = self.repository.delete(project_id)\n        return res\n\n\n    def search_projects(self, keyword: str) -> Sequence[ProjectEntity]:\n        \"\"\"模糊搜索项目\"\"\"\n\n    # 解析content，按照章节\n    def parse_content(self, content):\n        \"\"\"解析内容，按照章节\"\"\"\n        # 正则匹配常见章节格式（支持中英文数字）\n        chapter_pattern = re.compile(\n            r'(第[\\d一二三四五六七八九十百千]+[章回节部卷].*?)(?=\\n|$)'\n        )\n        # 找到所有章节标题位置\n        matches = list(chapter_pattern.finditer(content))\n        chapters = []\n        # 如果没找到章节，直接返回整个文本\n        if not matches:\n            return chapters\n\n        for i, match in enumerate(matches):\n            start = match.end()\n            end = matches[i + 1].start() if i + 1 < len(matches) else len(content)\n\n            chapter_name = match.group(1).strip()\n            chapter_content = content[start:end].strip()\n            chapters.append({\n                \"chapter_name\": chapter_name,\n                \"content\": chapter_content\n            })\n        # 排序\n        # chapters.sort(key=lambda x: x[\"chapter_name\"])\n        # 不需要排序了，因为是顺序解析得到的\n        return  chapters\n"
  },
  {
    "path": "SonicVale/app/services/prompt_service.py",
    "content": "from numba.scripts.generate_lower_listing import description\nfrom sqlalchemy import Sequence\n\nfrom app.core.enums import TaskEnum\nfrom app.core.llm_engine import LLMEngine\nfrom app.core.prompts import get_prompt_str\nfrom app.entity.prompt_entity import PromptEntity\nfrom app.models.po import PromptPO\n\nfrom app.repositories.prompt_repository import PromptRepository\n\n\nclass PromptService:\n\n    def __init__(self, repository: PromptRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    # 拆分台词prompt验证\n    def validate_prompt_with_DUBBING(self, content: str):\n        REQUIRED_BLOCKS = [\n            # (\"<possible_characters>\", \"</possible_characters>\", \"{possible_characters}\"),\n            # (\"<possible_emotions>\", \"</possible_emotions>\", \"{possible_emotions}\"),\n            # (\"<possible_strengths>\", \"</possible_strengths>\", \"{possible_strengths}\"),\n            (\"<novel_content>\", \"</novel_content>\", \"{novel_content}\"),\n        ]\n        for start, end, placeholder in REQUIRED_BLOCKS:\n            if start not in content or end not in content or placeholder not in content:\n                return False\n        return  True\n\n    # 创建默认提示词\n    def create_default_prompt(self):\n        task = TaskEnum.DUBBING\n        name = \"默认拆分台词提示词\"\n        description = \"默认拆分台词提示词\"\n        content = get_prompt_str()\n        self.create_prompt(PromptEntity(name=name, description=description, content=content, task=task))\n        return True\n\n    def create_prompt(self,  entity: PromptEntity):\n        \"\"\"创建新提示词\n        - 检查同名提示词是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n        prompt = self.repository.get_by_name(entity.name)\n        if prompt:\n            return None\n        # 判断task是否存在于task_enum中\n        if entity.task not in TaskEnum:\n            return None\n\n        # 验证拆分台词的提示词\n        if entity.task == TaskEnum.DUBBING:\n            isValid = self.validate_prompt_with_DUBBING(entity.content)\n            if not isValid:\n                return None\n\n        # 手动将entity转化为po\n        po = PromptPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = PromptEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_prompt(self, prompt_id: int) -> PromptEntity | None:\n        \"\"\"根据 ID 查询提示词\"\"\"\n        po = self.repository.get_by_id(prompt_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = PromptEntity(**data)\n        return res\n\n    def get_all_prompts(self) -> Sequence[PromptEntity]:\n        \"\"\"获取所有提示词列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            PromptEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_prompt(self, prompt_id: int, data:dict) -> bool:\n        \"\"\"更新提示词\n        - 可以只更新部分字段\n        - 检查同名冲突\n        \"\"\"\n        name = data[\"name\"]\n        task = data.get(\"task\")\n        if self.repository.get_by_name(name) and self.repository.get_by_name(name).id != prompt_id:\n            return False\n        # 如果改的是content\n\n        if TaskEnum(task) == TaskEnum.DUBBING:\n            if not self.validate_prompt_with_DUBBING(content=data['content']):\n                return False\n\n        self.repository.update(prompt_id, data)\n        return True\n\n    def delete_prompt(self, prompt_id: int) -> bool:\n        \"\"\"删除提示词\n        - 可以添加业务校验，例如提示词下有章节是否允许删除\n        - 后续需要级联删除所有章节内容\n        \"\"\"\n        res = self.repository.delete(prompt_id)\n        return res\n    # 根据task 获取提示词列表\n    def get_prompt_by_task(self, task: str) -> Sequence[PromptEntity]:\n        pos = self.repository.get_by_task(task)\n        entities = [\n            PromptEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    # 获取所有的stak\n    def get_all_tasks(self) -> Sequence[str]:\n        # 这些写死了，后面也在这添加，不改成数据库\n        return list(TaskEnum)\n\n\n    # def test_prompt(self, entity: PromptEntity):\n    #     \"\"\"测试提示词\"\"\"\n    #     # 按逗号划分模型名称\n    #     if entity.api_base_url is None or entity.api_key is None or entity.model_list is None:\n    #         return False\n    #     model_lists = entity.model_list.split(\",\")\n    #     llm = LLMEngine(entity.api_key, entity.api_base_url, model_lists[0])\n    #     res = llm.generate_text_test(\"你好\")\n    #     if res is not None:\n    #         return True\n    #     return False\n\n#     根据名字获取提示词\n    def get_prompt_by_name(self, name: str) -> PromptEntity | None:\n        \"\"\"根据名字获取提示词\"\"\"\n        po = self.repository.get_by_name(name)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = PromptEntity(**data)\n        return res\n\n\n\n\n"
  },
  {
    "path": "SonicVale/app/services/role_service.py",
    "content": "from sqlalchemy import Sequence\n\nfrom app.entity.role_entity import RoleEntity\nfrom app.models.po import RolePO\nfrom app.repositories.role_repository import RoleRepository\n\n\nclass RoleService:\n\n    def __init__(self, repository: RoleRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_role(self,  entity: RoleEntity):\n        \"\"\"创建新角色\n        - 检查同名角色是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n\n        role = self.repository.get_by_name(entity.name, entity.project_id)\n        if role:\n            return None\n        # 手动将entity转化为po\n        po = RolePO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = RoleEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_role(self, role_id: int) -> RoleEntity | None:\n        \"\"\"根据 ID 查询角色\"\"\"\n        po = self.repository.get_by_id(role_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = RoleEntity(**data)\n        return res\n\n    def get_all_roles(self,project_id: int) -> Sequence[RoleEntity]:\n        \"\"\"获取所有角色列表\"\"\"\n        pos = self.repository.get_all(project_id)\n        # pos -> entities\n\n        entities = [\n            RoleEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_role(self, role_id: int, data:dict) -> bool:\n        \"\"\"更新角色\n        - 可以只更新部分字段\n        - 检查同名冲突\n        - 检查project_id不能改变\n        \"\"\"\n        name = data[\"name\"]\n        project_id = data[\"project_id\"]\n        if self.repository.get_by_name(name, project_id) and self.repository.get_by_name(name,project_id).id != role_id:\n            return False\n        po = self.repository.get_by_id(role_id)\n        # 防止改变project_id\n        if po.project_id != project_id:\n            return False\n        self.repository.update(role_id, data)\n        return True\n\n    def delete_role(self, role_id: int) -> bool:\n        \"\"\"删除角色\n        \"\"\"\n        res = self.repository.delete(role_id)\n        return res\n"
  },
  {
    "path": "SonicVale/app/services/strength_service.py",
    "content": "from sqlalchemy import Sequence\n\nfrom app.entity.strength_entity import StrengthEntity\nfrom app.models.po import StrengthPO\nfrom app.repositories.strength_repository import StrengthRepository\n\n\nclass StrengthService:\n\n    def __init__(self, repository: StrengthRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def create_strength(self,  entity: StrengthEntity):\n        \"\"\"创建新情绪强弱枚举\n        - 检查同名情绪强弱枚举是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n\n        strength = self.repository.get_by_name(entity.name)\n        if strength:\n            return None\n        # 手动将entity转化为po\n        po = StrengthPO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = StrengthEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_strength(self, strength_id: int) -> StrengthEntity | None:\n        \"\"\"根据 ID 查询情绪强弱枚举\"\"\"\n        po = self.repository.get_by_id(strength_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = StrengthEntity(**data)\n        return res\n\n    def get_all_strengths(self) -> Sequence[StrengthEntity]:\n        \"\"\"获取所有情绪强弱枚举列表\"\"\"\n        pos = self.repository.get_all()\n        # pos -> entities\n\n        entities = [\n            StrengthEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_strength(self, strength_id: int, data:dict) -> bool:\n        \"\"\"更新情绪强弱枚举\n        - 可以只更新部分字段\n        \"\"\"\n\n        name = data.get(\"name\")\n        if name and self.repository.get_by_name(name):\n            return False\n        self.repository.update(strength_id, data)\n        return True\n\n    def delete_strength(self, strength_id: int) -> bool:\n        \"\"\"删除情绪强弱枚举\n        \"\"\"\n        res = self.repository.delete(strength_id)\n        return res\n\n    def get_strength_by_name(self, name: str) -> StrengthEntity | None:\n        \"\"\"根据名称查询情绪强弱枚举\"\"\"\n        po = self.repository.get_by_name(name)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = StrengthEntity(**data)\n        return res\n"
  },
  {
    "path": "SonicVale/app/services/tts_provider_service.py",
    "content": "import requests\nimport logging\nfrom sqlalchemy import Sequence\n\nfrom app.entity.tts_provider_entity import TTSProviderEntity\nfrom app.models.po import TTSProviderPO\nfrom app.repositories.tts_provider_repository import TTSProviderRepository\n\n\nclass TTSProviderService:\n\n    def __init__(self, repository: TTSProviderRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n\n    def get_all_tts_providers(self) -> list[TTSProviderEntity]:\n        \"\"\"查询所有tts供应商\"\"\"\n        pos = self.repository.get_all()\n        res = [TTSProviderEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}) for po in pos]\n        return res\n\n    def get_tts_provider(self, tts_provider_id: int) -> TTSProviderEntity | None:\n        \"\"\"根据 ID 查询tts供应商\"\"\"\n        po = self.repository.get_by_id(tts_provider_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = TTSProviderEntity(**data)\n        return res\n\n\n    def update_tts_provider(self, tts_provider_id: int, data:dict) -> bool:\n        \"\"\"更新tts供应商\n        - 可以只更新部分字段\n        - 检查同名冲突\n        - 检查project_id不能改变\n        \"\"\"\n        name = data[\"name\"]\n        if self.repository.get_by_name(name) and self.repository.get_by_name(name).id != tts_provider_id:\n            return False\n        self.repository.update(tts_provider_id, data)\n        return True\n\n    def delete_tts_provider(self, tts_provider_id: int) -> bool:\n        \"\"\"删除tts供应商\n        \"\"\"\n        res = self.repository.delete(tts_provider_id)\n        return res\n\n    def create_default_tts_provider(self):\n        \"\"\"创建默认的tts供应商\"\"\"\n        if self.repository.get_by_name(\"index_tts\") :\n            return\n        if self.repository.get_by_id(1) :\n            return\n        po = TTSProviderPO(name=\"index_tts\", id=1,status=1, api_base_url=\"\", api_key=\"\")\n        self.repository.create(po)\n\n    def test_tts_provider(self, entity: TTSProviderEntity):\n        # 拿到url\n        api_base_url = entity.api_base_url\n        if not api_base_url:\n            return False\n        # ping api\n        # 调用\n        try:\n            resp = requests.get(api_base_url, timeout=5)\n\n            # 如果返回 200-399 都认为是通的（有些服务会 302 重定向）\n            if 200 <= resp.status_code < 400:\n                try:\n                    data = resp.json()\n                    if \"endpoints\" in data:\n                        return True\n                    else:\n                        logging.error(\"TTS provider test failed: 'endpoints' missing in response\")\n                        return False\n                except ValueError:\n                    logging.error(\"TTS provider test failed: response is not valid JSON\")\n                    return False\n            else:\n                logging.error(\"TTS provider test failed: status %s\", resp.status_code)\n                return False\n\n        except Exception as e:\n            logging.exception(\"TTS provider test failed: %s\", e)\n            return False\n\n\n\n"
  },
  {
    "path": "SonicVale/app/services/voice_service.py",
    "content": "import json\nimport os\nimport shutil\nimport tempfile\nimport zipfile\nfrom typing import List, Tuple\n\nfrom sqlalchemy import Sequence\n\nfrom app.core.audio_engin import AudioProcessor\nfrom app.dto.voice_dto import VoiceAudioProcessDTO\nfrom app.entity.voice_entity import VoiceEntity\nfrom app.models.po import VoicePO\nfrom app.repositories.multi_emotion_voice_repository import MultiEmotionVoiceRepository\nfrom app.repositories.voice_repository import VoiceRepository\n\n\nclass VoiceService:\n\n    def __init__(self, repository: VoiceRepository,multi_emotion_voice_repository: MultiEmotionVoiceRepository):\n        \"\"\"注入 repository\"\"\"\n        self.repository = repository\n        self.multi_emotion_voice_repository = multi_emotion_voice_repository\n\n    def create_voice(self,  entity: VoiceEntity):\n        \"\"\"创建新音色\n        - 检查同名音色是否存在\n        - 如果存在，抛出异常或返回错误\n        - 调用 repository.create 插入数据库\n        \"\"\"\n\n        voice = self.repository.get_by_name(entity.name, entity.tts_provider_id)\n        if voice:\n            return None\n        # 手动将entity转化为po\n        po = VoicePO(**entity.__dict__)\n        res = self.repository.create(po)\n\n        # res(po) --> entity\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        entity = VoiceEntity(**data)\n\n        # 将po转化为entity\n        return entity\n\n\n    def get_voice(self, voice_id: int) -> VoiceEntity | None:\n        \"\"\"根据 ID 查询音色\"\"\"\n        po = self.repository.get_by_id(voice_id)\n        if not po:\n            return None\n        data = {k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")}\n        res = VoiceEntity(**data)\n        return res\n\n    def get_all_voices(self,tts_provider_id: int) -> Sequence[VoiceEntity]:\n        \"\"\"获取所有音色列表\"\"\"\n        pos = self.repository.get_all(tts_provider_id)\n        # pos -> entities\n\n        entities = [\n            VoiceEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n            for po in pos\n        ]\n        return entities\n\n    def update_voice(self, voice_id: int, data:dict) -> bool:\n        \"\"\"更新音色\n        - 可以只更新部分字段\n        - 检查同名冲突\n        - 检查project_id不能改变\n        \"\"\"\n        name = data[\"name\"]\n        tts_provider_id = data[\"tts_provider_id\"]\n        if self.repository.get_by_name(name, tts_provider_id) and self.repository.get_by_name(name,tts_provider_id).id != voice_id:\n            return False\n        po = self.repository.get_by_id(voice_id)\n        # 防止改变project_id\n        if po.tts_provider_id != tts_provider_id:\n            return False\n        self.repository.update(voice_id, data)\n        return True\n\n    def delete_voice(self, voice_id: int) -> bool:\n        \"\"\"删除音色,需要保证事务\n        \"\"\"\n\n        res = self.repository.delete(voice_id)\n        self.multi_emotion_voice_repository.delete_multi_emotion_voice_by_voice_id(voice_id)\n        return res\n\n    def export_voices(self, tts_provider_id: int, export_path: str, ids: List[int] | None = None) -> str:\n        \"\"\"导出音色库到zip文件\n        - 获取所有音色\n        - 将音色信息和对应的音频文件打包到zip\n        - 返回zip文件路径\n        \"\"\"\n        if ids is None:\n            voices = self.get_all_voices(tts_provider_id)\n        else:\n            pos = self.repository.get_by_ids(tts_provider_id, ids)\n            voices = [\n                VoiceEntity(**{k: v for k, v in po.__dict__.items() if not k.startswith(\"_\")})\n                for po in pos\n            ]\n        if not voices:\n            return None\n\n        # 确保导出目录存在\n        os.makedirs(os.path.dirname(export_path) if os.path.dirname(export_path) else \".\", exist_ok=True)\n\n        # 创建zip文件\n        with zipfile.ZipFile(export_path, 'w', zipfile.ZIP_DEFLATED) as zipf:\n            # 准备音色元数据\n            voices_metadata = []\n            \n            for voice in voices:\n                voice_data = {\n                    \"name\": voice.name,\n                    \"description\": voice.description,\n                    \"is_multi_emotion\": voice.is_multi_emotion,\n                    \"reference_file\": None\n                }\n                \n                # 如果有参考音频文件，添加到zip\n                if voice.reference_path and os.path.exists(voice.reference_path):\n                    # 保持原文件名\n                    file_name = os.path.basename(voice.reference_path)\n                    # 使用音色名称作为子目录，避免文件名冲突\n                    archive_path = f\"voices/{voice.name}/{file_name}\"\n                    zipf.write(voice.reference_path, archive_path)\n                    voice_data[\"reference_file\"] = archive_path\n                \n                voices_metadata.append(voice_data)\n            \n            # 写入元数据文件\n            metadata_json = json.dumps(voices_metadata, ensure_ascii=False, indent=2)\n            zipf.writestr(\"voices_metadata.json\", metadata_json)\n        \n        return export_path\n\n    def import_voices(self, tts_provider_id: int, zip_path: str, target_dir: str) -> Tuple[int, int, List[str]]:\n        \"\"\"从zip文件导入音色库\n        - 解压zip文件\n        - 将音频文件复制到指定目录\n        - 添加音色到数据库（跳过重名的）\n        - 返回: (成功数量, 跳过数量, 跳过的音色名称列表)\n        \"\"\"\n        if not os.path.exists(zip_path):\n            raise FileNotFoundError(f\"zip文件不存在: {zip_path}\")\n        \n        # 确保目标目录存在\n        os.makedirs(target_dir, exist_ok=True)\n        \n        success_count = 0\n        skipped_count = 0\n        skipped_names = []\n        \n        # 创建临时目录解压\n        with tempfile.TemporaryDirectory() as temp_dir:\n            # 解压zip文件\n            with zipfile.ZipFile(zip_path, 'r') as zipf:\n                zipf.extractall(temp_dir)\n            \n            # 读取元数据\n            metadata_path = os.path.join(temp_dir, \"voices_metadata.json\")\n            if not os.path.exists(metadata_path):\n                raise ValueError(\"无效的音色库文件：缺少voices_metadata.json\")\n            \n            with open(metadata_path, 'r', encoding='utf-8') as f:\n                voices_metadata = json.load(f)\n            \n            for voice_data in voices_metadata:\n                voice_name = voice_data[\"name\"]\n                \n                # 检查是否已存在同名音色\n                existing = self.repository.get_by_name(voice_name, tts_provider_id)\n                if existing:\n                    skipped_count += 1\n                    skipped_names.append(voice_name)\n                    continue\n                \n                reference_path = None\n                \n                # 如果有参考音频文件，复制到目标目录\n                if voice_data.get(\"reference_file\"):\n                    source_file = os.path.join(temp_dir, voice_data[\"reference_file\"])\n                    if os.path.exists(source_file):\n                        # 使用音色名称作为文件名，保留原扩展名\n                        file_ext = os.path.splitext(source_file)[1]\n                        file_name = f\"{voice_name}{file_ext}\"\n                        dest_file = os.path.join(target_dir, file_name)\n                        shutil.copy2(source_file, dest_file)\n                        reference_path = dest_file\n                \n                # 创建音色实体\n                entity = VoiceEntity(\n                    name=voice_name,\n                    tts_provider_id=tts_provider_id,\n                    reference_path=reference_path,\n                    description=voice_data.get(\"description\"),\n                    is_multi_emotion=voice_data.get(\"is_multi_emotion\", 0)\n                )\n                \n                # 保存到数据库\n                po = VoicePO(**entity.__dict__)\n                self.repository.create(po)\n                success_count += 1\n        \n        return success_count, skipped_count, skipped_names\n\n    def process_audio(self, dto: VoiceAudioProcessDTO) -> bool:\n        \"\"\"处理音色参考音频\n        - 变速、音量调整\n        - 裁剪/删除区间\n        - 添加/裁剪末尾静音\n        - 指定位置插入静音\n        \"\"\"\n        audio_path = dto.audio_path\n        if not os.path.exists(audio_path):\n            raise FileNotFoundError(audio_path)\n        \n        processor = AudioProcessor(audio_path)\n        \n        start_ms = dto.start_ms\n        end_ms = dto.end_ms\n        speed = dto.speed\n        volume = dto.volume\n        current_ms = dto.current_ms\n        silence_sec = dto.silence_sec\n        \n        # ---------- (1) 优先裁剪 ----------\n        if start_ms is not None and end_ms is not None and end_ms > start_ms:\n            processor.cut(start_ms, end_ms)\n        \n        # ---------- (2) 插入静音 ----------\n        elif current_ms is not None and silence_sec is not None and silence_sec != 0:\n            processor.insert_silence(current_ms, silence_sec)\n        \n        # ---------- (3) 末尾静音/裁剪 ----------\n        elif current_ms is None and silence_sec is not None and silence_sec != 0:\n            processor.append_silence(silence_sec)\n        \n        # ---------- (4) 音量 + 变速 ----------\n        if speed != 1.0:\n            processor.change_speed(speed)\n        if volume != 1.0:\n            processor.change_volume(volume)\n        \n        return True\n\n    def copy_voice(self, source_voice_id: int, new_name: str, target_dir: str = None) -> VoiceEntity:\n        \"\"\"复制音色\n        - 获取源音色信息\n        - 复制音频文件到目标目录\n        - 创建新音色记录\n        - 返回新音色实体\n        \"\"\"\n        # 获取源音色\n        source_voice = self.get_voice(source_voice_id)\n        if not source_voice:\n            raise ValueError(\"源音色不存在\")\n        \n        # 检查新名称是否已存在\n        existing = self.repository.get_by_name(new_name, source_voice.tts_provider_id)\n        if existing:\n            raise ValueError(f\"音色名称 '{new_name}' 已存在\")\n        \n        new_reference_path = None\n        \n        # 处理音频文件复制\n        if source_voice.reference_path and os.path.exists(source_voice.reference_path):\n            # 确定目标目录\n            if target_dir and target_dir.strip():\n                dest_dir = target_dir.strip()\n            else:\n                # 使用源音频所在目录\n                dest_dir = os.path.dirname(source_voice.reference_path)\n            \n            # 确保目标目录存在\n            os.makedirs(dest_dir, exist_ok=True)\n            \n            # 获取源文件扩展名\n            file_ext = os.path.splitext(source_voice.reference_path)[1]\n            # 使用新音色名作为文件名\n            new_file_name = f\"{new_name}{file_ext}\"\n            new_reference_path = os.path.join(dest_dir, new_file_name)\n            \n            # 复制文件\n            shutil.copy2(source_voice.reference_path, new_reference_path)\n        \n        # 创建新音色实体\n        new_entity = VoiceEntity(\n            name=new_name,\n            tts_provider_id=source_voice.tts_provider_id,\n            reference_path=new_reference_path,\n            description=source_voice.description,\n            is_multi_emotion=source_voice.is_multi_emotion\n        )\n        \n        # 保存到数据库\n        po = VoicePO(**new_entity.__dict__)\n        res = self.repository.create(po)\n        \n        # 返回新建的音色实体\n        data = {k: v for k, v in res.__dict__.items() if not k.startswith(\"_\")}\n        return VoiceEntity(**data)\n"
  },
  {
    "path": "SonicVale/requirements.txt",
    "content": "fastapi==0.119.0\nnumba==0.61.2\nnumpy==2.3.3\nopenai==2.8.0\nopenpyxl==3.1.5\npydantic==2.12.2\npypinyin==0.55.0\nRequests==2.32.5\nsoundfile==0.13.1\nSQLAlchemy==2.0.44\nstarlette==0.48.0\nuvicorn==0.37.0\n"
  },
  {
    "path": "sonicvale-front/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\nrelease\ndist-ssr\n*.local\n\n*.exe\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "sonicvale-front/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "sonicvale-front/README.md",
    "content": "# Vue 3 + Vite\n\nThis template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.\n\nLearn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).\n"
  },
  {
    "path": "sonicvale-front/electron/logger.js",
    "content": "// logger.js\nconst log = require('electron-log');\nconst iconv = require('iconv-lite');\n\n// 保存原始 console（避免丢失）\nconst raw = { ...console };\n\n// 配置日志\nlog.transports.file.level = 'silly';   // 全部记录\nlog.transports.console.level = false;  // 避免重复打印\n\n// 接管 console 方法\n['log', 'info', 'warn', 'error'].forEach((level) => {\n  console[level] = (...args) => {\n    log[level](...args);\n    raw[level](...args); // 保持原来在终端里能看到\n  };\n});\n\n// 中文转码工具（可用于 stdout/stderr）\nfunction decodeText(buffer) {\n  let text = buffer.toString('utf8');\n  if (/�/.test(text)) {\n    text = iconv.decode(buffer, 'gbk');\n  }\n  return text.trim();\n}\n\nmodule.exports = { log, decodeText };\n"
  },
  {
    "path": "sonicvale-front/electron/main.js",
    "content": "\nconst logger = require('./logger');\nconst { decodeText } = require('./logger');\nconst { app, BrowserWindow, ipcMain, dialog, shell } = require('electron')\nconst path = require('path')\nconst fs = require('fs')\nconst { spawn, exec } = require('child_process')\nconst os = require('os')\nconst http = require('http')\n\n\nlet backendProcess = null\n\nfunction startBackend() {\n  const isDev = !app.isPackaged\n  const exePath = isDev\n    ? path.join(__dirname, 'main.exe') // dev 环境路径\n    : path.join(process.resourcesPath, 'app.asar.unpacked', 'electron', 'main.exe') // prod 路径\n\n  console.log('启动后端：', exePath)\n\n  backendProcess = spawn(exePath, [], {\n    cwd: path.dirname(exePath),\n    detached: true,  // ❗关闭主进程时能跟着退出\n    stdio: ['ignore', 'pipe', 'pipe'], // 输出日志供调试\n  })\n\n  // 日志输出（可选）\n  backendProcess.stdout.on('data', data => {\n    console.log(`[后端] ${decodeText(data)}`);\n  });\n\n  backendProcess.stderr.on('data', data => {\n    console.error(`[后端错误] ${decodeText(data)}`);\n  });\n\n  backendProcess.on('exit', (code, signal) => {\n    console.log(`后端退出，code=${code}, signal=${signal}`);\n  });\n}\n\nfunction waitForBackendReady(retries = 60, delay = 500) {\n  return new Promise((resolve, reject) => {\n    let attempts = 0\n    const check = () => {\n      const req = http.get('http://127.0.0.1:8200/docs', res => {\n        res.destroy()\n        resolve(true)\n      }).on('error', err => {\n        if (++attempts >= retries) reject(err)\n        else setTimeout(check, delay)\n      })\n    }\n    check()\n  })\n}\n\nfunction createWindow() {\n  const win = new BrowserWindow({\n\n    width: 1360,\n    height: 765,\n    show: false, // ✅ 先不显示，等最大化后再显示\n    icon: path.join(__dirname, '../resource/icon/yingu.ico'),\n\n    webPreferences: {\n      preload: path.join(__dirname, 'preload.js'),\n      nodeIntegration: false,\n      contextIsolation: true,\n      sandbox: false,\n      webSecurity: false,\n    },\n    autoHideMenuBar: true, // 这会让菜单栏自动隐藏，但通过 Alt 可以唤出\n\n  })\n\n  win.once('ready-to-show', () => {\n    win.maximize() // ✅ 启动时自动最大化（不是全屏）\n    win.show()     // ✅ 再显示窗口\n  })\n  const isDev = !app.isPackaged\n  if (isDev) {\n    // 开发环境：直连 Vite\n    win.loadURL('http://localhost:5173')\n    // win.webContents.openDevTools({ mode: 'detach' })\n  } else {\n    // 生产环境：直接加载打包后的静态文件，不阻塞首屏\n    win.loadFile(path.join(__dirname, '../dist/index.html'))\n\n    // 非阻塞地检测后端是否就绪，用于日志/提示\n    waitForBackendReady()\n      .then(() => console.log('后端就绪'))\n      .catch(e => {\n        console.error('后端未就绪:', e)\n        // 可选：给用户一个友好提示页（不想覆盖 UI 就注释掉下面两行）\n        mainWindow.loadURL('data:text/html,<h1 style=\"font-family:sans-serif\">backend is not ready</h1><p>please restart now</p>')\n      })\n  }\n}\n\n// ============== 事件入口 ===============\n\napp.whenReady().then(async () => {\n  startBackend()\n  try {\n    await waitForBackendReady()\n    createWindow()\n  } catch (err) {\n    console.error('后端启动失败:', err)\n    const errorWin = new BrowserWindow({ width: 600, height: 300 })\n    errorWin.loadURL(`data:text/html;charset=utf-8,\n  <!DOCTYPE html>\n  <html>\n    <head><meta charset=\"UTF-8\"></head>\n    <body>\n      <h2 style=\"font-family:sans-serif\">后端启动失败</h2>\n      <p>请检查后端程序并重启应用</p>\n    </body>\n  </html>\n`);\n  }\n})\n\n// 杀死后端\nfunction killBackendTree(child) {\n  if (!child || !child.pid) return\n  const pid = child.pid\n\n  if (process.platform === 'win32') {\n    exec(`taskkill /PID ${pid} /T /F`, (err) => {\n      if (err) console.warn('taskkill 失败：', err.message)\n    })\n  } else {\n    try {\n      // 先温柔地\n      process.kill(pid, 'SIGTERM')\n      // 兜底：0.8s 后还活着就强杀整个进程组\n      setTimeout(() => {\n        try { process.kill(-pid, 'SIGKILL') } catch { }\n        try { process.kill(pid, 'SIGKILL') } catch { }\n      }, 800)\n    } catch (e) {\n      // 可能已退出\n    }\n  }\n\n}\nfunction shutdown() {\n  killBackendTree(backendProcess)\n}\napp.on('before-quit', shutdown)\napp.on('will-quit', shutdown)\napp.on('quit', shutdown)\n\napp.on('window-all-closed', () => {\n  shutdown()\n  if (process.platform !== 'darwin') app.quit()\n})\n\n// 处理 Ctrl+C / 任务管理器结束 等\nprocess.on('SIGINT', shutdown)\nprocess.on('SIGTERM', shutdown)\nprocess.on('exit', shutdown)\n\n\n// ============== IPC 处理 ===============\n// 选择参考音频\nipcMain.handle('dialog:pick-audio', async () => {\n  const { canceled, filePaths } = await dialog.showOpenDialog({\n    title: '选择参考音频',\n    properties: ['openFile'],\n    filters: [\n      { name: 'Audio', extensions: ['mp3', 'wav', 'm4a', 'ogg', 'flac'] }\n    ]\n  })\n\n  if (canceled || !filePaths || !filePaths[0]) return null\n  return filePaths[0] // 返回绝对路径\n})\n\n// 打开文件夹\nipcMain.handle('dialog:open-folder', async (event, folderPath) => {\n  if (!folderPath) return\n\n  try {\n    await shell.openPath(folderPath)\n    return true\n  } catch (e) {\n    console.error('打开文件夹失败', e)\n    return false\n  }\n})\n\n//选择音色文件夹\nipcMain.handle('select-voice-folder', async () => {\n  const result = await dialog.showOpenDialog({\n    properties: ['openDirectory']\n  })\n  if (result.canceled || result.filePaths.length === 0) return null\n\n  const rootPath = result.filePaths[0]\n  const folders = fs.readdirSync(rootPath, { withFileTypes: true }).filter(dirent => dirent.isDirectory())\n\n  const resultList = []\n\n  for (const folder of folders) {\n    const emotion = folder.name\n    const emotionPath = path.join(rootPath, emotion)\n    const files = fs.readdirSync(emotionPath)\n\n    for (const file of files) {\n      const strength = path.parse(file).name\n      const reference_path = path.join(emotionPath, file)\n\n      resultList.push({\n        voice_name: path.basename(rootPath),\n        emotion_name: emotion,\n        strength_name: strength,\n        reference_path\n      })\n    }\n  }\n\n  return resultList\n})\n\n\n// ✅ 选择文件夹：返回选中的绝对路径\nipcMain.handle('dialog:selectDir', async () => {\n  const result = await dialog.showOpenDialog({\n    title: '选择项目根路径',\n    properties: ['openDirectory', 'createDirectory']\n  })\n  if (result.canceled || !result.filePaths || !result.filePaths.length) return null\n  return result.filePaths[0]\n})\n\n// 保存文件对话框\nipcMain.handle('dialog:save-file', async (event, options) => {\n  const { title, defaultPath, filters } = options || {}\n  const result = await dialog.showSaveDialog({\n    title: title || '保存文件',\n    defaultPath: defaultPath || '',\n    filters: filters || [{ name: '所有文件', extensions: ['*'] }]\n  })\n  if (result.canceled || !result.filePath) return null\n  return result.filePath\n})\n\n// 选择文件对话框\nipcMain.handle('dialog:pick-file', async (event, options) => {\n  const { title, filters } = options || {}\n  const result = await dialog.showOpenDialog({\n    title: title || '选择文件',\n    properties: ['openFile'],\n    filters: filters || [{ name: '所有文件', extensions: ['*'] }]\n  })\n  if (result.canceled || !result.filePaths || !result.filePaths.length) return null\n  return result.filePaths[0]\n})\n\n// 选择目录对话框\nipcMain.handle('dialog:pick-directory', async (event, options) => {\n  const { title } = options || {}\n  const result = await dialog.showOpenDialog({\n    title: title || '选择目录',\n    properties: ['openDirectory', 'createDirectory']\n  })\n  if (result.canceled || !result.filePaths || !result.filePaths.length) return null\n  return result.filePaths[0]\n})\n\n// 写入文件（用于音频下载等）\nipcMain.handle('fs:write-file', async (event, { filePath, data }) => {\n  try {\n    // data 是 Uint8Array 转成的普通数组，需要转回 Buffer\n    const buffer = Buffer.from(data)\n    fs.writeFileSync(filePath, buffer)\n    return { success: true }\n  } catch (error) {\n    console.error('写入文件失败:', error)\n    return { success: false, error: error.message }\n  }\n})\n\n// 复制文件（用于音频下载等）\nipcMain.handle('fs:copy-file', async (event, { sourcePath, destPath }) => {\n  try {\n    fs.copyFileSync(sourcePath, destPath)\n    return { success: true }\n  } catch (error) {\n    console.error('复制文件失败:', error)\n    return { success: false, error: error.message }\n  }\n})\n\n"
  },
  {
    "path": "sonicvale-front/electron/preload.js",
    "content": "// electron/preload.js\nconst { contextBridge, ipcRenderer } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconsole.log('[preload] injected, electron:', process.versions.electron)\n\n// 将绝对路径转换成 file:// URL，跨平台可用\nfunction pathToFileUrl(p) {\n  if (!p) return ''\n  const isWin = process.platform === 'win32'\n  const normalized = isWin ? p.replace(/\\\\/g, '/') : p\n  const prefix = isWin ? 'file:///' : 'file://'\n  return prefix + encodeURI(normalized)\n}\n\n// 获取用户主目录\nfunction getUserHome() {\n  return os.homedir()\n}\n\n// 暴露给渲染进程的 API\ncontextBridge.exposeInMainWorld('native', {\n  /**\n   * 打开系统文件选择对话框，选音频文件\n   * @returns Promise<string|null> 绝对路径，取消时为 null\n   */\n  pickAudio: () => ipcRenderer.invoke('dialog:pick-audio'),\n\n  /**\n   * 把绝对路径转为 file:// URL\n   * @param {string} p\n   * @returns {string}\n   */\n  pathToFileUrl,\n  // \n  openFolder: (folderPath) => ipcRenderer.invoke('dialog:open-folder', folderPath),\n  // 选择音色文件夹\n  selectVoiceFolder: () => ipcRenderer.invoke('select-voice-folder'),\n  // 选择项目根路径文件夹\n  selectDir: () => ipcRenderer.invoke('dialog:selectDir'),\n  \n  /**\n   * 保存文件对话框\n   * @param {Object} options - { title, defaultPath, filters }\n   * @returns Promise<string|null> 绝对路径，取消时为 null\n   */\n  saveFile: (options) => ipcRenderer.invoke('dialog:save-file', options),\n  \n  /**\n   * 选择文件对话框\n   * @param {Object} options - { title, filters }\n   * @returns Promise<string|null> 绝对路径，取消时为 null\n   */\n  pickFile: (options) => ipcRenderer.invoke('dialog:pick-file', options),\n  \n  /**\n   * 选择目录对话框\n   * @param {Object} options - { title }\n   * @returns Promise<string|null> 绝对路径，取消时为 null\n   */\n  pickDirectory: (options) => ipcRenderer.invoke('dialog:pick-directory', options),\n  \n  /**\n   * 获取用户主目录\n   * @returns {string}\n   */\n  getUserHome,\n  \n  /**\n   * 写入文件\n   * @param {string} filePath - 目标文件路径\n   * @param {Uint8Array} data - 文件数据\n   * @returns Promise<{success: boolean, error?: string}>\n   */\n  writeFile: (filePath, data) => ipcRenderer.invoke('fs:write-file', { filePath, data: Array.from(data) }),\n  \n  /**\n   * 复制文件\n   * @param {string} sourcePath - 源文件路径\n   * @param {string} destPath - 目标文件路径\n   * @returns Promise<{success: boolean, error?: string}>\n   */\n  copyFile: (sourcePath, destPath) => ipcRenderer.invoke('fs:copy-file', { sourcePath, destPath }),\n})\n\n\n\n"
  },
  {
    "path": "sonicvale-front/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>音谷</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "sonicvale-front/package.json",
    "content": "{\n  \"name\": \"sonicvale\",\n  \"productName\": \"音谷\",\n  \"version\": \"1.1.5\",\n  \"author\": \"lxc\",\n  \"main\": \"electron/main.js\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"electron\": \"electron .\",\n    \"start\": \"concurrently \\\"npm run dev\\\" \\\"npm run electron\\\"\",\n    \"electron-build\": \"vite build && electron-builder\"\n  },\n  \"build\": {\n    \"appId\": \"com.example.yingu\",\n    \"copyright\": \"Copyright © 2025 lxc\",\n    \"productName\": \"音谷\",\n    \"directories\": {\n      \"output\": \"release\"\n    },\n    \"files\": [\n      \"**/*\"\n    ],\n    \"asar\": true,\n    \"asarUnpack\": [\n      \"electron/**/*.exe\"\n    ],\n    \"win\": {\n      \"icon\": \"resource/icon/yingu.ico\",\n      \"target\": \"nsis\"\n    },\n    \"nsis\": {\n      \"oneClick\": false,\n      \"perMachine\": true,\n      \"allowElevation\": true,\n      \"allowToChangeInstallationDirectory\": true,\n      \"createDesktopShortcut\": true,\n      \"createStartMenuShortcut\": true,\n      \"license\": \"resource/license.txt\"\n    },\n    \"electronDownload\": {\n      \"mirror\": \"https://npmmirror.com/mirrors/electron/\"\n    }\n  },\n  \"dependencies\": {\n    \"@element-plus/icons-vue\": \"^2.3.2\",\n    \"axios\": \"^1.11.0\",\n    \"electron-log\": \"^5.4.3\",\n    \"element-plus\": \"^2.11.1\",\n    \"iconv-lite\": \"^0.7.0\",\n    \"sortablejs\": \"^1.15.6\",\n    \"vue\": \"^3.5.18\",\n    \"vue-json-editor\": \"^1.4.3\",\n    \"vue-json-pretty\": \"^2.6.0\",\n    \"vue-router\": \"^4.5.1\",\n    \"vue-virtual-scroll-list\": \"^2.3.5\",\n    \"wavesurfer.js\": \"^7.10.1\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^6.0.1\",\n    \"concurrently\": \"^9.2.0\",\n    \"electron\": \"^37.3.1\",\n    \"electron-builder\": \"^26.0.12\",\n    \"vite\": \"^7.1.2\"\n  }\n}\n"
  },
  {
    "path": "sonicvale-front/resource/license.txt",
    "content": "ûЭ\n\nҪʾʹñǰϸĶЭ顣һװʹñΪĶⲢͬⱾЭȫݡ\n\nһʹ\n1. Ϸ;ѧϰоּȡúϷȨҵ;\n2. δȨɣûʹñɡ¡ģҵַȨΪ\n\nֹΪ\nûʹñУôΪ\n1. ɡ¡δȨݣ\n2. ַ˵ĺϷȨ棬ȨФȨȨȨ\n3. ΥΥ;\n4. 򹤳̡ƽͼȡԴ룻\n5. 𺦱ߺϷȨΪ\n\n֪ʶȨ\n1. Ȩ̱Ȩ֪ʶȨ鿪С\n2. ûڱɵݣϷʹûге\n\nġ˵\n1. ûʹñɵݵµľסȨΣûге뿪޹ء\n2. ߲ûΪеκֱӻΡ\n3. ûӦʹñһк𣬰ڵͶߡ⡢ϡ\n\n塢\n1. ״ṩ߲֤书ȫûضҲ֤С\n2. ʹû޷ʹöɵκֱӡӡżȻ𺦣߲еκΡ\n3. ûеʹñܴķա\n\nЭЧֹ\n1. ûװʹñΪͬⱾЭ顣\n2. ûΥЭ飬Ȩʱֹʹɣ׷εȨ\n\n----------------------------\nЭսȨС\n"
  },
  {
    "path": "sonicvale-front/src/App.vue",
    "content": "<template>\n  <el-container class=\"layout-root\">\n    <!-- 侧边栏 -->\n    <el-aside class=\"layout-sider\" :class=\"{ 'is-collapsed': collapsed }\" :width=\"collapsed ? '64px' : '220px'\">\n      <!-- Logo -->\n      <div class=\"logo\">\n        <span class=\"logo-emoji\">🎙️</span>\n        <transition name=\"fade\">\n          <span v-if=\"!collapsed\" class=\"logo-text\">音谷配音平台</span>\n        </transition>\n      </div>\n\n      <!-- 菜单 -->\n      <el-menu :default-active=\"activeMenu\" background-color=\"#1f2d3d\" text-color=\"#bfcbd9\" active-text-color=\"#409EFF\"\n        router class=\"sider-menu\" :collapse=\"collapsed\" collapse-transition>\n        <el-menu-item index=\"/projects\">\n          <el-icon>\n            <Folder />\n          </el-icon><span>内容管理</span>\n        </el-menu-item>\n        <el-menu-item index=\"/voices\">\n          <el-icon>\n            <Microphone />\n          </el-icon><span>音色管理</span>\n        </el-menu-item>\n        <el-menu-item index=\"/config\">\n          <el-icon>\n            <Setting />\n          </el-icon><span>配置中心</span>\n        </el-menu-item>\n        <!-- 提示 -->\n        <el-menu-item index=\"/prompts\">\n          <el-icon>\n            <Document />\n          </el-icon><span>提示词管理</span>\n        </el-menu-item>\n      </el-menu>\n\n      <!-- ✅ 新增底部信息 -->\n      <!-- 底部信息（版本/免费/联系方式） -->\n      <!-- 警告： -->\n      <!--\n  ================================================================\n  🎙️ 音谷配音平台\n  作者：lxc\n\n  QQ：1428390267\n  本软件完全免费\n  ================================================================\n-->\n\n      <div class=\"sider-info\" v-if=\"!collapsed\">\n        <div class=\"info-item\">版本：v1.1.5</div>\n        <div class=\"info-item\">配音交流群：1084186865（如果群已经满，请按照申请提示进入其他群）</div>\n        <!-- 🔔 醒目声明 -->\n        <div class=\"info-warning\">\n          \n            本软件完全免费，遵循 AGPLv3 开源协议。\n       \n            禁止倒卖，违者必究。 \n          \n        </div>\n      </div>\n\n\n\n      <!-- 底部收缩/展开按钮 -->\n      <div class=\"sider-footer\">\n        <!-- 主题切换 -->\n        <div class=\"footer-item\">\n          <el-tooltip :content=\"isDark ? '切换到亮色模式' : '切换到暗色模式'\" placement=\"right\" :disabled=\"!collapsed\">\n            <el-button class=\"theme-btn\" circle @click=\"toggleTheme\">\n              <el-icon v-if=\"isDark\">\n                <Sunny />\n              </el-icon>\n              <el-icon v-else>\n                <Moon />\n              </el-icon>\n            </el-button>\n          </el-tooltip>\n          <transition name=\"fade\">\n            <span v-if=\"!collapsed\" class=\"action-label\" @click=\"toggleTheme\">{{ isDark ? '暗色模式' : '亮色模式' }}</span>\n          </transition>\n        </div>\n\n        <!-- 折叠切换 -->\n        <div class=\"footer-item\">\n          <el-tooltip :content=\"collapsed ? '展开菜单' : '收起菜单'\" placement=\"right\" :disabled=\"!collapsed\">\n            <el-button class=\"collapse-btn\" circle @click=\"toggleCollapse\">\n              <el-icon v-if=\"collapsed\">\n                <Expand />\n              </el-icon>\n              <el-icon v-else>\n                <Fold />\n              </el-icon>\n            </el-button>\n          </el-tooltip>\n          <transition name=\"fade\">\n            <span v-if=\"!collapsed\" class=\"action-label\" @click=\"toggleCollapse\">收起侧边栏</span>\n          </transition>\n        </div>\n      </div>\n    </el-aside>\n\n    <!-- 右侧内容 -->\n    <el-container class=\"layout-content\">\n      <el-main class=\"layout-main\">\n        <router-view />\n      </el-main>\n    </el-container>\n  </el-container>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { Folder, Setting, Microphone, Fold, Expand, Document, Moon, Sunny } from '@element-plus/icons-vue'\n\nconst route = useRoute()\nconst activeMenu = ref(route.path)\nwatch(() => route.path, (p) => (activeMenu.value = p))\n\nconst collapsed = ref(false)\nconst toggleCollapse = () => { collapsed.value = !collapsed.value }\n\nconst THEME_KEY = 'sv_theme'\nconst isDark = ref(false)\n\nfunction readStoredTheme() {\n  try { return localStorage.getItem(THEME_KEY) } catch { return null }\n}\n\nfunction systemPrefersDark() {\n  try { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return false }\n}\n\nfunction applyTheme(dark) {\n  document.documentElement.classList.toggle('dark', !!dark)\n}\n\nfunction toggleTheme() {\n  isDark.value = !isDark.value\n}\n\nonMounted(() => {\n  const stored = readStoredTheme()\n  isDark.value = stored ? stored === 'dark' : systemPrefersDark()\n  applyTheme(isDark.value)\n})\n\nwatch(isDark, (dark) => {\n  applyTheme(dark)\n  try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light') } catch { }\n  window.dispatchEvent(new CustomEvent('sv-theme-changed', { detail: { theme: dark ? 'dark' : 'light' } }))\n})\n</script>\n\n<style>\n/* —— 基础布局 —— */\nhtml,\nbody,\n#app {\n  height: 100%;\n  margin: 0;\n  overflow: hidden;\n}\n\n.layout-root {\n  height: 100vh;\n}\n\n.layout-sider {\n  background: linear-gradient(180deg, #1a2634 0%, #1f2d3d 100%);\n  color: #fff;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n  overflow: hidden !important;\n  transition: width .2s ease;\n  border-right: 1px solid rgba(255, 255, 255, 0.08);\n  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);\n}\n\n/* 折叠态强制隐藏所有滚动条 */\n.layout-sider.is-collapsed {\n  overflow: hidden !important;\n}\n\n.layout-sider.is-collapsed * {\n  overflow: hidden !important;\n}\n\n/* 顶部 Logo */\n.logo {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 10px;\n  height: 60px;\n  padding: 0 12px;\n  background: linear-gradient(135deg, #15202b 0%, #1a2836 100%);\n  border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n.is-collapsed .logo {\n  padding: 0;\n}\n\n.logo-emoji {\n  font-size: 22px;\n  line-height: 1;\n  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));\n}\n\n.logo-text {\n  font-size: 15px;\n  font-weight: 600;\n  white-space: nowrap;\n  overflow: hidden;\n  background: linear-gradient(90deg, #fff, #a8d8ff);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n/* 菜单区域：只纵向滚动，禁止横向滚动（修复折叠态横向滚动条） */\n.sider-menu {\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n  overflow-x: hidden;\n  border: none !important;\n}\n\n/* 修复折叠时菜单容器溢出 */\n.is-collapsed .sider-menu {\n  overflow: hidden;\n}\n\n/* 底部控制区 */\n.sider-footer {\n  flex: 0 0 auto;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 12px 8px;\n  border-top: 1px solid rgba(255, 255, 255, 0.08);\n  background: rgba(0, 0, 0, 0.1);\n}\n\n.is-collapsed .sider-footer {\n  align-items: center;\n  padding: 12px 0;\n  width: 64px;\n  box-sizing: border-box;\n}\n\n.footer-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  height: 44px;\n  padding: 0 8px;\n  border-radius: 10px;\n  cursor: pointer;\n  transition: all 0.25s ease;\n}\n\n.footer-item:hover {\n  background: rgba(255, 255, 255, 0.06);\n}\n\n.is-collapsed .footer-item {\n  justify-content: center;\n  padding: 0;\n  width: 56px;\n  height: 44px;\n  margin: 0 auto;\n}\n\n.action-label {\n  font-size: 13px;\n  color: #bfcbd9;\n  white-space: nowrap;\n  user-select: none;\n  transition: color 0.2s ease;\n}\n\n.footer-item:hover .action-label {\n  color: #fff;\n}\n\n/* 折叠按钮 */\n.collapse-btn {\n  background: rgba(255, 255, 255, 0.08);\n  border: none;\n  width: 36px;\n  height: 36px;\n  backdrop-filter: blur(4px);\n  transition: all 0.25s ease;\n  flex-shrink: 0;\n}\n\n.collapse-btn:hover {\n  background: rgba(64, 158, 255, 0.3);\n  transform: scale(1.05);\n}\n\n/* 主题切换按钮 */\n.theme-btn {\n  background: rgba(255, 255, 255, 0.08);\n  border: none;\n  width: 36px;\n  height: 36px;\n  backdrop-filter: blur(4px);\n  transition: all 0.25s ease;\n  flex-shrink: 0;\n}\n\n.theme-btn:hover {\n  background: rgba(255, 184, 77, 0.3);\n  transform: scale(1.05);\n}\n\n/* 右侧容器 */\n.layout-content {\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.layout-main {\n  background: var(--el-bg-color-page);\n  padding: 24px;\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow: auto;\n}\n\n/* —— 菜单样式（展开态） —— */\n.el-menu-item {\n  border-radius: 10px;\n  margin: 4px 12px;\n  height: 44px;\n  line-height: 44px;\n  transition: all 0.25s ease;\n}\n\n.el-menu-item .el-icon {\n  font-size: 18px;\n  transition: transform 0.2s ease;\n}\n\n.el-menu-item:hover .el-icon {\n  transform: scale(1.1);\n}\n\n.el-menu-item:hover {\n  background-color: rgba(64, 158, 255, 0.15) !important;\n}\n\n.el-menu-item.is-active {\n  background: linear-gradient(135deg, #409EFF 0%, #5cadff 100%) !important;\n  color: #fff !important;\n  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.35);\n}\n\n/* —— 折叠态定制（关键修复） —— */\n.el-menu--collapse .el-menu-item {\n  margin: 6px 8px;\n  padding: 0 !important;\n  width: 48px !important;\n  min-width: 48px !important;\n  max-width: 48px !important;\n  height: 48px;\n  box-sizing: border-box;\n  justify-content: center;\n  display: flex;\n  align-items: center;\n}\n\n.el-menu--collapse .el-menu-item .el-icon {\n  margin-right: 0;\n  font-size: 20px;\n}\n\n.el-menu--collapse .el-menu-item.is-active {\n  border-radius: 12px;\n}\n\n/* 如果后续加子菜单，折叠时隐藏箭头避免溢出 */\n.el-menu--collapse .el-sub-menu__icon-arrow {\n  display: none;\n}\n\n/* 折叠态菜单容器修复溢出 */\n.el-menu--collapse {\n  width: 64px !important;\n  min-width: 64px !important;\n  max-width: 64px !important;\n  overflow: hidden !important;\n}\n\n/* el-aside 内部滚动条强制隐藏 */\n.is-collapsed .el-menu {\n  overflow: hidden !important;\n}\n\n/* 隐藏 webkit 滚动条 */\n.is-collapsed ::-webkit-scrollbar {\n  display: none !important;\n  width: 0 !important;\n  height: 0 !important;\n}\n\n/* 过渡动画 */\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity .15s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n}\n\n\n/* 底部信息块 */\n/* 底部信息块整体 */\n.sider-info {\n  flex: 0 0 auto;\n  padding: 12px 14px 14px 14px;\n  border-top: 1px solid rgba(255, 255, 255, 0.08);\n  font-size: 12px;\n  line-height: 1.6;\n  background: rgba(0, 0, 0, 0.15);\n  color: #c9d4e0;\n}\n\n/* 普通信息行（版本、联系方式） */\n.info-item {\n  color: #9aa8b8;\n  margin-bottom: 6px;\n  display: flex;\n  align-items: flex-start;\n  gap: 4px;\n  word-break: break-all;\n}\n\n.info-item:first-child {\n  font-weight: 600;\n  color: #67c23a;\n  font-size: 13px;\n}\n\n/* 让QQ号可选中复制 */\n.info-item::selection {\n  background: #409eff;\n  color: #fff;\n}\n\n.info-warning {\n  margin-top: 10px;\n  padding: 10px 12px;\n  font-size: 11px;\n  color: #ffb84d;\n  background: linear-gradient(135deg, rgba(255, 184, 77, 0.12) 0%, rgba(255, 152, 0, 0.08) 100%);\n  border: 1px solid rgba(255, 184, 77, 0.3);\n  border-radius: 8px;\n  line-height: 1.6;\n  text-align: justify;\n  transition: all 0.3s ease;\n}\n\n.info-warning:hover {\n  background: linear-gradient(135deg, rgba(255, 184, 77, 0.2) 0%, rgba(255, 152, 0, 0.15) 100%);\n  color: #ffd27f;\n  border-color: rgba(255, 184, 77, 0.5);\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/api/chapter.js",
    "content": "import request from './config'\nimport dayjs from 'dayjs'\n\nexport function getChaptersByProject(projectId) {\n  return request.get(`/chapters/project/${projectId}`)\n}\n\nexport function getChapterDetail(chapterId) {\n  return request.get(`/chapters/${chapterId}`)\n}\n\n\nexport function createChapter(title, projectId) {\n  return request.post('/chapters', {\n    title,\n    project_id: projectId\n  })\n}\n\n\nexport function updateChapter(id, payload) {\n  return request.put(`/chapters/${id}`, payload)\n}\n\nexport function deleteChapter(chapterId) {\n  return request.delete(`/chapters/${chapterId}`)\n}\n\n\nexport function splitChapterByLLM(projectId, chapterId) {\n  return request.get(`/chapters/get-lines/${projectId}/${chapterId}`)\n}\n\n\n\n\n\n// 导出 LLM Prompt\nexport function exportLLMPrompt(projectId, chapterId) {\n  // GET /export-llm-prompt/{project_id}/{chapter_id}\n  return request.get(`/chapters/export-llm-prompt/${projectId}/${chapterId}`)\n}\n\n// 导入第三方 JSON（multipart/form-data，字段名 data）\nexport function importThirdLines(projectId, chapterId, formData) {\n  // POST /import-lines/{project_id}/{chapter_id}\n  // 注意：formData 已经是 FormData；不要再手动设置 boundary\n  return request.post(`/chapters/import-lines/${projectId}/${chapterId}`, formData, {\n    headers: { 'Content-Type': 'multipart/form-data' }\n  })\n}\n\n// 智能匹配音色\n// @router.post(\"/add-smart-role-and-voice/{project_id}/{chapter_id}\",response_model=Res[str],summary=\"添加智能匹配角色和音色的功能\",description=\"添加智能匹配角色和音色的功能\")\n// async def add_smart_role_and_voice(project_id: int,chapter_id: int,\nexport function addSmartRoleAndVoice(projectId, chapterId) {\n  return request.post(`/chapters/add-smart-role-and-voice/${projectId}/${chapterId}`)\n}\n"
  },
  {
    "path": "sonicvale-front/src/api/config.js",
    "content": "// src/api/config.js\nimport axios from 'axios'\n\nconst service = axios.create({\n  baseURL: 'http://127.0.0.1:8200/', // 统一前缀，根据你的后端改\n  timeout: 1000000\n})\n\n// 请求拦截器\nservice.interceptors.request.use(\n  config => {\n    // 这里可以加 token\n    return config\n  },\n  error => Promise.reject(error)\n)\n\n// 响应拦截器\nservice.interceptors.response.use(\n  response => response.data,\n  error => {\n    console.error('API Error:', error)\n    return Promise.reject(error)\n  }\n)\n\nexport default service\n"
  },
  {
    "path": "sonicvale-front/src/api/enums.js",
    "content": "import request from './config'\n\n\n\n// 查询单个情绪\nexport function fetchEmotion(id) {\n  return request.get(`/emotions/${id}`).then(res => {\n    if (res.code === 200) return res.data\n    return null\n  })\n}\n\n// 查询所有情绪\nexport function fetchAllEmotions() {\n  return request.get(`/emotions`).then(res => {\n    if (res.code === 200) return res.data\n    return []\n  })\n}\n\n// 查询单个情绪强度\nexport function fetchStrength(id) {\n  return request.get(`/strengths/${id}`).then(res => {\n    if (res.code === 200) return res.data\n    return null\n  })\n}\n\n// 查询所有情绪\nexport function fetchAllStrengths() {\n  return request.get(`/strengths`).then(res => {\n    if (res.code === 200) return res.data\n    return []\n  })\n}"
  },
  {
    "path": "sonicvale-front/src/api/line.js",
    "content": "import request from './config'\n\nexport function deleteLinesByChapter(chapterId) {\n  return request.delete(`/lines/lines/${chapterId}`)\n}\nexport function getLinesByChapter(chapterId) {\n  return request.get(`/lines/lines/${chapterId}`)\n}\nexport function generateAudio(projectId, chapterId, body) {\n  console.log('generateAudio', projectId, chapterId, body)\n  return request.post(`/lines/generate-audio/${projectId}/${chapterId}`, body)\n}\n// createLine\n// export function getLine(lineId) {\n//   // GET /lines/{line_id}\n//   return request.get(`/lines/${lineId}`)\n// }\nexport function createLine(projectId, data) {\n  // POST /lines/{project_id}\n  // data: LineCreateDTO（含 chapter_id, text_content, role_id?, line_order? ...）\n  return request.post(`/lines/${projectId}`, data)\n}\n\nexport function updateLine(lineId, data) {\n  // PUT /lines/{line_id}\n  // 按你后端的逻辑，不能修改 chapter_id；但如果你传入也会被忽略/校验\n  return request.put(`/lines/${lineId}`, data)\n}\n\nexport function deleteLine(lineId) {\n  // DELETE /lines/{line_id}\n  return request.delete(`/lines/${lineId}`)\n}\n\nexport async function reorderLinesByPut(orderList) {\n  return request.put('/lines/batch/orders', orderList)\n}\n\n// @router.put(\"/{line_id}/audio_path\", response_model=Res[bool])\n// def update_line_audio_path(\n//         line_id: int,\n//     dto: LineCreateDTO,  # 关键：明确从 body 读取“数组”\n//     line_service: LineService = Depends(get_line_service),\n// ):\nexport function updateLineAudioPath(lineId, data) {\n  // PUT /lines/{line_id}/audio_path\n  return request.put(`/lines/${lineId}/audio_path`, data)\n}\n\n\nexport function processAudio(line_id, payload) {\n  return request.post(`/lines/process-audio/${line_id}`, payload)\n}\n\n// 导出结果和字幕\n// 导出接口，带 single 参数\nexport function exportLines(chapter_id, single = false) {\n  return request.get(`/lines/export-audio/${chapter_id}`, {\n    params: { single }\n  })\n}\n\n\n// 矫正字幕 - 拼音匹配矫正\nexport function correctLinesByPinyin(chapter_id) {\n  return request.post(`/lines/correct-subtitle-pinyin/${chapter_id}`)\n}\n\n// 矫正字幕 - LLM矫正（自动从项目配置获取LLM信息）\nexport function correctLinesByLLM(chapter_id, batch_size = 20) {\n  return request.post(`/lines/correct-subtitle-llm/${chapter_id}`, null, {\n    params: { batch_size }\n  })\n}"
  },
  {
    "path": "sonicvale-front/src/api/multiEmotionVoice.js",
    "content": "import request from './config'\n\nexport function fetchMultiEmotionVoicesByVoiceId(voiceId) {\n  return request.get(`/multi_emotion_voices/voice_id/${voiceId}`)\n}\n\nexport function createMultiEmotionVoice(dto) {\n  return request.post(`/multi_emotion_voices`, dto)\n}\n\nexport function deleteMultiEmotionVoice(id) {\n  return request.delete(`/multi_emotion_voices/${id}`)\n}\n\nexport function updateMultiEmotionVoice(id, dto) {\n    console.log(dto)\n  return request.put(`/multi_emotion_voices/${id}`, dto)\n}\n"
  },
  {
    "path": "sonicvale-front/src/api/project.js",
    "content": "// src/api/project.js\nimport request from './config'\nimport dayjs from 'dayjs'\n\n// 获取全部项目\nexport function fetchProjects() {\n  return request.get('/projects').then(res => {\n    if (res.code === 200) {\n      const projects = res.data.map(p => ({\n        id: p.id,\n        name: p.name,\n        description: p.description,\n        createdAt: dayjs(p.created_at).format('YYYY-MM-DD HH:mm:ss'),\n        updatedAt: dayjs(p.updated_at).format('YYYY-MM-DD HH:mm:ss'),\n        createdAtRaw: p.created_at,  // 原始时间戳（排序用）\n        updatedAtRaw: p.updated_at,  // 原始时间戳（排序用）\n        llmModel: p.llm_model,\n        ttsProviderId: p.tts_provider_id,\n        llmProviderId: p.llm_provider_id,\n        promptId: p.prompt_id,\n        is_precise_fill: p.is_precise_fill,  // ✅ 新增字段\n        project_root_path: p.project_root_path,\n      }))\n\n      // 🔥 按更新时间排序（最新在前）\n      return projects.sort((a, b) => new Date(b.updatedAtRaw) - new Date(a.updatedAtRaw))\n    }\n    return []\n  })\n}\n\n// 删除项目\nexport function deleteProject(id) {\n  return request.delete(`/projects/${id}`)\n}\n\n// 创建项目\nexport function createProject(data) {\n  return request.post('/projects', data)\n}\n\nexport function getProjectDetail(projectId) {\n  return request.get(`/projects/${projectId}`)\n}\n\nexport function updateProject(projectId, data) {\n  // 后端若是 PATCH 就改为 service.patch\n  console.log('updateProject', projectId, data)\n  return request.put(`/projects/${projectId}`, data)\n}\n\n// 批量导入章节\nexport function importChapters(projectId, data) {\n  return request.post(`/projects/${projectId}/import`,  data )\n}"
  },
  {
    "path": "sonicvale-front/src/api/prompt.js",
    "content": "import request from './config'\n\nexport function createPrompt(data) {\n  return request.post('/prompts/', data)\n}\nexport async function fetchPromptList() {\n  const res = await request.get('/prompts/')\n    if (res.code === 200) {\n        return res.data\n    }\n    return []\n}\nexport async function fetchPromptById(id) {\n    const res = await request.get(`/prompts/${id}`)\n    if (res.code === 200) {\n        return res.data\n    }\n    return null\n    }\nexport function updatePrompt(id, data) {\n  return request.put(`/prompts/${id}`, data)\n}\nexport function deletePrompt(id) {\n  return request.delete(`/prompts/${id}`)\n}\n// 获取所有task\nexport function fetchAllTasks() {\n  return request.get('/prompts/tasks/all')\n  .then(res => {\n    if (res.code === 200) {\n      return res.data\n    }\n    return []\n  })\n}\n"
  },
  {
    "path": "sonicvale-front/src/api/provider.js",
    "content": "import request from './config'\n\n/**\n * LLM Providers\n */\n\n// 获取 LLM 提供商列表\nexport function fetchLLMProviders() {\n  return request.get('/llm_providers/').then(res => {\n    if (res.code === 200) {\n      return res.data\n    }\n    return []\n  })\n}\n\n// 创建 LLM 提供商\nexport function createLLMProvider(payload) {\n  // payload: { name, api_base_url, api_key?, model_list?, status? }\n  return request.post('/llm_providers/', payload)\n}\n\n// 更新 LLM 提供商\nexport function updateLLMProvider(id, payload) {\n  return request.put(`/llm_providers/${id}`, payload)\n}\n\n// 删除 LLM 提供商\nexport function deleteLLMProvider(id) {\n  return request.delete(`/llm_providers/${id}`)\n}\n// 测试 LLM 提供商\nexport function testLLMProvider(data) {\n  return request.post('/llm_providers/test', data)\n}\n\n/**\n * TTS Provider\n * 说明：只有一个（id=1），不可新增/删除，只能查找和更新\n */\n\n// 获取 TTS 提供商\nexport function fetchTTSProviders() {\n  return request.get('/tts_providers').then(res => {\n    if (res.code === 200) {\n        console.log(res.data)\n      return res.data\n    }\n    \n    // 如果后端暂时没实现接口，就返回默认值，避免前端报错\n  })\n}\n\n// 更新 TTS 提供商（id 固定为 1）\nexport function updateTTSProvider(id, payload) {\n  return request.put(`/tts_providers/${id}`, payload)\n}\n\n\n// 测试 TTS 引擎\nexport function testTTSProvider(data) {\n  return request.post('/tts_providers/test', data)\n}\n\n"
  },
  {
    "path": "sonicvale-front/src/api/role.js",
    "content": "import request from './config'\n\nexport function getRolesByProject(projectId) {\n  return request.get(`/roles/project/${projectId}`)\n}\n\nexport function updateRole(roleId, payload) {\n  return request.put(`/roles/${roleId}`, payload)\n}\n\n// deleteRole\nexport function deleteRole(roleId) {\n  return request.delete(`/roles/${roleId}`)\n}\n\nexport function createRole(payload) {\n  return request.post('/roles', payload)\n}"
  },
  {
    "path": "sonicvale-front/src/api/voice.js",
    "content": "import request from './config'\n\n// 创建音色\nexport function createVoice(payload) {\n  // payload: { name, tts_provider_id, reference_path?, description? }\n  return request.post('/voices', payload)\n}\n\n// 查询单个音色\nexport function fetchVoice(id) {\n  return request.get(`/voices/${id}`).then(res => {\n    if (res.code === 200) return res.data\n    return null\n  })\n}\n\n// 查询某个 TTS Provider 下的所有音色\nexport function fetchVoicesByTTS(tts_provider_id) {\n  return request.get(`/voices/tts/${tts_provider_id}`).then(res => {\n    if (res.code === 200) return res.data\n    return []\n  })\n}\n\nexport function getVoicesByTTS(ttsId = 1) {\n  return request.get(`/voices/tts/${ttsId}`)\n}\n\n\n\n// 更新音色\nexport function updateVoice(id, payload) {\n  return request.put(`/voices/${id}`, payload)\n}\n\n// 删除音色\nexport function deleteVoice(id) {\n  return request.delete(`/voices/${id}`)\n}\n\n// 导出音色库\nexport function exportVoices(tts_provider_id, export_path, voice_ids = null) {\n  const payload = { tts_provider_id, export_path }\n  if (Array.isArray(voice_ids) && voice_ids.length > 0) payload.ids = voice_ids\n  return request.post('/voices/export', payload)\n}\n\n// 导入音色库\nexport function importVoices(tts_provider_id, zip_path, target_dir) {\n  return request.post('/voices/import', {\n    tts_provider_id,\n    zip_path,\n    target_dir\n  })\n}\n\n// 处理音色参考音频\nexport function processVoiceAudio(audio_path, params) {\n  return request.post('/voices/process-audio', {\n    audio_path,\n    speed: params.speed,\n    volume: params.volume,\n    start_ms: params.start_ms,\n    end_ms: params.end_ms,\n    silence_sec: params.silence_sec,\n    current_ms: params.current_ms\n  })\n}\n\n// 复制音色\nexport function copyVoice(source_voice_id, new_name, target_dir = null) {\n  return request.post('/voices/copy', {\n    source_voice_id,\n    new_name,\n    target_dir\n  })\n}\n"
  },
  {
    "path": "sonicvale-front/src/components/WaveCellPro.vue",
    "content": "<!-- src/components/WaveCellPro.vue -->\n<template>\n  <div class=\"wavecell\">\n    <div class=\"bar\">\n      <!-- 替换原来的按钮 -->\n      <el-button :type=\"isPlaying ? 'danger' : 'success'\" class=\"play-btn\" :class=\"{ playing: isPlaying }\" circle\n        size=\"mid\" @click=\"togglePlay\">\n        <template #icon>\n          <el-icon :size=\"22\">\n            <VideoPause v-if=\"isPlaying\" />\n            <VideoPlay v-else />\n          </el-icon>\n        </template>\n      </el-button>\n\n      <!-- 下载按钮 -->\n      <el-tooltip :content=\"ready ? '下载音频' : '暂无音频'\" placement=\"top\">\n        <el-button class=\"download-btn\" :class=\"{ 'is-disabled': !ready }\" circle size=\"mid\" \n          @click=\"downloadAudio\" :disabled=\"!ready\">\n          <template #icon>\n            <el-icon :size=\"18\">\n              <Download />\n            </el-icon>\n          </template>\n        </el-button>\n      </el-tooltip>\n\n\n      <span class=\"lbl\">速度</span>\n      <el-slider v-model=\"rate\" :min=\"0.5\" :max=\"2.0\" :step=\"0.1\" class=\"slider\" />\n\n      <span class=\"lbl\">音量</span>\n      <el-slider v-model=\"vol2x\" :min=\"0\" :max=\"2.0\" :step=\"0.01\" class=\"slider\" />\n\n      <span class=\"lbl\">添加间隔(s)</span>\n      <el-input-number v-model=\"tailSilence\" :min=\"0\" :max=\"30\" :step=\"0.1\" size=\"small\" />\n\n\n      <!-- <el-switch v-model=\"regionMode\" active-text=\"标注\" inactive-text=\"浏览\" /> -->\n      <el-button size=\"small\" @click=\"makeRegion\" :disabled=\"hasRegion\">删除区间选择</el-button>\n      <!-- <el-button size=\"small\" @click=\"loopRegion\" :disabled=\"!hasRegion\">循环区间</el-button> -->\n      <el-button size=\"small\" @click=\"clearRegion\" :disabled=\"!hasRegion\">清除区间</el-button>\n\n      <el-button size=\"small\" type=\"primary\" @click=\"confirmProcess\" :disabled=\"!ready\">应用处理</el-button>\n    </div>\n\n    <div ref=\"container\" class=\"wave\" />\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'\nimport { ElMessageBox, ElMessage } from 'element-plus'\nimport { VideoPlay, VideoPause, Download } from '@element-plus/icons-vue'\nimport WaveSurfer from 'wavesurfer.js'\nimport Regions from 'wavesurfer.js/dist/plugins/regions.esm.js'\n\n\nconst props = defineProps({\n  src: { type: String, required: true },     // 建议传 file://；否则会尝试转换\n  speed: { type: Number, default: 1.0 },     // 初始速度\n  volume2x: { type: Number, default: 1.0 },  // 0~2.0（前端试听倍数）\n  startMs: { type: Number, default: null },  // 初始选区\n  endMs: { type: Number, default: null },\n})\n\nconst emit = defineEmits([\n  'request-stop-others', // (instance) 让父级停掉其它行\n  'confirm',             // ({ speed, volume, start_ms, end_ms })\n  'ready',\n  'dispose',             // () 组件卸载时触发\n  'ended',               // () 播放结束\n])\n\nconst container = ref(null)\n\nlet ws = null\nlet regionsPlugin = null\nconst region = ref(null) // ← 关键：响应式\nlet themeListener = null\n\nconst isPlaying = ref(false)\nconst ready = ref(false)\nconst rate = ref(props.speed || 1.0)\nconst vol2x = ref(Math.max(0, Math.min(props.volume2x ?? 1.0, 1.0))) // 统一 0~1\nconst regionMode = ref(false)\n\nconst tailSilence = ref(0) // 默认 0 秒\n\nconst hasRegion = computed(() => !!region.value)\n\nfunction getWaveColors() {\n  const dark = document?.documentElement?.classList?.contains('dark')\n  return {\n    waveColor: dark ? '#5b6473' : '#cfd6e4',\n    progressColor: '#409EFF',\n  }\n}\n\nfunction applyWaveTheme() {\n  if (!ws) return\n  const { waveColor, progressColor } = getWaveColors()\n  if (ws.setOptions) ws.setOptions({ waveColor, progressColor })\n}\n\nfunction toUrl(src) {\n  if (!src) return ''\n  if (/^https?:\\/\\//i.test(src) || /^file:\\/\\//i.test(src)) return src\n  return window.native?.pathToFileUrl ? window.native.pathToFileUrl(src) : src\n}\n\nonMounted(async () => {\n  themeListener = () => applyWaveTheme()\n  window.addEventListener('sv-theme-changed', themeListener)\n\n  const { waveColor, progressColor } = getWaveColors()\n  ws = WaveSurfer.create({\n    container: container.value,\n    height: 64,\n    normalize: true,\n    autoScroll: true,\n    autoCenter: true,\n    barWidth: 2,\n    waveColor,\n    progressColor,\n  })\n\n  // v7：registerPlugin 获取实例\n  regionsPlugin = ws.registerPlugin(Regions.create({ dragSelection: true }))\n\n  ws.on('ready', () => {\n    ready.value = true\n    ws.setPlaybackRate(rate.value)\n    ws.setVolume(Math.max(0, Math.min(vol2x.value ?? 1.0, 1.0))) // 统一 0~1\n\n    // 恢复初始区域\n    if (props.startMs != null && props.endMs != null && props.endMs > props.startMs) {\n      region.value = regionsPlugin.addRegion({\n        start: props.startMs / 1000,\n        end: props.endMs / 1000,\n        drag: true,\n        resize: true,\n        color: 'rgba(64,158,255,0.15)',\n      })\n    }\n    emit('ready', ws)\n  })\n\n  ws.on('play', () => {\n    isPlaying.value = true\n    emit('request-stop-others', ws)\n  })\n  ws.on('pause', () => { isPlaying.value = false })\n  ws.on('finish', () => {\n    isPlaying.value = false        // 确保状态同步\n    console.log('播放结束，触发 ended 事件')\n    emit('ended', { src: props.src })                 // 通知父组件\n  })\n\n  // 区域事件（v7：挂 regionsPlugin）\n  regionsPlugin.on('region-created', r => { region.value = r })\n  regionsPlugin.on('region-updated', r => { region.value = r })\n  regionsPlugin.on('region-clicked', (r, e) => {\n    e.stopPropagation()\n    region.value = r\n    if (regionMode.value) r.play({ loop: true })  // v7：用 play({ loop:true })\n    else ws.play(r.start)\n  })\n\n  await ws.load(toUrl(props.src))\n})\n\nonBeforeUnmount(() => {\n  try {\n    if (themeListener) window.removeEventListener('sv-theme-changed', themeListener)\n    emit('dispose', ws)\n    ws && ws.destroy()\n  }\n  finally { ws = null; regionsPlugin = null; region.value = null; themeListener = null }\n})\n\n// —— 实时预听：速度/音量 —— //\nwatch(rate, v => ws && ws.setPlaybackRate(v || 1.0))\nwatch(vol2x, v => ws && ws.setVolume(Math.max(0, Math.min(v ?? 1.0, 1.0))))\n\n// 切换“标注/浏览”：开关拖拽建区\nwatch(regionMode, (on) => {\n  if (regionsPlugin?.setOptions) regionsPlugin.setOptions({ dragSelection: !!on })\n\n})\n\nfunction togglePlay() {\n  if (!ws) return\n  isPlaying.value ? ws.pause() : ws.play()\n}\n\nfunction makeRegion() {\n  if (!ws || region.value) return\n  const dur = ws.getDuration() || 0\n  const start = Math.max(0, (ws.getCurrentTime?.() || 0) - 0.25)\n  const end = Math.min(dur, start + 1.5)\n  region.value = regionsPlugin.addRegion({\n    start, end,\n    drag: true, resize: true,\n    color: 'rgba(255,0,0,0.15)',\n  })\n}\n\nfunction loopRegion() {\n  if (region.value) region.value.play({ loop: true })\n}\n\nfunction clearRegion() {\n  if (region.value) { region.value.remove(); region.value = null }\n}\n\nasync function confirmProcess() {\n  const start_ms = region.value ? Math.round(region.value.start * 1000) : null\n  const end_ms = region.value ? Math.round(region.value.end * 1000) : null\n  const current_ms = ws ? Math.round(ws.getCurrentTime() * 1000) : 0  // ✅ 新增\n  await ElMessageBox.confirm('确认按当前试听参数处理该音频吗？（会生成新文件或覆盖，视后端实现）', '确认处理', { type: 'warning' })\n  emit('confirm', {\n    speed: Number(rate.value || 1.0),\n    volume: Number(vol2x.value || 1.0),\n    start_ms, end_ms,\n    silence_sec: Number(tailSilence.value || 0),\n    current_ms,\n  })\n}\n\n// 下载音频\nasync function downloadAudio() {\n  if (!props.src) {\n    ElMessage.warning('暂无可下载的音频')\n    return\n  }\n\n  try {\n    // 解析源路径（支持 file:// 和普通路径）\n    let sourcePath = props.src\n    if (sourcePath.startsWith('file:///')) {\n      // 解码 file:// URL 并提取路径\n      sourcePath = decodeURI(sourcePath.replace('file:///', ''))\n    } else if (sourcePath.startsWith('file://')) {\n      sourcePath = decodeURI(sourcePath.replace('file://', ''))\n    }\n    \n    // 去除可能存在的查询参数 (?v=xxx)\n    sourcePath = sourcePath.split('?')[0]\n    \n    // 提取文件名作为默认保存名\n    const fileName = sourcePath.split(/[\\\\/]/).pop() || 'audio.wav'\n    \n    // Electron 环境下使用原生保存对话框\n    if (window.native?.saveFile && window.native?.writeFile) {\n      const savePath = await window.native.saveFile({\n        title: '保存音频文件',\n        defaultPath: fileName,\n        filters: [{ name: '音频文件', extensions: ['wav', 'mp3', 'flac', 'ogg'] }]\n      })\n      \n      if (!savePath) {\n        return // 用户取消\n      }\n      \n      // 通过 fetch 获取音频数据\n      const response = await fetch(toUrl(props.src))\n      const arrayBuffer = await response.arrayBuffer()\n      const uint8Array = new Uint8Array(arrayBuffer)\n      \n      // 使用 Electron 的 writeFile 方法直接写入文件\n      const result = await window.native.writeFile(savePath, uint8Array)\n      \n      if (result.success) {\n        ElMessage.success('音频下载成功')\n      } else {\n        ElMessage.error('写入文件失败: ' + (result.error || '未知错误'))\n      }\n    } else {\n      // 非 Electron 环境，使用浏览器下载\n      const response = await fetch(toUrl(props.src))\n      const blob = await response.blob()\n      const url = URL.createObjectURL(blob)\n      const link = document.createElement('a')\n      link.href = url\n      link.download = fileName\n      document.body.appendChild(link)\n      link.click()\n      document.body.removeChild(link)\n      URL.revokeObjectURL(url)\n      \n      ElMessage.success('音频下载成功')\n    }\n  } catch (error) {\n    console.error('下载音频失败:', error)\n    ElMessage.error('下载音频失败: ' + (error.message || '未知错误'))\n  }\n}\n</script>\n\n<style scoped>\n.wavecell {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.bar {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n}\n\n.lbl {\n  font-size: 12px;\n  color: var(--el-text-color-regular);\n  margin-left: 6px;\n}\n\n.slider {\n  width: 140px;\n}\n\n.wave {\n  width: 100%;\n}\n\n\n\n/* 下载按钮样式 */\n.download-btn {\n  background: linear-gradient(135deg, #74b9ff 0%, #a29bfe 100%);\n  border: none;\n  color: #fff;\n  transition: all 0.3s ease;\n  box-shadow: 0 2px 8px rgba(116, 185, 255, 0.35);\n}\n\n.download-btn:hover {\n  transform: scale(1.1);\n  box-shadow: 0 4px 12px rgba(116, 185, 255, 0.5);\n  background: linear-gradient(135deg, #a29bfe 0%, #74b9ff 100%);\n}\n\n.download-btn:active {\n  transform: scale(0.95);\n}\n\n.download-btn .el-icon {\n  color: #fff;\n}\n\n.download-btn.is-disabled {\n  background: #c0c4cc;\n  box-shadow: none;\n  cursor: not-allowed;\n}\n\n.download-btn.is-disabled:hover {\n  transform: none;\n  background: #c0c4cc;\n  box-shadow: none;\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/main.js",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport ElementPlus from 'element-plus'\nimport 'element-plus/dist/index.css'\nimport 'element-plus/theme-chalk/dark/css-vars.css'\nimport router from './router'\n\n\n\ncreateApp(App).use(router).use(ElementPlus).mount('#app')\n"
  },
  {
    "path": "sonicvale-front/src/pages/ConfigCenter.vue",
    "content": "<template>\n  <div>\n    <h2 style=\"margin-bottom:16px;\">配置中心</h2>\n\n    <el-tabs v-model=\"activeTab\">\n      <!-- LLM 管理 -->\n      <el-tab-pane label=\"LLM 管理\" name=\"llm\">\n        <div class=\"toolbar\">\n          <el-button type=\"primary\" @click=\"openLLMDialog()\">新增 LLM 提供商</el-button>\n        </div>\n\n        <el-table :data=\"llmList\" stripe border highlight-current-row class=\"styled-table\">\n          <el-table-column prop=\"name\" label=\"名称\" min-width=\"160\" />\n          <el-table-column prop=\"api_base_url\" label=\"Base URL\" min-width=\"240\" />\n          <el-table-column prop=\"model_list\" label=\"模型列表\" min-width=\"240\">\n            <template #default=\"{ row }\">\n              <div style=\"display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;\">\n                <div style=\"display: flex; flex-wrap: wrap; gap: 4px; flex: 1;\">\n                  <el-tooltip\n                    v-for=\"(item, idx) in (row.model_list || '').split(/[,，]/).filter(s => s.trim())\"\n                    :key=\"idx\"\n                    content=\"点击复制\"\n                    placement=\"top\"\n                    :show-after=\"500\"\n                  >\n                    <el-tag\n                      size=\"small\"\n                      effect=\"plain\"\n                      style=\"cursor: pointer;\"\n                      @click=\"copyText(item.trim())\"\n                    >\n                      {{ item.trim() }}\n                    </el-tag>\n                  </el-tooltip>\n                </div>\n                <el-tooltip content=\"复制全部模型\" placement=\"top\">\n                  <el-button\n                    type=\"info\"\n                    link\n                    :icon=\"CopyDocument\"\n                    @click=\"copyText(row.model_list)\"\n                    style=\"padding: 0; height: auto;\"\n                  />\n                </el-tooltip>\n              </div>\n            </template>\n          </el-table-column>\n          <el-table-column label=\"API Key\" min-width=\"180\">\n            <template #default=\"{ row }\">\n              <span class=\"api-key\">{{ maskKey(row.api_key) }}</span>\n            </template>\n          </el-table-column>\n\n\n\n          <el-table-column label=\"状态\" width=\"120\">\n            <template #default=\"{ row }\">\n              <el-tag effect=\"light\" :type=\"row.status === 1 ? 'success' : 'info'\">\n                <span class=\"status-dot\" :class=\"row.status === 1 ? 'dot-green' : 'dot-gray'\"></span>\n                {{ row.status === 1 ? '启用' : '停用' }}\n              </el-tag>\n            </template>\n          </el-table-column>\n\n          <!-- <el-table-column prop=\"updated_at\" label=\"更新于\" min-width=\"180\" /> -->\n\n          <el-table-column label=\"操作\" width=\"180\" fixed=\"right\" align=\"center\">\n            <template #default=\"{ row }\">\n              <div class=\"flex justify-center gap-2\">\n                <el-button type=\"primary\" size=\"small\" plain @click=\"openLLMDialog(row)\">\n                  编辑\n                </el-button>\n\n                <el-popconfirm title=\"确认删除该 LLM 提供商？\" confirm-button-text=\"确定\" cancel-button-text=\"取消\"\n                  @confirm=\"removeLLM(row.id)\">\n                  <template #reference>\n                    <el-button type=\"danger\" size=\"small\" plain>\n                      删除\n                    </el-button>\n                  </template>\n                </el-popconfirm>\n              </div>\n            </template>\n          </el-table-column>\n\n        </el-table>\n      </el-tab-pane>\n\n      <!-- TTS 管理（列表，仅编辑） -->\n      <el-tab-pane label=\"TTS 管理\" name=\"tts\">\n        <div class=\"toolbar\">\n          <el-button type=\"primary\" disabled>仅支持编辑，不可新增/删除</el-button>\n        </div>\n\n        <el-table :data=\"ttsList\" stripe border highlight-current-row class=\"styled-table\">\n          <el-table-column prop=\"name\" label=\"名称\" min-width=\"160\" />\n          <el-table-column prop=\"api_base_url\" label=\"Base URL\" min-width=\"240\" />\n\n          <el-table-column label=\"API Key\" min-width=\"180\">\n            <template #default=\"{ row }\">\n              <span class=\"api-key\">{{ maskKey(row.api_key) }}</span>\n            </template>\n          </el-table-column>\n\n          <el-table-column label=\"状态\" width=\"120\">\n            <template #default=\"{ row }\">\n              <el-tag effect=\"light\" :type=\"row.status === 1 ? 'success' : 'info'\">\n                <span class=\"status-dot\" :class=\"row.status === 1 ? 'dot-green' : 'dot-gray'\"></span>\n                {{ row.status === 1 ? '启用' : '停用' }}\n              </el-tag>\n            </template>\n          </el-table-column>\n\n          <!-- <el-table-column prop=\"updated_at\" label=\"更新于\" min-width=\"180\" /> -->\n\n          <el-table-column label=\"操作\" width=\"140\" fixed=\"right\">\n            <template #default=\"{ row }\">\n              <el-button type=\"primary\" size=\"small\" plain @click=\"openTTSDialog(row)\">编辑</el-button>\n            </template>\n          </el-table-column>\n        </el-table>\n      </el-tab-pane>\n    </el-tabs>\n\n    <!-- LLM 弹窗 -->\n    <el-dialog :title=\"llmForm.id ? '编辑 LLM 提供商' : '新增 LLM 提供商'\" v-model=\"llmDialogVisible\" width=\"560px\">\n      <el-form :model=\"llmForm\" :rules=\"llmRules\" ref=\"llmFormRef\" label-width=\"110px\">\n        <el-form-item label=\"名称\" prop=\"name\">\n          <el-input v-model=\"llmForm.name\" placeholder=\"如：DeepSeek\" />\n        </el-form-item>\n        <el-form-item label=\"Base URL\" prop=\"api_base_url\">\n          <el-input v-model=\"llmForm.api_base_url\" placeholder=\"https://api.xxx.com\" />\n        </el-form-item>\n        <el-form-item label=\"API Key\">\n          <el-input v-model=\"llmForm.api_key\" placeholder=\"可留空\" show-password />\n        </el-form-item>\n        <el-form-item label=\"模型列表\">\n          <el-select\n            v-model=\"currentModelList\"\n            multiple\n            filterable\n            allow-create\n            default-first-option\n            :reserve-keyword=\"false\"\n            placeholder=\"输入模型后回车\"\n            style=\"width: 100%\"\n          >\n          </el-select>\n        </el-form-item>\n        <el-form-item label=\"状态\">\n          <el-switch v-model=\"llmForm.status\" :active-value=\"1\" :inactive-value=\"0\" />\n        </el-form-item>\n        <el-form-item label=\"自定义参数\" prop=\"custom_params\">\n          <el-input type=\"textarea\" v-model=\"llmForm.custom_params\" :rows=\"6\" placeholder='请输入 JSON 格式参数' />\n        </el-form-item>\n\n      </el-form>\n\n      <template #footer>\n        <!-- 新增测试按钮 -->\n        <el-button type=\"warning\" @click=\"testLLM\">测试</el-button>\n        <el-button @click=\"llmDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"submitLLM\">确定</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- TTS 弹窗（编辑） -->\n    <el-dialog title=\"编辑 TTS 引擎\" v-model=\"ttsDialogVisible\" width=\"560px\">\n      <el-form :model=\"ttsForm\" :rules=\"ttsRules\" ref=\"ttsFormRef\" label-width=\"110px\">\n        <el-form-item label=\"名称\" prop=\"name\">\n          <el-input v-model=\"ttsForm.name\" placeholder=\"如：Index_TTS\" />\n        </el-form-item>\n        <el-form-item label=\"Base URL\">\n          <el-input v-model=\"ttsForm.api_base_url\" placeholder=\"可留空\" />\n        </el-form-item>\n        <el-form-item label=\"API Key\">\n          <el-input v-model=\"ttsForm.api_key\" placeholder=\"可留空\" show-password />\n        </el-form-item>\n\n        <el-form-item label=\"状态\">\n          <el-switch v-model=\"ttsForm.status\" :active-value=\"1\" :inactive-value=\"0\" />\n        </el-form-item>\n\n      </el-form>\n\n      <template #footer>\n        <!-- 新增测试按钮 -->\n        <el-button type=\"warning\" @click=\"testTTS\">测试</el-button>\n        <el-button @click=\"ttsDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"submitTTS\">确定</el-button>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n\n\n<script setup>\nimport { ref, onMounted, nextTick, watch } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { CopyDocument } from '@element-plus/icons-vue'\nimport {\n  fetchLLMProviders, createLLMProvider, updateLLMProvider, deleteLLMProvider,\n  fetchTTSProviders, updateTTSProvider, testLLMProvider, testTTSProvider\n} from '../api/provider'\n\nconst activeTab = ref('llm')\n\n// ---------- LLM ----------\nconst llmList = ref([])\n\nconst loadLLM = async () => { llmList.value = await fetchLLMProviders() }\n\nconst llmDialogVisible = ref(false)\nconst llmFormRef = ref()\nconst DEFAULT_CUSTOM_PARAMS = JSON.stringify(\n  {\n    response_format: { type: 'json_object' },\n    temperature: 0.7,\n    top_p: 0.9\n  },\n  null,\n  2  // 漂亮一点，换行缩进\n)\n\nconst llmForm = ref({\n  id: null,\n  name: '',\n  api_base_url: '',\n  api_key: '',\n  model_list: '',\n  status: 1,\n  custom_params: DEFAULT_CUSTOM_PARAMS\n})\nconst llmRules = {\n  name: [{ required: true, message: '请输入名称', trigger: 'blur' }],\n  api_base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],\n  custom_params: [\n    {\n      required: true,\n      message: '自定义参数不能为空，至少为 {}',\n      trigger: 'blur'\n    },\n    {\n      validator: (rule, value, callback) => {\n        const v = (value || '').trim()\n        if (!v) {\n          return callback(new Error('自定义参数不能为空，至少为 {}'))\n        }\n        try {\n          JSON.parse(v)\n          callback()\n        } catch (e) {\n          callback(new Error('自定义参数必须是合法 JSON 格式'))\n        }\n      },\n      trigger: 'blur'\n    }\n  ]\n}\n\nconst currentModelList = ref([])\n\n// 监听弹窗打开，初始化 currentModelList\nwatch(() => llmDialogVisible.value, (val) => {\n  if (val) {\n    if (llmForm.value.model_list) {\n      currentModelList.value = llmForm.value.model_list\n        .split(/[,，]/)\n        .map(s => s.trim())\n        .filter(s => s)\n    } else {\n      currentModelList.value = []\n    }\n  } else {\n    currentModelList.value = []\n  }\n})\n\n// 监听 currentModelList 变化，同步回 llmForm.model_list\nwatch(currentModelList, (val) => {\n  // 如果输入包含逗号，自动分割\n  let hasSplit = false\n  const processedList = []\n\n  for (const item of val) {\n    if (item && (item.includes(',') || item.includes('，'))) {\n      const parts = item.split(/[,，]/).map(s => s.trim()).filter(s => s)\n      processedList.push(...parts)\n      hasSplit = true\n    } else {\n      processedList.push(item)\n    }\n  }\n\n  if (hasSplit) {\n    // 去重并更新 currentModelList\n    currentModelList.value = [...new Set(processedList)]\n    return\n  }\n\n  llmForm.value.model_list = val.join(',')\n}, { deep: true })\n\nconst copyText = (text) => {\n  if (!text) return\n  navigator.clipboard.writeText(text).then(() => {\n    ElMessage.success('已复制')\n  }).catch(() => {\n    ElMessage.error('复制失败')\n  })\n}\n\nfunction openLLMDialog(row) {\n  if (row) llmForm.value = { ...row }\n  else llmForm.value = { id: null, name: '', api_base_url: '', api_key: '', model_list: '', status: 1, custom_params: DEFAULT_CUSTOM_PARAMS }\n  llmDialogVisible.value = true\n}\nfunction submitLLM() {\n  llmFormRef.value.validate(async (valid) => {\n    if (!valid) return\n    try {\n      if (llmForm.value.id) {\n        await updateLLMProvider(llmForm.value.id, llmForm.value)\n        ElMessage.success('已更新')\n      } else {\n        await createLLMProvider(llmForm.value)\n        ElMessage.success('已创建')\n      }\n      llmDialogVisible.value = false\n      await loadLLM()\n    } catch {\n      ElMessage.error('操作失败')\n    }\n  })\n}\nasync function removeLLM(id) {\n  try {\n    await deleteLLMProvider(id)\n    ElMessage.success('已删除')\n    await loadLLM()\n  } catch {\n    ElMessage.error('删除失败')\n  }\n}\n\n\nimport { ElLoading } from 'element-plus'\n\nasync function testLLM() {\n  // 打开等待框\n  const loading = ElLoading.service({\n    lock: true,\n    text: '正在测试，请稍候...',\n    background: 'rgba(0, 0, 0, 0.4)'\n  })\n\n  try {\n    const res = await testLLMProvider(llmForm.value)\n    if (res.code === 200) {\n      ElMessage.success(res.message || '测试成功')\n    } else {\n      ElMessage.error(res.message || '测试失败')\n    }\n  } catch (e) {\n    ElMessage.error('测试异常')\n  } finally {\n    // 关闭等待框\n    loading.close()\n  }\n}\n\n\n\n// ---------- TTS ----------\nconst ttsList = ref([])\nconst ttsDialogVisible = ref(false)\nconst ttsFormRef = ref()\nconst ttsForm = ref({\n  id: 1,\n  name: '',\n  api_base_url: '',\n  api_key: '',\n  status: 1,\n})\nconst ttsRules = {\n  name: [{ required: true, message: '请输入名称', trigger: 'blur' }]\n}\n\nconst loadTTS = async () => {\n  const list = await fetchTTSProviders()\n  ttsList.value = Array.isArray(list) ? list : []\n}\n\nfunction openTTSDialog(row) {\n  ttsForm.value = { ...row }\n  ttsDialogVisible.value = true\n}\n\nfunction submitTTS() {\n  ttsFormRef.value.validate(async (valid) => {\n    if (!valid) return\n    try {\n      await updateTTSProvider(ttsForm.value.id, ttsForm.value)\n      ElMessage.success('已更新')\n      ttsDialogVisible.value = false\n      await loadTTS()\n    } catch {\n      ElMessage.error('操作失败')\n    }\n  })\n}\n\n\n\nasync function testTTS() {\n  const loading = ElLoading.service({\n    lock: true,\n    text: '正在测试 TTS，请稍候...',\n    background: 'rgba(0, 0, 0, 0.4)'\n  })\n\n  try {\n    console.log('ttsForm.value', ttsForm.value)\n    const res = await testTTSProvider(ttsForm.value)\n    if (res.code === 200) {\n      ElMessage.success(res.message || 'TTS 测试成功')\n    } else {\n      ElMessage.error(res.message || 'TTS 测试失败')\n    }\n  } catch (e) {\n    ElMessage.error('TTS 测试异常')\n  } finally {\n    loading.close()\n  }\n}\n\n\n\n// ---------- 工具 ----------\nconst maskKey = (val) => (val ? '•'.repeat(Math.min(val.length, 8)) : '（未设置）')\n\nonMounted(async () => {\n  await Promise.all([loadLLM(), loadTTS()])\n})\n</script>\n\n<style scoped>\n.toolbar {\n  margin-bottom: 12px;\n}\n\n.masked {\n  margin-right: 8px;\n}\n\n.styled-table {\n  border-radius: 10px;\n  overflow: hidden;\n  font-size: 14px;\n}\n\n.styled-table ::v-deep(.el-table__header th) {\n  background-color: var(--el-fill-color-light);\n  font-weight: 600;\n  text-align: center;\n}\n\n.styled-table ::v-deep(.el-table__body td) {\n  text-align: center;\n}\n\n.api-key {\n  background: var(--el-fill-color-light);\n  padding: 2px 6px;\n  border-radius: 6px;\n  font-size: 13px;\n  color: var(--el-text-color-regular);\n}\n\n.status-dot {\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  margin-right: 6px;\n}\n\n.dot-green {\n  background: #67c23a;\n}\n\n.dot-gray {\n  background: #909399;\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/pages/ProjectDubbingDetail.vue",
    "content": "<template>\n    <div class=\"page-wrap\">\n        <!-- 顶部信息栏 -->\n        <div class=\"header\">\n            <div class=\"title-side\">\n                <el-button text @click=\"$router.back()\">\n                    <el-icon>\n                        <ArrowLeft />\n                    </el-icon> 返回\n                </el-button>\n                <h2 class=\"proj-title\">{{ project?.name || '项目名称' }}</h2>\n                <el-tag effect=\"plain\" type=\"info\">ID: {{ projectId }}</el-tag>\n                <el-tag effect=\"light\" class=\"ml8\">章节 {{ stats.chapterCount }}</el-tag>\n                <el-tag effect=\"light\" class=\"ml8\">角色 {{ stats.roleCount }}</el-tag>\n                <el-tag effect=\"light\" class=\"ml8\">台词 {{ stats.lineCount }}</el-tag>\n                <el-tag effect=\"light\" type=\"danger\" class=\"ml8\">剩余生成：{{ queue_rest_size }}</el-tag>\n                <!-- ✅ 精准填充状态 -->\n                <el-tag class=\"ml8\" effect=\"light\" :type=\"project?.is_precise_fill == 1 ? 'success' : 'info'\">\n                    <el-icon style=\"margin-right: 4px;\">\n                        <CircleCheck v-if=\"project?.is_precise_fill == 1\" />\n                        <CircleClose v-else />\n                    </el-icon>\n                    精准填充：{{ project?.is_precise_fill == 1 ? '开启' : '关闭' }}\n                </el-tag>\n\n                <!-- 进度条 -->\n                <div class=\"ml8\" style=\"width: 180px; display: inline-flex; align-items: center;\" v-if=\"lines.length > 0\">\n                    <el-progress \n                        :class=\"{ 'gen-progress': generationProgress < 100 }\"\n                        :percentage=\"generationProgress\"\n                        :stroke-width=\"20\"\n                        :text-inside=\"true\"\n                        :format=\"() => `${generationStats.done} / ${generationStats.total}`\"\n                        style=\"width: 100%\"\n                    />\n                </div>\n\n            </div>\n            <div class=\"action-side\">\n                <el-button @click=\"openProjectSettings\">\n                    <el-icon>\n                        <Setting />\n                    </el-icon> 项目设置\n                </el-button>\n                <el-button type=\"primary\" @click=\"openQueue = true\" class=\"ml8\">\n                    <el-icon>\n                        <Headset />\n                    </el-icon> 消息队列\n                </el-button>\n            </div>\n        </div>\n\n        <el-container class=\"main\">\n            <!-- 左侧章节 -->\n            <el-aside :width=\"asideCollapsed ? '0px' : '240px'\" class=\"aside\" :class=\"{ 'aside-collapsed': asideCollapsed }\">\n                <div class=\"aside-head\">\n                    <div class=\"aside-title\">\n                        <div class=\"title-left\">\n                            <el-icon>\n                                <Menu />\n                            </el-icon>\n                            <span>所有章节</span>\n                        </div>\n\n                        <div class=\"title-right\">\n                            <el-tooltip content=\"定位到当前章节\" placement=\"top\">\n                                <el-button circle size=\"small\" type=\"primary\" plain @click=\"scrollToActiveChapter\">\n                                    <el-icon>\n                                        <Refresh />\n                                    </el-icon>\n                                </el-button>\n                            </el-tooltip>\n                            <el-tooltip content=\"折叠侧边栏\" placement=\"top\">\n                                <el-button circle size=\"small\" plain @click=\"asideCollapsed = true\">\n                                    <el-icon>\n                                        <DArrowLeft />\n                                    </el-icon>\n                                </el-button>\n                            </el-tooltip>\n                        </div>\n                    </div>\n\n\n\n\n\n\n                    <div class=\"aside-actions\">\n\n\n                        <el-button type=\"success\" plain size=\"small\" @click=\"handleBatchImport\">\n                            <el-icon>\n                                <Upload />\n                            </el-icon>\n                            <span>批量导入</span>\n                        </el-button>\n\n                        <el-button type=\"primary\" plain size=\"small\" @click=\"dialogNewChapter = true\">\n                            <el-icon>\n                                <Plus />\n                            </el-icon>\n                            <span>新建章节</span>\n                        </el-button>\n                    </div>\n                    <el-input v-model=\"chapterKeyword\" placeholder=\"搜索章节\" clearable class=\"mb8\">\n                        <template #prefix><el-icon>\n                                <Search />\n                            </el-icon></template>\n                    </el-input>\n                </div>\n\n\n\n                <!-- ✅ 替换开始 -->\n                <!-- 让树撑满剩余高度 -->\n                <div class=\"tree-container\">\n                    <el-tree-v2 ref=\"chapterTreeRef\" :data=\"filteredChapters\" :props=\"{ value: 'id', label: 'title' }\"\n                        :item-size=45 :height=\"treeHeight\" :current-node-key=\"activeChapterId\"\n                        @node-click=\"onSelectChapter\" :highlight-current=\"true\" class=\"chapter-menu\">\n                        <template #default=\"{ data, node }\">\n                            <el-icon>\n                                <Document />\n                            </el-icon>\n                            <div class=\"chapter-item\" :class=\"{ 'is-active': activeChapterId === data.id }\">\n                                <div class=\"chapter-title ellipsis\">{{ data.title }}</div>\n\n                                <div class=\"chapter-ops\">\n                                    <el-button link @click.stop=\"openRenameChapter(data)\" class=\"op-btn\">\n                                        <el-icon>\n                                            <Edit />\n                                        </el-icon>\n                                    </el-button>\n\n                                    <el-popconfirm title=\"确认删除该章节？\" @confirm=\"deleteChapter(data)\">\n                                        <template #reference>\n                                            <el-button link class=\"op-btn del-btn\">\n                                                <el-icon>\n                                                    <Delete />\n                                                </el-icon>\n                                            </el-button>\n                                        </template>\n                                    </el-popconfirm>\n                                </div>\n                            </div>\n                        </template>\n\n                    </el-tree-v2>\n                </div>\n\n\n            </el-aside>\n\n            <!-- 侧边栏折叠时的展开按钮 -->\n            <div v-if=\"asideCollapsed\" class=\"aside-expand-btn\" @click=\"asideCollapsed = false\" title=\"展开侧边栏\">\n                <el-icon :size=\"16\">\n                    <DArrowRight />\n                </el-icon>\n            </div>\n\n            <!-- 主区域 -->\n\n            <el-main class=\"content\">\n                <!-- 未选择章节时显示提示 -->\n                <div v-if=\"!activeChapterId\" class=\"no-chapter-placeholder\">\n                    <el-empty description=\"请先在左侧选择一个章节\" :image-size=\"160\">\n                        <template #image>\n                            <el-icon :size=\"80\" color=\"#c0c4cc\">\n                                <Document />\n                            </el-icon>\n                        </template>\n                    </el-empty>\n                </div>\n\n                <!-- 已选择章节时显示内容 -->\n                <template v-else>\n                <!-- 章节正文 -->\n                <el-card class=\"chapter-card\">\n                    <div class=\"chapter-card-head\">\n                        <div class=\"left\">\n                            <el-icon>\n                                <Document />\n                            </el-icon>\n                            <span class=\"title\">{{ currentChapter?.title || '未选择章节' }}</span>\n                            <el-tag v-if=\"currentChapterContent\" size=\"small\" effect=\"light\" class=\"ml8\">\n                                {{ currentChapterContent.length }} 字\n                            </el-tag>\n                            <el-tag v-if=\"currentChapterContent\" size=\"small\" effect=\"light\" class=\"ml8\">\n                                {{ lines.length }} 行\n                            </el-tag>\n\n                        </div>\n                        <div class=\"right\">\n                            <el-button @click=\"toggleChapterCollapse\" text>\n                                <el-icon>\n                                    <CaretBottom v-if=\"!chapterCollapsed\" />\n                                    <CaretRight v-else />\n                                </el-icon>\n                                {{ chapterCollapsed ? '展开' : '收起' }}\n                            </el-button>\n                            <el-divider direction=\"vertical\" />\n                            <el-button @click=\"openImportDialog\" text>\n                                <el-icon>\n                                    <Upload />\n                                </el-icon> 导入/粘贴\n                            </el-button>\n                            <el-button @click=\"openEditDialog\" text :disabled=\"!currentChapter\">\n                                <el-icon>\n                                    <Edit />\n                                </el-icon> 编辑\n                            </el-button>\n                            <el-button type=\"primary\" @click=\"splitByLLM\" :disabled=\"!currentChapterContent\">\n                                <el-icon>\n                                    <MagicStick />\n                                </el-icon> LLM 拆分为台词\n                            </el-button>\n\n\n                            <!-- 新增：导出 Prompt -->\n                            <el-button @click=\"exportLLMPrompt\" :disabled=\"!currentChapter\">\n                                <el-icon>\n                                    <Document />\n                                </el-icon> 导出 Prompt\n                            </el-button>\n\n                            <!-- 新增：导入第三方 JSON -->\n                            <el-button @click=\"openImportThirdDialog\" :disabled=\"!currentChapter\">\n                                <el-icon>\n                                    <Upload />\n                                </el-icon> 导入第三方 JSON\n                            </el-button>\n                        </div>\n                    </div>\n\n                    <el-collapse-transition>\n                        <div v-show=\"!chapterCollapsed\" class=\"chapter-content-box\">\n                            <el-empty v-if=\"!currentChapterContent\" description=\"尚未导入本章节正文，点击右上角『导入/粘贴』\" />\n                            <el-scrollbar v-else class=\"chapter-scroll\">\n                                <pre class=\"chapter-text\">{{ currentChapterContent }}</pre>\n                            </el-scrollbar>\n                        </div>\n                    </el-collapse-transition>\n                </el-card>\n\n                <el-tabs v-model=\"activeTab\" class=\"el-tabs-box\">\n                    <!-- 台词管理 -->\n                    <el-tab-pane label=\"台词管理\" name=\"lines\">\n                        <div class=\"toolbar\">\n                            <!-- 左侧：筛选区 -->\n                            <div class=\"toolbar-group\">\n                                <el-select v-model=\"roleFilter\" clearable filterable placeholder=\"按角色筛选\" class=\"filter-item w200\">\n                                    <el-option v-for=\"r in roles\" :key=\"r.id\" :label=\"r.name\" :value=\"r.id\" />\n                                </el-select>\n                                <el-input v-model=\"lineKeyword\" placeholder=\"搜索台词\" clearable class=\"filter-item w220\">\n                                    <template #prefix>\n                                        <el-icon>\n                                            <Search />\n                                        </el-icon>\n                                    </template>\n                                </el-input>\n                                <el-tooltip content=\"刷新列表\" placement=\"top\">\n                                    <el-button @click=\"loadLines\" circle plain>\n                                        <el-icon>\n                                            <Refresh />\n                                        </el-icon>\n                                    </el-button>\n                                </el-tooltip>\n                            </div>\n\n                            <!-- 中间：操作区 -->\n                            <div class=\"toolbar-group\">\n                                <el-button type=\"primary\" @click=\"generateAll\">\n                                    <el-icon class=\"mr-1\">\n                                        <Headset />\n                                    </el-icon> 批量生成\n                                </el-button>\n\n                                <el-dropdown trigger=\"click\">\n                                    <el-button type=\"primary\" plain>\n                                        <el-icon class=\"mr-1\">\n                                            <Operation />\n                                        </el-icon> 批量处理\n                                        <el-icon class=\"el-icon--right\">\n                                            <ArrowDown />\n                                        </el-icon>\n                                    </el-button>\n                                    <template #dropdown>\n                                        <el-dropdown-menu>\n                                            <el-dropdown-item @click=\"batchAddTailSilence\">\n                                                <el-icon>\n                                                    <Mute />\n                                                </el-icon> 批量添加间隔\n                                            </el-dropdown-item>\n                                            <el-dropdown-item @click=\"batchProcessSpeed\">\n                                                <el-icon>\n                                                    <Odometer />\n                                                </el-icon> 批量变速\n                                            </el-dropdown-item>\n                                            <el-dropdown-item @click=\"batchProcessVolume\">\n                                                <el-icon>\n                                                    <Microphone />\n                                                </el-icon> 批量调音\n                                            </el-dropdown-item>\n                                        </el-dropdown-menu>\n                                    </template>\n                                </el-dropdown>\n\n                                <el-divider direction=\"vertical\" class=\"toolbar-divider\" />\n\n                                <el-tooltip content=\"导出配音与字幕\" placement=\"top\">\n                                    <el-button type=\"success\" plain @click=\"markAllAsCompleted\">\n                                        <el-icon class=\"mr-1\">\n                                            <Check />\n                                        </el-icon> 导出\n                                    </el-button>\n                                </el-tooltip>\n                                <el-dropdown @command=\"handleCorrectCommand\" trigger=\"click\">\n                                    <el-button type=\"danger\" plain>\n                                        <el-icon class=\"mr-1\">\n                                            <Edit />\n                                        </el-icon> 矫正 <el-icon class=\"el-icon--right\"><ArrowDown /></el-icon>\n                                    </el-button>\n                                    <template #dropdown>\n                                        <el-dropdown-menu>\n                                            <el-dropdown-item command=\"llm\">\n                                                <el-icon><MagicStick /></el-icon> LLM矫正\n                                            </el-dropdown-item>\n                                            <el-dropdown-item command=\"pinyin\">\n                                                <el-icon><Edit /></el-icon> 拼音匹配矫正\n                                            </el-dropdown-item>\n                                        </el-dropdown-menu>\n                                    </template>\n                                </el-dropdown>\n                            </div>\n\n                            <!-- 右侧：设置区 -->\n                            <div class=\"toolbar-group ml-auto\">\n                                <div class=\"switch-item\" @click=\"playMode = playMode === 'sequential' ? 'single' : 'sequential'\">\n                                    <span class=\"switch-label\">连播</span>\n                                    <el-switch v-model=\"playMode\" active-value=\"sequential\" inactive-value=\"single\"\n                                        inline-prompt active-text=\"开\" inactive-text=\"关\" @click.stop />\n                                </div>\n                                <div class=\"switch-item\" @click=\"completionSoundEnabled = !completionSoundEnabled\">\n                                    <span class=\"switch-label\">提示音</span>\n                                    <el-switch v-model=\"completionSoundEnabled\" inline-prompt active-text=\"开\"\n                                        inactive-text=\"关\" @click.stop />\n                                </div>\n                            </div>\n                        </div>\n\n                        <!-- ✅ 新版：虚拟滚动表格 -->\n                        <div class=\"table-box\">\n                            <el-auto-resizer v-slot=\"{ height, width }\">\n                                <el-table-v2 :data=\"displayedLines\" :columns=\"lineColumns\" :row-height=\"150\" fixed\n                                    :width=\"width\" :height=\"height\" row-key=\"id\" class=\"lines-table\" />\n                            </el-auto-resizer>\n                        </div>\n                    </el-tab-pane>\n\n                    <!-- 角色库 -->\n                    <el-tab-pane label=\"角色库\" name=\"roles\">\n\n                        <div class=\"toolbar\">\n                            <el-input v-model=\"roleKeyword\" placeholder=\"搜索角色\" clearable class=\"w260\">\n                                <template #prefix>\n                                    <el-icon>\n                                        <Search />\n                                    </el-icon>\n                                </template>\n                            </el-input>\n                            <el-button @click=\"loadRoles\" circle plain title=\"刷新\">\n                                <el-icon>\n                                    <Refresh />\n                                </el-icon>\n                            </el-button>\n                            <el-divider direction=\"vertical\" class=\"toolbar-divider\" />\n                            <el-button type=\"primary\" @click=\"$router.push('/voices')\">\n                                <el-icon class=\"mr-1\">\n                                    <Plus />\n                                </el-icon> 管理音色库\n                            </el-button>\n                            <el-button type=\"success\" @click=\"openCreateRole\">\n                                <el-icon class=\"mr-1\">\n                                    <Plus />\n                                </el-icon> 新建角色\n                            </el-button>\n                            <el-tooltip placement=\"top\" content=\"此功能为测试版，结果可能不稳定，并且效果依赖于音色的标签，因此尽可能完善丰富音色标签。\">\n                                <el-button type=\"danger\" @click=\"addSmartRoleAndVoice\">\n                                    <el-icon class=\"mr-1\">\n                                        <MagicStick />\n                                    </el-icon>\n                                    智能匹配音色（Beta）\n                                </el-button>\n                            </el-tooltip>\n                        </div>\n\n                        <div class=\"role-grid\">\n                            <el-card v-for=\"r in displayedRoles\" :key=\"r.id\" class=\"role-card\" shadow=\"hover\" :body-style=\"{ padding: '0px' }\">\n                                <div class=\"card-header\">\n                                    <div class=\"role-info-side\">\n                                        <el-avatar :size=\"32\" class=\"role-avatar\">{{ r.name.slice(0, 1) }}</el-avatar>\n                                        <h4 class=\"role-title\" :title=\"r.name\">{{ r.name }}</h4>\n                                    </div>\n                                    <div class=\"role-actions\">\n                                        <el-button link @click=\"openRenameRole(r)\">\n                                            <el-icon><Edit /></el-icon>\n                                        </el-button>\n                                        <el-popconfirm title=\"确定删除该角色？\" @confirm=\"deleteRole(r)\">\n                                            <template #reference>\n                                                <el-button link type=\"danger\">\n                                                    <el-icon><Delete /></el-icon>\n                                                </el-button>\n                                            </template>\n                                        </el-popconfirm>\n                                    </div>\n                                </div>\n\n                                <div class=\"card-body\">\n                                    <p class=\"role-desc\" :title=\"r.description\">{{ r.description || '暂无备注' }}</p>\n                                    \n                                    <div class=\"bind-info\">\n                                        <div class=\"voice-tag-side\">\n                                            <el-tag v-if=\"getRoleVoiceName(r.id)\" type=\"success\" size=\"small\" effect=\"plain\">\n                                                {{ getRoleVoiceName(r.id) }}\n                                            </el-tag>\n                                            <el-tag v-else type=\"info\" size=\"small\" effect=\"plain\">未绑定音色</el-tag>\n                                            \n                                            <el-button circle size=\"small\" :disabled=\"!roleVoiceMap[r.id]\"\n                                                @click=\"toggleVoicePlay(roleVoiceMap[r.id])\"\n                                                class=\"play-btn\">\n                                                <el-icon>\n                                                    <VideoPause v-if=\"isPlaying && currentVoiceId === roleVoiceMap[r.id]\" />\n                                                    <Headset v-else />\n                                                </el-icon>\n                                            </el-button>\n                                        </div>\n\n                                        <el-button type=\"primary\" size=\"small\" link @click=\"openVoiceDialog(r)\">\n                                            {{ getRoleVoiceName(r.id) ? '更换' : '绑定' }}\n                                        </el-button>\n                                    </div>\n                                </div>\n                            </el-card>\n                        </div>\n\n\n                    </el-tab-pane>\n                </el-tabs>\n                </template>\n            </el-main>\n\n        </el-container>\n\n        <!-- 右侧任务队列 -->\n        <el-drawer v-model=\"openQueue\" title=\"任务队列\" size=\"420px\">\n            <el-timeline>\n                <el-timeline-item v-for=\"q in queue\" :key=\"q.id\" :timestamp=\"q.time\" :type=\"q.type\">\n                    <div class=\"queue-item\">\n                        <div class=\"queue-title\">{{ q.title }}</div>\n                        <div class=\"queue-meta\">{{ q.meta }}</div>\n                    </div>\n                </el-timeline-item>\n            </el-timeline>\n        </el-drawer>\n\n        <!-- 新建章节 -->\n        <el-dialog title=\"新建章节\" v-model=\"dialogNewChapter\" width=\"460px\">\n            <el-form :model=\"chapterForm\" ref=\"chapterFormRef\" label-width=\"90px\">\n                <el-form-item label=\"章节标题\" prop=\"title\"\n                    :rules=\"[{ required: true, message: '请输入章节标题', trigger: 'blur' }]\">\n                    <el-input v-model=\"chapterForm.title\" placeholder=\"例如：第一章 初遇\" />\n                </el-form-item>\n            </el-form>\n            <template #footer>\n                <el-button @click=\"dialogNewChapter = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"createChapter\">确定</el-button>\n            </template>\n        </el-dialog>\n\n        <!-- 重命名章节 -->\n        <el-dialog title=\"重命名章节\" v-model=\"dialogRenameChapter\" width=\"460px\">\n            <el-form :model=\"chapterForm\" ref=\"chapterRenameRef\" label-width=\"90px\">\n                <el-form-item label=\"新标题\" prop=\"title\"\n                    :rules=\"[{ required: true, message: '请输入新标题', trigger: 'blur' }]\">\n                    <el-input v-model=\"chapterForm.title\" />\n                </el-form-item>\n            </el-form>\n            <template #footer>\n                <el-button @click=\"dialogRenameChapter = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"renameChapter\">确定</el-button>\n            </template>\n        </el-dialog>\n\n        <!-- 导入/粘贴正文 -->\n        <el-dialog title=\"导入/粘贴章节正文\" v-model=\"dialogImport\" width=\"720px\">\n            <el-input v-model=\"importText\" type=\"textarea\" :rows=\"14\" placeholder=\"在此处粘贴本章节全文…\" />\n            <template #footer>\n                <el-button @click=\"dialogImport = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"submitImport\">保存</el-button>\n            </template>\n        </el-dialog>\n\n        <!-- 编辑正文 -->\n        <el-dialog title=\"编辑章节正文\" v-model=\"dialogEdit\" width=\"720px\">\n            <el-input v-model=\"editText\" type=\"textarea\" :rows=\"14\" placeholder=\"编辑本章节全文…\" />\n            <template #footer>\n                <el-button @click=\"dialogEdit = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"submitEdit\">保存</el-button>\n            </template>\n        </el-dialog>\n        <!-- 角色重命名弹窗 -->\n        <el-dialog title=\"重命名角色\" v-model=\"dialogRenameRole\" width=\"400px\">\n            <el-form :model=\"roleForm\" label-width=\"80px\">\n                <el-form-item label=\"角色名称\" prop=\"name\"\n                    :rules=\"[{ required: true, message: '请输入角色名称', trigger: 'blur' }]\">\n                    <el-input v-model=\"roleForm.name\" />\n                </el-form-item>\n            </el-form>\n            <template #footer>\n                <el-button @click=\"dialogRenameRole = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"renameRole\">确定</el-button>\n            </template>\n        </el-dialog>\n        <!-- 新建角色 -->\n        <el-dialog title=\"新建角色\" v-model=\"dialogCreateRole\" width=\"460px\">\n            <el-form :model=\"createRoleForm\" ref=\"createRoleFormRef\" label-width=\"88px\">\n                <el-form-item label=\"角色名称\" prop=\"name\"\n                    :rules=\"[{ required: true, message: '请输入角色名称', trigger: 'blur' }]\">\n                    <el-input v-model=\"createRoleForm.name\" placeholder=\"如：路人甲 / 萧炎\" />\n                </el-form-item>\n\n                <el-form-item label=\"角色描述\">\n                    <el-input v-model=\"createRoleForm.description\" placeholder=\"可选：角色备注\" />\n                </el-form-item>\n\n                <el-form-item label=\"默认音色\">\n                    <el-select v-model=\"createRoleForm.default_voice_id\" filterable clearable placeholder=\"可选\">\n                        <el-option v-for=\"v in voicesOptions\" :key=\"v.id\" :label=\"v.name\" :value=\"v.id\" />\n                    </el-select>\n                </el-form-item>\n            </el-form>\n\n            <template #footer>\n                <el-button @click=\"dialogCreateRole = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"createRole\">创建</el-button>\n            </template>\n        </el-dialog>\n        <!-- 项目设置弹窗（复用创建项目表单结构） -->\n        <el-dialog v-model=\"settingsVisible\" title=\"项目设置\" width=\"500px\">\n            <el-form :model=\"settingsForm\" :rules=\"settingsRules\" ref=\"settingsFormRef\" label-width=\"100px\">\n                <!-- 项目名称 -->\n                <el-form-item label=\"项目名称\" prop=\"name\">\n                    <el-input v-model=\"settingsForm.name\" placeholder=\"请输入项目名称\"></el-input>\n                </el-form-item>\n\n                <!-- 项目描述 -->\n                <el-form-item label=\"项目描述\" prop=\"description\">\n                    <el-input v-model=\"settingsForm.description\" type=\"textarea\" placeholder=\"请输入项目描述\"\n                        :rows=\"3\"></el-input>\n                </el-form-item>\n\n                <!-- LLM 提供商 -->\n                <el-form-item label=\"LLM 提供商\">\n                    <el-select v-model=\"settingsForm.llm_provider_id\" placeholder=\"请选择 LLM 提供商\" clearable\n                        style=\"width: 100%;\">\n                        <el-option v-for=\"provider in llmProviders\" :key=\"provider.id\" :label=\"provider.name\"\n                            :value=\"provider.id\" />\n                    </el-select>\n                </el-form-item>\n\n                <!-- LLM 模型 -->\n                <el-form-item label=\"LLM 模型\">\n                    <el-select v-model=\"settingsForm.llm_model\" placeholder=\"请选择 LLM 模型\" clearable style=\"width: 100%;\">\n                        <el-option v-for=\"model in availableModels\" :key=\"model\" :label=\"model\" :value=\"model\" />\n                    </el-select>\n                    <!-- 如果为空就提示 -->\n                    <div v-if=\"!settingsForm.llm_model && settingsForm.llm_provider_id\"\n                        style=\"color: #f56c6c; font-size: 12px; margin-top: 4px;\">\n                        请选择 LLM 模型\n                    </div>\n                </el-form-item>\n\n                <!-- TTS 提供商 -->\n                <el-form-item label=\"TTS 引擎\">\n                    <el-select v-model=\"settingsForm.tts_provider_id\" placeholder=\"请选择 TTS 引擎\" clearable\n                        style=\"width: 100%;\">\n                        <el-option v-for=\"tts in ttsProviders\" :key=\"tts.id\" :label=\"tts.name\" :value=\"tts.id\" />\n                    </el-select>\n                </el-form-item>\n                <!-- 提示词模板 -->\n                <el-form-item label=\"提示词模版\">\n                    <el-select v-model=\"settingsForm.prompt_id\" placeholder=\"请选择提示词\" clearable filterable>\n                        <el-option v-for=\"p in prompts\" :key=\"p.id\" :label=\"p.name\" :value=\"p.id\" />\n                    </el-select>\n                </el-form-item>\n                <!-- ✅ 精准填充开关（0/1） -->\n                <!-- ✅ 精准填充开关 + 小问号解释 -->\n                <el-form-item>\n                    <template #label>\n                        <span class=\"label-with-help\">\n                            精准填充\n                            <el-tooltip effect=\"dark\" placement=\"top\" content=\"开启后，会自动填充LLM拆分后遗漏的句子或者词语\">\n                                <el-icon class=\"help-icon\">\n                                    <QuestionFilled />\n                                </el-icon>\n                            </el-tooltip>\n                        </span>\n                    </template>\n\n                    <el-switch v-model=\"settingsForm.is_precise_fill\" :active-value=\"1\" :inactive-value=\"0\"\n                        active-text=\"开启\" inactive-text=\"关闭\" />\n                </el-form-item>\n                <el-form-item label=\"项目根路径\" prop=\"project_root_path\">\n                    <el-input v-model=\"settingsForm.project_root_path\" readonly\n                        placeholder=\"例如：D:\\\\Works\\\\MyProject 或 /Users/me/Projects/demo\" >\n                        <template #append>\n                            <el-button @click=\"openRootDir\">打开目录</el-button>\n                        </template>\n                        </el-input>\n                </el-form-item>\n\n\n            </el-form>\n\n            <template #footer>\n                <el-button @click=\"settingsVisible = false\">取消</el-button>\n                <el-button type=\"primary\" :loading=\"savingSettings\" @click=\"saveProjectSettings\">确定</el-button>\n            </template>\n        </el-dialog>\n\n        <!-- 导入第三方 JSON（台词） -->\n        <el-dialog title=\"导入第三方 JSON（台词）\" v-model=\"dialogImportThird\" width=\"720px\">\n            <el-alert type=\"info\" :closable=\"false\" class=\"mb-2\"\n                title=\"请粘贴一个 JSON 数组，每个元素形如 { role_name: string, text_content: string, emotion_name: string, strength_name: string}；提交后将直接写入该章节台词。\" />\n            <el-input v-model=\"thirdJsonText\" type=\"textarea\" :rows=\"14\"\n                placeholder='[{\"role_name\":\"旁白\",\"text_content\":\"……\",\"emotion_name\": \"平静\", \"strength_name\": \"中等\"}]' />\n            <div class=\"flex items-center gap-2 mt-2\">\n                <el-upload :show-file-list=\"false\" accept=\".json,application/json\" :before-upload=\"readThirdJsonFile\">\n                    <el-button>从文件加载 .json</el-button>\n                </el-upload>\n                <el-text type=\"info\">（可选）选择本地 JSON 文件自动填充</el-text>\n            </div>\n            <template #footer>\n                <el-button @click=\"dialogImportThird = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"submitImportThird\">导入</el-button>\n            </template>\n        </el-dialog>\n\n        <el-dialog v-model=\"dialogSelectVoice.visible\" title=\"选择音色\" width=\"820px\" align-center>\n            <!-- 筛选区 -->\n            <div class=\"filter-bar\">\n                <el-select ref=\"filterSelectRef\" v-model=\"filterTags\" multiple filterable clearable collapse-tags\n                    collapse-tags-tooltip placeholder=\"按标签筛选\" class=\"filter-select\" @change=\"handleTagChange\">\n                    <el-option v-for=\"tag in allTags\" :key=\"tag\" :label=\"tag\" :value=\"tag\" />\n                </el-select>\n\n                <!-- 新增名字搜索框 -->\n                <el-input v-model=\"searchName\" placeholder=\"搜索名字\" clearable style=\"margin-left: 8px; width: 200px;\" />\n            </div>\n\n            <!-- 音色卡片网格 - 增加滚动容器 -->\n            <div class=\"voice-selection-container\">\n                <el-scrollbar max-height=\"60vh\">\n                    <div class=\"voice-grid\">\n                        <el-card v-for=\"v in filteredVoices\" :key=\"v.id\" class=\"voice-card\" shadow=\"hover\"\n                            @click=\"selectVoice(v)\">\n                            <div class=\"voice-card-head\">\n                                <div class=\"voice-title\">{{ v.name }}</div>\n                                <div class=\"voice-desc\">\n                                    <el-tag v-for=\"(tag, index) in (v.description ? v.description.split(',') : [])\" :key=\"index\"\n                                        type=\"info\" effect=\"plain\" size=\"small\">\n                                        {{ tag }}\n                                    </el-tag>\n                                    <span v-if=\"!v.description\">无标签</span>\n                                </div>\n                            </div>\n\n                            <div class=\"voice-actions\">\n                                <el-button circle @click.stop=\"toggleVoicePlay(v.id)\"\n                                    :title=\"isPlaying && currentVoiceId === v.id ? '暂停' : '试听'\">\n                                    <el-icon>\n                                        <Headset />\n                                    </el-icon>\n                                </el-button>\n                                <el-button type=\"primary\" size=\"small\" @click.stop=\"confirmSelectVoice(v)\">\n                                    选择\n                                </el-button>\n                            </div>\n                        </el-card>\n                    </div>\n                </el-scrollbar>\n            </div>\n        </el-dialog>\n\n\n\n        <!-- 拆分预览（解析 get-lines 的结果） -->\n        <!-- <el-dialog title=\"拆分预览\" v-model=\"dialogSplitPreview\" width=\"780px\">\n            <el-table :data=\"splitPreview\" border stripe>\n                <el-table-column prop=\"role_name\" label=\"角色\" width=\"180\" />\n                <el-table-column prop=\"text_content\" label=\"台词\" />\n            </el-table>\n            <template #footer>\n                <el-button @click=\"dialogSplitPreview = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"confirmSaveInitLines\">保存为初始台词</el-button>\n            </template>\n        </el-dialog> -->\n    </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport {\n    Lock, Unlock, ArrowLeft, Setting, Headset, Menu, Plus, Search, Edit, Delete, Refresh, MagicStick, Document, CaretBottom, CaretRight, Upload, VideoPlay, VideoPause, Mute, Check,\n    CircleCheck, CircleClose, QuestionFilled, Odometer, Microphone, ArrowDown, Operation, DArrowLeft, DArrowRight\n} from '@element-plus/icons-vue'\nimport service from '../api/config'\nimport * as chapterAPI from '../api/chapter'\nimport * as roleAPI from '../api/role'\nimport * as projectAPI from '../api/project'\nimport * as lineAPI from '../api/line'\nimport * as voiceAPI from '../api/voice'\nimport * as providerAPI from '../api/provider'\nimport * as enumAPI from '../api/enums' // 例如 emotion/strength API\nimport * as promptAPI from '../api/prompt'\nimport { ElTableV2 } from 'element-plus'\nimport { h } from 'vue'\nimport {\n    ElInput,\n    ElSelect,\n    ElOption,\n    ElTag,\n    ElText,\n    ElButton,\n    ElPopconfirm,\n    ElSwitch\n} from 'element-plus'\nconst emotionLocked = ref(false)\nconst strengthLocked = ref(false)\n\nconst roleColumnLocked = ref(false)\n\n// 侧边栏折叠状态\nconst asideCollapsed = ref(false)\n// //////////////////////////////////websocket\n// ---- WebSocket（局部，纯 JS）+ 任务队列 ----\nimport { onUnmounted } from 'vue'\nconst queue_rest_size = ref(0) // 后端返回的队列剩余长度\n\n\nlet ws = null\nlet wsRetry = 0\nlet reconnectTimer = null\n\nfunction wsUrl() {\n    const httpBase = service.defaults.baseURL // 例如 'http://127.0.0.1:8000/'\n    const proto = location.protocol === 'https:' ? 'wss' : 'ws'\n    const host = httpBase.replace(/^http(s?):\\/\\//, '').replace(/\\/$/, '') // 去掉 http:// 和末尾斜杠\n    return `${proto}://${host}/ws?project_id=${projectId}`\n}\n\n// 队列：追加一条并持久化（最多保留 200 条）\nfunction addQueue(item) {\n    queue.value.unshift({\n        id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,\n        time: new Date().toLocaleTimeString(),\n        title: item.title || '',\n        meta: item.meta || '',\n        type: item.type || 'info', // ElementPlus: primary/success/warning/danger/info\n    })\n    if (queue.value.length > 200) queue.value.length = 200\n    try { localStorage.setItem(`queue_${projectId}`, JSON.stringify(queue.value)) } catch { }\n}\nfunction restoreQueue() {\n    try {\n        const raw = localStorage.getItem(`queue_${projectId}`)\n        if (raw) queue.value = JSON.parse(raw)\n    } catch { }\n}\n\n// 根据后端推送更新本地行\nfunction applyLineUpdate(msg) {\n    const { line_id, status } = msg\n    const idx = lines.value.findIndex(l => l.id === line_id)\n    if (idx >= 0) {\n        const old = lines.value[idx]\n        lines.value[idx] = {\n            ...old,\n            status,                                  // 'pending' | 'processing' | 'done' | 'failed'\n        }\n        // ✅ 关键：当生成完成时，强制重载对应 WaveCellPro\n        if (status === 'done') {\n            console.log(\"音频生成完成，强制重载对应 WaveCellPro\")\n            bumpVer(line_id)           // 让 :key 与 :src?v= 都变\n\n        }\n\n    } else {\n        // 当前章节列表里没有该行（例如切换了章节），这里先忽略。\n        // 需要的话也可以触发一次局部刷新：activeChapterId.value && loadLines()\n    }\n}\n\nconst HEARTBEAT_INTERVAL = 30000;   // 30s 发送一次 ping\nconst HEARTBEAT_DEADLINE = 7000;   // 7s 内未收到 pong 视为假死\nlet heartbeatTimer = null;     // 定时发送 ping\nlet heartbeatTimeout = null;   // 等待 pong 的超时\n\n// 清理心跳定时器\nfunction stopHeartbeat() {\n    if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }\n    if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }\n}\n\nfunction startHeartbeat() {\n    // 先清理旧的定时器，防止重连时产生多个定时器\n    stopHeartbeat();\n    // 周期性发送 ping\n    heartbeatTimer = setInterval(() => {\n        // 如果 readyState 不是 OPEN，等 onclose 去处理重连\n        if (!ws || ws.readyState !== WebSocket.OPEN) return;\n\n        // 发送应用层 ping，并启动一个等待 pong 的超时定时器\n        try {\n            ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));\n            // addQueue({ title: '心跳发送ping', meta: '心跳机制', type: 'info' });\n        } catch { }\n        if (heartbeatTimeout) clearTimeout(heartbeatTimeout);\n        heartbeatTimeout = setTimeout(() => {\n            // 未按期收到 pong，判定为假死，主动关闭触发重连\n            addQueue({ title: '心跳超时', meta: '触发重连', type: 'warning' });\n            try { ws && ws.close(); } catch { }\n        }, HEARTBEAT_DEADLINE);\n    }, HEARTBEAT_INTERVAL);\n}\n\nfunction connectWS() {\n    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return\n\n    ws = new WebSocket(wsUrl())\n\n    ws.onopen = () => {\n        wsRetry = 0\n        addQueue({ title: '已连接任务通道', meta: `项目 ${projectId}`, type: 'primary' })\n        // 启动心跳\n        startHeartbeat();\n        // 可选：连接后拉一次你后端的“快照”接口，补齐中途错过的状态（若有）\n        // try { request.get(`/chapters/processing/${projectId}`).then(res => { if (res?.code === 200) res.data.forEach(applyLineUpdate) }) } catch {}\n    }\n\n    ws.onmessage = (evt) => {\n        try {\n            const msg = JSON.parse(evt.data)\n            if (msg.type === 'pong') {\n                if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }\n                // addQueue({ title: '心跳收到pong', meta: '连接正常', type: 'info' });\n                return;\n            }\n            if (msg.event === 'line_update') {\n                // 队列可视化\n                const type = msg.status === 'failed' ? 'danger'\n                    : msg.status === 'processing' ? 'warning'\n                        : msg.status === 'done' ? 'success'\n                            : msg.status === 'queued' ? 'info'\n                                : 'info'\n                const meta = msg.meta || (msg.status === 'done'\n                    ? '生成完成'\n                    : msg.status === 'processing'\n                        ? '生成中'\n                        : msg.status === 'failed'\n                            ? '生成失败'\n                            : msg.status === 'queued'\n                                ? '已入队'\n                                : '状态更新')\n                // 同时弹出提示框\n                // console.log(`[${new Date().toLocaleTimeString()}] #${msg.line_id} ${meta}`)\n                addQueue({ title: `台词 #${msg.line_id}`, meta, type })\n                \n                // queued 状态不更新行数据，只更新队列大小\n                if (msg.status !== 'queued') {\n                    applyLineUpdate(msg)\n                }\n                \n                // 生成失败时弹出明显的错误提示\n                if (msg.status === 'failed') {\n                    ElMessage.error({\n                        message: `台词 #${msg.line_id} 生成失败: ${msg.meta || '未知错误'}`,\n                        duration: 15000,\n                        showClose: true\n                    })\n                }\n                queue_rest_size.value = msg.progress\n                if (msg.progress === 0 && msg.status !== 'processing' && msg.status !== 'queued') {\n                    if (completionSoundEnabled.value === true) {\n                        const audio = new Audio(new URL('../assets/完成提示音.mp3', import.meta.url).href)\n                        audio.volume = 0.2\n                        audio.play().catch(err => {\n                            console.warn('播放完成提示音失败：', err)\n                        })\n                    }\n                    // 可配合消息提示\n                    // ElMessage({\n                    //     message: '🎵 所有音频已生成完成！',\n                    //     type: 'success'\n                    // })\n                    addQueue({ title: '🎉 所有音频已生成完成！', type: 'success' })\n                }\n\n            }\n        } catch { /* 忽略解析错误 */ }\n    }\n\n    ws.onclose = () => {\n        const delay = Math.min(1000 * Math.pow(2, wsRetry++), 15000)\n        addQueue({ title: '任务通道已断开', meta: `将于 ${delay}ms 后重连`, type: 'warning' })\n        reconnectTimer = setTimeout(connectWS, delay)\n    }\n\n    ws.onerror = () => {\n        try { ws && ws.close() } catch { }\n    }\n}\n\n\n// //////////////////////////////////websocket\n// @ts-ignore\nconst native = window.native\n\n// 路由参数\nconst route = useRoute()\nconst projectId = Number(route.params.id)\n\n// 顶部\nconst project = ref(null)\nconst stats = ref({ chapterCount: 0, roleCount: 0, lineCount: 0 })\n\n// —— 项目设置（复用“创建项目”表单结构）——\nconst settingsVisible = ref(false)\nconst savingSettings = ref(false)\nconst settingsFormRef = ref(null)\nconst settingsForm = ref({\n    name: '',\n    description: '',\n    llm_provider_id: null,\n    llm_model: null,\n    tts_provider_id: null,\n    prompt_id: null,\n    is_precise_fill: null,      // ✅ 新增字段，默认 0\n    project_root_path: null,\n})\nconst settingsRules = {\n    name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],\n    description: [{ required: true, message: '请输入项目描述', trigger: 'blur' }],\n    llm_model: [{ required: true, message: '请选择 LLM 模型', trigger: 'change' }],\n}\n\n// Provider 下拉\nconst llmProviders = ref([])\nconst availableModels = ref([])\nconst ttsProviders = ref([])\nconst prompts = ref([])\n\n// 打开“项目设置”弹窗：预填现有项目数据 + 拉取 Provider\nasync function openProjectSettings() {\n\n    console.log('项目详情', project.value)\n    // 获取项目详情\n\n    settingsVisible.value = true\n    // 先把当前项目的字段填进去（你已有 project 对象）\n    settingsForm.value = {\n        name: project.value?.name || '',\n        description: project.value?.description || '',\n        llm_provider_id: project.value?.llmProviderId ?? project.value?.llm_provider_id ?? null,\n        llm_model: project.value?.llmModel ?? project.value?.llm_model ?? null,\n        tts_provider_id: project.value?.ttsProviderId ?? project.value?.tts_provider_id ?? null,\n        prompt_id: project.value?.promptId ?? project.value?.prompt_id ?? null,\n        is_precise_fill: project.value?.is_precise_fill ?? null,\n        project_root_path: project.value?.project_root_path ?? null\n\n    }\n    console.log('表格详情', settingsForm.value)\n\n    // 并行拉取 Provider\n    try {\n        const [llmRes, ttsRes, promptRes] = await Promise.all([providerAPI.fetchLLMProviders(), providerAPI.fetchTTSProviders(), promptAPI.fetchPromptList()])\n        llmProviders.value = llmRes || []\n        ttsProviders.value = ttsRes || []\n        prompts.value = promptRes || []   // ✅ 保存提示词列表\n        console.log('提示词列表', promptRes)\n        // 回填模型列表\n        const provider = llmProviders.value.find(p => p.id === settingsForm.value.llm_provider_id)\n        console.log('模型列表', provider)\n        // 将provider.model_list转为数组\n        availableModels.value = provider ? (provider.model_list ? provider.model_list.split(',') : []) : []\n        // 如果当前选的模型不在列表里，清空\n        if (!availableModels.value.includes(settingsForm.value.llm_model)) {\n            settingsForm.value.llm_model = null\n        }\n\n    } catch (e) {\n        // 忽略错误，用空列表\n        llmProviders.value = []\n        ttsProviders.value = []\n        availableModels.value = []\n        prompts.value = []\n    }\n}\nwatch(\n    () => settingsForm.value.llm_provider_id,\n    (newProviderId, oldProviderId) => {\n        // 如果是初始化（oldProviderId === undefined/null），不要清空\n        if (!oldProviderId) {\n            const provider = llmProviders.value.find(p => p.id === newProviderId)\n            availableModels.value = provider ? (provider.model_list ? provider.model_list.split(',') : []) : []\n            return\n        }\n\n        // 只有用户真的切换时才清空\n        settingsForm.value.llm_model = null\n        const provider = llmProviders.value.find(p => p.id === newProviderId)\n        availableModels.value = provider ? (provider.model_list ? provider.model_list.split(',') : []) : []\n    }\n)\n\nasync function openRootDir  (){\n    await native.openFolder(settingsForm.value.project_root_path)\n}\n// 保存=更新项目（直接调用你的 update 接口）\nasync function saveProjectSettings() {\n    console.log('保存项目设置', settingsForm.value)\n    if (!projectId) return\n\n    try {\n        // await new Promise((resolve, reject) => {\n        //   settingsFormRef.value.validate((valid) => (valid ? resolve() : reject()))\n        // })\n\n        // 仅提交需要的字段；与后端 DTO 对齐\n        const payload = {\n            name: settingsForm.value.name,\n            description: settingsForm.value.description,\n            llm_provider_id: settingsForm.value.llm_provider_id,\n            llm_model: settingsForm.value.llm_model,\n            tts_provider_id: settingsForm.value.tts_provider_id,\n            prompt_id: settingsForm.value.prompt_id,\n            is_precise_fill: settingsForm.value.is_precise_fill,\n            project_root_path: settingsForm.value.project_root_path\n        }\n        console.log('保存项目设置结果', projectId)\n\n        const res = await projectAPI.updateProject(projectId, payload)\n        console.log('保存项目设置结果', res)\n        if (res?.code === 200) {\n            ElMessage.success('项目设置已保存')\n            settingsVisible.value = false\n            await loadProject() // 刷新头部显示的项目名等\n        } else {\n            ElMessage.error(res?.message || '保存失败')\n        }\n    } catch {\n        /* 校验失败或异常 */\n    } finally {\n        savingSettings.value = false\n    }\n}\n\n\n// 章节\nconst chapters = ref([]) // ChapterResponseDTO[]\nconst activeChapterId = ref(null)\nconst chapterKeyword = ref('')\nconst filteredChapters = computed(() => {\n    const kw = chapterKeyword.value.trim().toLowerCase()\n    return chapters.value.filter(c => c.title.toLowerCase().includes(kw))\n})\nconst currentChapter = computed(() => chapters.value.find(c => c.id === activeChapterId.value) || null)\nconst currentChapterContent = computed(() => currentChapter.value?.text_content || '')\n\nconst chapterCollapsed = ref(true)\nfunction toggleChapterCollapse() { chapterCollapsed.value = !chapterCollapsed.value }\n\nasync function loadProject() {\n    const res = await projectAPI.getProjectDetail(projectId)\n    if (res?.code === 200) project.value = res.data\n}\n\nasync function loadChapters() {\n    const res = await chapterAPI.getChaptersByProject(projectId)\n    chapters.value = res?.code === 200 ? (res.data || []) : []\n    stats.value.chapterCount = chapters.value.length\n    // 不再自动选择章节，由 restoreLastChapter 处理\n    if (activeChapterId.value && chapters.value.find(c => c.id === activeChapterId.value)) {\n        await loadLines()\n        await loadChapterDetail(activeChapterId.value)\n    }\n}\n\nasync function loadChapterDetail(chapterId) {\n    const res = await chapterAPI.getChapterDetail(chapterId)\n    if (res?.code === 200) {\n        // 更新该章在列表里的 text_content\n        const idx = chapters.value.findIndex(c => c.id === chapterId)\n        if (idx >= 0) chapters.value[idx] = res.data\n    }\n}\n\nfunction loadChapterContent(indexStr) {\n    activeChapterId.value = Number(indexStr)\n    loadLines()\n    loadChapterDetail(activeChapterId.value)\n}\n// ✅ 修改后（TreeV2 版本）\nconst onSelectChapter = (data) => {\n    // data 是章节对象，例如 { id: 1, title: \"第一章 起始\" }\n    activeChapterId.value = data.id\n\n    // 如果你原本有加载章节内容的逻辑：\n    loadChapterContent?.(data.id)\n    // 记忆\n    saveLastChapter();\n}\n\nconst dialogNewChapter = ref(false)\nconst dialogRenameChapter = ref(false)\nconst chapterForm = ref({ id: null, title: '' })\n\nasync function createChapter() {\n    const title = chapterForm.value.title?.trim()\n    if (!title) return\n    const res = await chapterAPI.createChapter(title, projectId)\n    if (res?.code === 200) {\n        ElMessage.success('已创建章节')\n        dialogNewChapter.value = false\n        chapterForm.value = { id: null, title: '' }\n        await loadChapters()\n    }\n}\n\nfunction openRenameChapter(c) {\n    chapterForm.value = { id: c.id, title: c.title }\n    dialogRenameChapter.value = true\n}\n\nasync function renameChapter() {\n    const title = chapterForm.value.title?.trim()\n    if (!title) return\n    const id = chapterForm.value.id\n    const payload = { title, project_id: projectId } // DTO 要求必须含 project_id\n    // 保持原排序和已有内容（后端允许的话可只传必填字段）\n    const exist = chapters.value.find(c => c.id === id)\n    if (exist?.text_content) payload.text_content = exist.text_content\n    if (exist?.order_index != null) payload.order_index = exist.order_index\n\n    const res = await chapterAPI.updateChapter(id, payload)\n    if (res?.code === 200) {\n        ElMessage.success('已重命名')\n        dialogRenameChapter.value = false\n        await loadChapters()\n    }\n}\n\nasync function deleteChapter(c) {\n    const res = await chapterAPI.deleteChapter(c.id)\n    if (res?.code === 200) {\n        ElMessage.success('已删除章节')\n        await loadChapters()\n        if (activeChapterId.value === c.id && chapters.value[0]) {\n            activeChapterId.value = chapters.value[0].id\n            await loadLines()\n            await loadChapterDetail(activeChapterId.value)\n        }\n    }\n}\n\n// 导入/编辑章节正文\nconst dialogImport = ref(false)\nconst dialogEdit = ref(false)\nconst importText = ref('')\nconst editText = ref('')\n\nfunction openImportDialog() {\n    importText.value = ''\n    dialogImport.value = true\n}\nfunction openEditDialog() {\n    editText.value = currentChapterContent.value || ''\n    dialogEdit.value = true\n}\n\nasync function submitImport() {\n    if (!activeChapterId.value) return\n    console.log('导入章节正文')\n    const text = importText.value\n    console.log('导入章节正文', text)\n    const exist = chapters.value.find(c => c.id === activeChapterId.value)\n    const payload = {\n        title: exist?.title || '未命名章节',\n        project_id: projectId,\n        text_content: text\n    }\n    const res = await chapterAPI.updateChapter(activeChapterId.value, payload)\n    if (res?.code === 200) {\n        ElMessage.success('已导入章节正文')\n        dialogImport.value = false\n        await loadChapterDetail(activeChapterId.value)\n    }\n}\n\nasync function submitEdit() {\n    if (!activeChapterId.value) return\n    const text = editText.value\n    const exist = chapters.value.find(c => c.id === activeChapterId.value)\n    const payload = {\n        title: exist?.title || '未命名章节',\n        project_id: projectId,\n        text_content: text\n    }\n    const res = await chapterAPI.updateChapter(activeChapterId.value, payload)\n    if (res?.code === 200) {\n        ElMessage.success('已保存修改')\n        dialogEdit.value = false\n        await loadChapterDetail(activeChapterId.value)\n    }\n}\n\n// LLM 拆分（解析 → 预览 → 保存为初始台词）\n// const dialogSplitPreview = ref(false)\n// const splitPreview = ref([]) // LineInitDTO[]\nimport { ElLoading, ElMessageBox } from 'element-plus'\nasync function splitByLLM() {\n    if (!activeChapterId.value) return\n\n    try {\n        await ElMessageBox.confirm(\n            '确定要调用 LLM 对该章节进行台词拆分吗？此操作可能覆盖原有台词。',\n            '确认操作',\n            {\n                confirmButtonText: '确定',\n                cancelButtonText: '取消',\n                type: 'warning',\n            }\n        )\n    } catch {\n        // 用户点取消，直接返回\n        return\n    }\n    // 先删除原有台词\n    const res = await lineAPI.deleteLinesByChapter(activeChapterId.value)\n    if (res?.code === 200) {\n        ElMessage.success('已删除原有台词')\n        await loadLines()\n        await loadRoles()\n    } else {\n        ElMessage.error(res?.message || '删除原有台词失败')\n        return\n    }\n\n    const loading = ElLoading.service({\n        lock: true,\n        text: '正在调用 LLM 拆分台词，请稍候...',\n        background: 'rgba(0, 0, 0, 0.4)',\n    })\n\n    try {\n        console.log('llm进行台词拆分请求开始', projectId, activeChapterId.value)\n        const res = await chapterAPI.splitChapterByLLM(projectId, activeChapterId.value)\n        if (res?.code === 200) {\n            console.log('llm进行台词拆分请求结果 typeof=', typeof res.data, res.data)\n            await loadLines()\n            await loadRoles()\n        } else {\n            ElMessage.warning(res?.message || '解析失败')\n        }\n    } catch (err) {\n        ElMessage.error('LLM 拆分台词失败，请稍后再试')\n        console.error('LLM 请求失败:', err)\n    } finally {\n        loading.close()\n    }\n}\n\n// async function confirmSaveInitLines() {\n//     if (!splitPreview.value.length) return\n//     const res = await request.post(`/chapters/save-init-lines/${projectId}/${activeChapterId.value}`, splitPreview.value)\n//     if (res?.code === 200) {\n//         ElMessage.success('已保存初始台词')\n//         // dialogSplitPreview.value = false\n//         await loadLines()\n//         await loadRoles()\n//     } else {\n//         ElMessage.error(res?.message || '保存失败')\n//     }\n// }\n\n// 台词列表\nconst lines = ref([]) // LineResponseDTO[]\n\n// 进度统计\nconst generationStats = computed(() => {\n    const total = lines.value.length\n    const done = lines.value.filter(l => l.status === 'done').length\n    return { total, done }\n})\nconst generationProgress = computed(() => {\n    if (!generationStats.value.total) return 0\n    return Math.floor((generationStats.value.done / generationStats.value.total) * 100)\n})\nconst activeTab = ref('lines')\nconst lineKeyword = ref('')\nconst roleFilter = ref(null)\n\nconst displayedLines = computed(() => {\n    const kw = lineKeyword.value.trim().toLowerCase()\n    return lines.value\n        .filter(l => (!roleFilter.value ? true : l.role_id === roleFilter.value))\n        .filter(l => (l.text_content || '').toLowerCase().includes(kw))\n        // \n        // ③ 状态筛选 ✅ 新增\n        .filter(l => (!statusFilter.value ? true : l.status === statusFilter.value))\n})\n\nfunction tableHeaderStyle() { return { background: 'var(--el-fill-color-light)', fontWeight: 600, color: 'var(--el-text-color-primary)' } }\n\n\n\n\nasync function loadLines() {\n    if (!activeChapterId.value) return\n    const res = await lineAPI.getLinesByChapter(activeChapterId.value)\n    lines.value = res?.code === 200 ? (res.data || []) : []\n    // 音频默认参数：\n    stats.value.lineCount = lines.value.length\n    // ✅ 关键：刷新所有行的音频版本号，强制 WaveCellPro 重新加载音频\n    lines.value.forEach(row => {\n        if (row.audio_path) {\n            bumpVer(row.id)\n        }\n    })\n}\n\n// async function doProcess(row) {\n//     if (!row?.id || !row.audio_path) return ElMessage.warning('该行无音频')\n//     try {\n//         const payload = {\n//             speed: Number(row._procSpeed || 1.0),\n//             volume: Number(row._procVolume || 1.0),\n//         }\n//         const res = await lineAPI.processAudio(row.id, payload)\n//         if (res?.code === 200) {\n//             ElMessage.success('处理完成')\n//             // 若另存，后端已更新 audio_path；这里刷新一次列表以拿到最新路径\n//             await loadLines()\n//             // 可选：自动播放预览\n//             // playLine(row)\n//         } else {\n//             ElMessage.error(res?.message || '处理失败')\n//         }\n//     } catch (e) {\n//         ElMessage.error('处理失败')\n//         console.error(e)\n//     }\n// }\n\n// 替换原来的两个函数\nfunction statusType(s) {\n    if (s === 'done') return 'success'\n    if (s === 'processing') return 'warning'\n    if (s === 'failed') return 'danger'\n    return 'info' // pending\n}\nfunction statusText(s) {\n    if (s === 'done') return '已生成'\n    if (s === 'processing') return '生成中'\n    if (s === 'failed') return '生成失败'\n    return '未生成' // pending\n}\n\n\nfunction canGenerate(row) {\n    const voiceId = getRoleVoiceId(row.role_id)\n    // return !!voiceId && row.status !== 'processing'\n    return !!voiceId\n}\n\nasync function generateOne(row) {\n    if (!canGenerate(row)) {\n        ElMessage.warning('请先为该角色绑定音色')\n        return\n    }\n\n    try {\n        if (row.is_done !== 0) {\n            row.is_done = 0\n            updateLineIsDone(row, 0)\n        }\n        // ✅ 用户点击“确定”后才继续执行以下逻辑\n        addQueue({ title: `台词 #${row.id}`, meta: '已入队，开始生成', type: 'info' })\n\n        const body = {\n            chapter_id: row.chapter_id,\n            role_id: row.role_id,\n            voice_id: getRoleVoiceId(row.role_id),\n            id: row.id,\n            emotion_id: row.emotion_id,\n            strength_id: row.strength_id,\n            text_content: row.text_content,\n            audio_path: row.audio_path,\n        }\n\n        console.log('准备生成音频:', body)\n\n        const res = await lineAPI.generateAudio(projectId, activeChapterId.value, body)\n\n        if (res?.code === 200) {\n\n            ElMessage.success('已添加到异步任务中')\n            // 前端转换状态，会不会影响？有待商定是自己动变更，还是等后端推送\n            row.status = 'processing'\n            // 强制刷新行\n            // await loadLines()\n\n        } else {\n            addQueue({\n                title: `台词 #${row.id}`,\n                meta: res?.message || '生成失败（请求失败）',\n                type: 'danger',\n            })\n            ElMessage.error(res?.message || '生成失败')\n        }\n    } catch (err) {\n        // ✅ 用户点击“取消”或关闭弹窗时\n        if (err === 'cancel' || err === 'close') {\n            ElMessage.info('已取消生成操作')\n        } else {\n            console.error('生成出错:', err)\n            ElMessage.error('生成失败，请稍后再试')\n        }\n    }\n}\n\n\n/**\n * 并发限制执行器\n * @param {Array} tasks - 任务数组\n * @param {Function} fn - 异步执行函数，接收单个任务项\n * @param {number} concurrency - 最大并发数\n */\nasync function runWithConcurrencyLimit(tasks, fn, concurrency = 5) {\n    const results = []\n    let index = 0\n\n    async function worker() {\n        while (index < tasks.length) {\n            const currentIndex = index++\n            try {\n                results[currentIndex] = await fn(tasks[currentIndex])\n            } catch (err) {\n                results[currentIndex] = { error: err }\n            }\n        }\n    }\n\n    // 启动 concurrency 个 worker 并行处理\n    const workers = Array(Math.min(concurrency, tasks.length))\n        .fill(null)\n        .map(() => worker())\n\n    await Promise.all(workers)\n    return results\n}\n\nfunction generateAll() {\n    const todo = displayedLines.value.filter(l => canGenerate(l))\n    if (!todo.length) {\n        return ElMessage.info('无可生成项或未绑定音色')\n    }\n\n    ElMessageBox.confirm(\n        `此操作将会重新生成 ${todo.length} 条已绑定音色的台词，是否继续？`,\n        '提示',\n        {\n            confirmButtonText: '确认',\n            cancelButtonText: '取消',\n            type: 'warning',\n        }\n    )\n        .then(async () => {\n            // 用户确认，使用并发限制（最多5个同时请求）\n            await runWithConcurrencyLimit(todo, generateOne, 5)\n        })\n        .catch(() => {\n            // 用户取消\n            ElMessage.info('已取消批量生成')\n        })\n}\n\n// 播放\nconst audioPlayer = new Audio()\n\nconst isPlaying = ref(false)\nconst currentVoiceId = ref(null)\n\nfunction toggleVoicePlay(voiceId) {\n    if (!voiceId) return\n    const voice = voicesOptions.value.find(v => v.id === voiceId)\n    if (!voice?.reference_path) return ElMessage.warning('该音色未设置参考音频')\n\n    const src = native?.pathToFileUrl ? native.pathToFileUrl(voice.reference_path) : voice.reference_path\n\n    if (currentVoiceId.value === voiceId) {\n        // 切换暂停/继续\n        if (isPlaying.value) {\n            audioPlayer.pause()\n        } else {\n            audioPlayer.play().catch(() => ElMessage.error('无法播放音频'))\n        }\n        return\n    }\n\n    // 播放新的音色\n    audioPlayer.pause()\n    audioPlayer.src = src\n    audioPlayer.currentTime = 0\n    currentVoiceId.value = voiceId\n    audioPlayer.play().catch(() => ElMessage.error('无法播放音频'))\n}\n\n// 状态监听\naudioPlayer.addEventListener('play', () => { isPlaying.value = true })\naudioPlayer.addEventListener('pause', () => { isPlaying.value = false })\naudioPlayer.addEventListener('ended', () => {\n    isPlaying.value = false\n    currentVoiceId.value = null\n})\n\n\n\nfunction playLine(row) {\n    if (!row.audio_path) return\n    try {\n        const src = native?.pathToFileUrl ? native.pathToFileUrl(row.audio_path) : row.audio_path\n        audioPlayer.pause()\n        audioPlayer.src = src\n        audioPlayer.currentTime = 0\n        audioPlayer.play().catch(() => ElMessage.error('无法播放音频'))\n    } catch {\n        ElMessage.error('无法播放音频')\n    }\n}\n\n// 角色 & 绑定音色\nconst roles = ref([]) // RoleResponseDTO[]\nconst roleKeyword = ref('')\nconst displayedRoles = computed(() => {\n    const kw = roleKeyword.value.trim().toLowerCase()\n    return roles.value.filter(r => r.name.toLowerCase().includes(kw)).sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))\n})\n\nconst roleVoiceMap = ref({}) // roleId -> voiceId\nconst voicesOptions = ref([]) // VoiceResponseDTO[]\n\nasync function loadRoles() {\n    const res = await roleAPI.getRolesByProject(projectId)\n    roles.value = res?.code === 200 ? (res.data || []) : []\n    stats.value.roleCount = roles.value.length\n    // 同步默认绑定\n    const map = {}\n    roles.value.forEach(r => {\n        if (r.default_voice_id) map[r.id] = r.default_voice_id\n    })\n    roleVoiceMap.value = map\n}\n\nasync function loadVoices() {\n    // 默认 TTS = 1\n    const res = await voiceAPI.getVoicesByTTS()\n    console.log('loadVoices', res)\n    voicesOptions.value = res?.code === 200 ? (res.data || []) : []\n}\n\nfunction getRoleName(roleId) { return roles.value.find(r => r.id === roleId)?.name || '—' }\nfunction getRoleVoiceId(roleId) { return roleVoiceMap.value[roleId] || null }\nfunction getRoleVoiceName(roleId) {\n    const vid = getRoleVoiceId(roleId)\n    return voicesOptions.value.find(v => v.id === vid)?.name\n}\n\nasync function bindVoice(r) {\n    // 更新角色的 default_voice_id\n    const payload = {\n        name: r.name,\n        project_id: r.project_id,\n        default_voice_id: roleVoiceMap.value[r.id] || null\n    }\n    const res = await roleAPI.updateRole(r.id, payload)\n    if (res?.code === 200) {\n        ElMessage.success(`已为「${r.name}」绑定音色`)\n    } else {\n        ElMessage.error(res?.message || '绑定失败')\n    }\n}\n\n// 任务队列（简单示意）\nconst openQueue = ref(false)\nconst queue = ref([])\n\n// 初始化\n\nonMounted(async () => {\n    await loadProject()\n    await Promise.all([loadChapters(), loadRoles(), loadVoices()])\n    restoreLastChapter() // 恢复上次章节\n    scrollToActiveChapter() // 定位到选中的章节\n    await loadLines()\n    await loadChapterDetail(activeChapterId.value)\n    // —— WebSocket：恢复历史队列并连接\n    restoreQueue()\n    connectWS()\n})\n\nonUnmounted(() => {\n    // 清理重连定时器\n    if (reconnectTimer) clearTimeout(reconnectTimer)\n    // 清理心跳定时器\n    stopHeartbeat()\n    // 关闭 WebSocket 连接\n    try { ws && ws.close() } catch { }\n    ws = null\n})\n\n\nconst dialogRenameRole = ref(false)\nconst roleForm = ref({ id: null, name: '', project_id: projectId })\n\nfunction openRenameRole(r) {\n    roleForm.value = { id: r.id, name: r.name, project_id: r.project_id }\n    dialogRenameRole.value = true\n}\n\nasync function renameRole() {\n    const res = await roleAPI.updateRole(roleForm.value.id, roleForm.value)\n    if (res?.code === 200) {\n        ElMessage.success('角色重命名成功')\n        dialogRenameRole.value = false\n        await loadRoles()\n        await loadLines() // 刷新台词角色名\n    } else {\n        ElMessage.error(res?.message || '重命名失败')\n    }\n}\n\nasync function deleteRole(r) {\n    const res = await roleAPI.deleteRole(r.id)\n    if (res?.code === 200) {\n        ElMessage.success('角色删除成功')\n        await loadRoles()\n        await loadLines() // 同步台词，角色应置空\n    } else {\n        ElMessage.error(res?.message || '删除失败')\n    }\n}\n\n// —— 新建角色 —— //\nconst dialogCreateRole = ref(false)\nconst createRoleFormRef = ref(null)\nconst createRoleForm = ref({\n    name: '',\n    description: '',\n    default_voice_id: null,\n    project_id: projectId,\n})\n\nfunction openCreateRole() {\n    createRoleForm.value = {\n        name: '',\n        description: '',\n        default_voice_id: null,\n        project_id: projectId,\n    }\n    dialogCreateRole.value = true\n}\nasync function addSmartRoleAndVoice() {\n    // 二次确认\n    try {\n        await ElMessageBox.confirm(`确定要智能进行匹配音色吗？`, '提示', {\n            confirmButtonText: '确认',\n            cancelButtonText: '取消',\n            type: 'warning',\n        })\n    } catch {\n        return // 用户取消\n    }\n    const loading = ElLoading.service({\n        lock: true,\n        text: '正在智能匹配角色和音色，请稍候...',\n        background: 'rgba(0, 0, 0, 0.4)',\n    })\n    // 发送请求（必须 await + 声明 res）\n    try {\n        const res = await chapterAPI.addSmartRoleAndVoice(projectId, activeChapterId.value)\n        if (res?.code === 200) {\n            // 提取出res.data列表中所有角色名\n            const names = res.data.map(r => r.role_name)\n\n            ElMessage.success(`已为「${names}」智能匹配音色`)\n            await loadRoles()\n            await loadLines()  // 同步台词角色名\n        } else {\n            ElMessage.error(res?.message || '匹配音色失败')\n        }\n    } catch (e) {\n        ElMessage.error('匹配音色异常')\n    } finally {\n        loading.close()\n    }\n\n}\n\n\n\nasync function createRole() {\n    // 简单防重名提示（前端软校验，最终以后端为准）\n    const name = (createRoleForm.value.name || '').trim()\n    if (!name) return ElMessage.warning('请输入角色名称')\n    const dup = roles.value.some(r => r.name === name)\n    if (dup) {\n        // 允许创建同名与否以你后端为准，这里仅提醒\n        await ElMessageBox.confirm(`已存在名为「${name}」的角色，仍要创建吗？`, '提示', {\n            confirmButtonText: '继续创建',\n            cancelButtonText: '取消',\n            type: 'warning',\n        }).catch(() => { return })\n        if (!name) return // 用户取消\n    }\n\n    // 选择一种：roleAPI 或 request\n    // 1) 如果你有 roleAPI.createRole：\n    const res = await roleAPI.createRole(createRoleForm.value)\n\n    // 2) 通用：直接用 request.post\n    //   const res = await request.post('/roles', createRoleForm.value)\n\n    if (res?.code === 200) {\n        ElMessage.success('已创建角色')\n        dialogCreateRole.value = false\n\n        // 刷新角色与台词（有些页面需要马上用到）\n        await loadRoles()\n        await loadLines()\n\n        // 如果选择了默认音色，同步映射，避免下拉延迟\n        const newRole = (res.data) ? res.data : roles.value.find(r => r.name === name)\n        if (newRole && createRoleForm.value.default_voice_id) {\n            roleVoiceMap.value[newRole.id] = createRoleForm.value.default_voice_id\n        }\n\n        // 若你前面实现了“隐藏已删除同名角色”的本地黑名单，这里确保新建角色可见：\n        if (typeof hiddenRoleNames !== 'undefined' && hiddenRoleNames?.value instanceof Set) {\n            if (hiddenRoleNames.value.has(name)) {\n                hiddenRoleNames.value.delete(name)\n                try { localStorage.setItem(`hidden_roles_${projectId}`, JSON.stringify([...hiddenRoleNames.value])) } catch { }\n            }\n        }\n    } else {\n        ElMessage.error(res?.message || '创建失败')\n    }\n}\n// 插入与删除\nasync function insertBelow(row) {\n    if (!activeChapterId.value) return\n\n    // 1) 先创建新行（后端返回 newId）\n    const createRes = await lineAPI.createLine(projectId, {\n        chapter_id: row.chapter_id,\n        role_id: row.role_id,\n        text_content: '',\n        status: 'pending',\n        line_order: 0, // 随便，后面统一重排\n        is_done: 0,\n        emotion_id: row.emotion_id,\n        strength_id: row.strength_id\n    })\n    if (createRes?.code !== 200 || !createRes.data?.id) {\n        return ElMessage.error(createRes?.message || '插入失败')\n    }\n    const newId = createRes.data.id\n    // 2) 插入新行到当前行的下方（修改 lines 列表）\n    const insertIndex = lines.value.findIndex(item => item.id === row.id)\n    if (insertIndex === -1) {\n        return ElMessage.error('找不到插入位置')\n    }\n\n    // 创建一个“空行”对象，插入到列表中\n    const newLine = {\n        ...row,\n        id: newId,\n        role_id: null,\n        text_content: '',\n        status: 'pending',\n        is_done: 0,\n        // 情绪和强度继承当前行\n\n    }\n\n    lines.value.splice(insertIndex + 1, 0, newLine)\n\n    // 3) 重新构造 orderList，按当前顺序赋予新的 line_order\n    const orderList = lines.value.map((line, index) => ({\n        id: line.id,\n        line_order: index + 1\n    }))\n\n    console.log('orderList', orderList)\n    // 4) 调用批量重排接口\n    const reorderRes = await lineAPI.reorderLinesByPut(orderList)\n\n    if (reorderRes?.code === 200) {\n        ElMessage.success('已插入并更新顺序')\n        await loadLines()\n    } else {\n        ElMessage.error(reorderRes?.message || '更新顺序失败')\n        await loadLines() // 以服务端为准\n    }\n}\n// 插入到顶部\nasync function insertAtTop() {\n    if (!activeChapterId.value) return\n\n    // 1) 先创建新行（后端返回 newId）\n    const createRes = await lineAPI.createLine(projectId, {\n        chapter_id: activeChapterId.value,\n        role_id: null,\n        text_content: '',\n        status: 'pending',\n        line_order: 0 // 随便，后面统一重排\n    })\n    if (createRes?.code !== 200 || !createRes.data?.id) {\n        return ElMessage.error(createRes?.message || '插入失败')\n    }\n    const newId = createRes.data.id\n\n    // 2) 创建“空行”对象，插到最前面\n    const newLine = {\n        id: newId,\n        chapter_id: activeChapterId.value,\n        role_id: null,\n        text_content: '',\n        status: 'pending'\n    }\n\n    lines.value.unshift(newLine) // 插到数组开头\n\n    // 3) 重新构造 orderList\n    const orderList = lines.value.map((line, index) => ({\n        id: line.id,\n        line_order: index + 1\n    }))\n\n    // 4) 调用批量重排接口\n    const reorderRes = await lineAPI.reorderLinesByPut(orderList)\n\n    if (reorderRes?.code === 200) {\n        ElMessage.success('已在第一行插入并更新顺序')\n        await loadLines()\n    } else {\n        ElMessage.error(reorderRes?.message || '更新顺序失败')\n        await loadLines()\n    }\n}\n\n\nasync function deleteLine(row) {\n    // 1) 调用后端删除\n    const delRes = await lineAPI.deleteLine(row.id)\n    if (delRes?.code !== 200) {\n        return ElMessage.error(delRes?.message || '删除失败')\n    }\n\n    // 2) 前端移除这一行\n    const deleteIndex = lines.value.findIndex(item => item.id === row.id)\n    if (deleteIndex !== -1) {\n        lines.value.splice(deleteIndex, 1)\n    }\n\n    // 3) 重排顺序\n    const orderList = lines.value.map((line, index) => ({\n        id: line.id,\n        line_order: index + 1\n    }))\n\n    const reorderRes = await lineAPI.reorderLinesByPut(orderList)\n\n    if (reorderRes?.code === 200) {\n        ElMessage.success('已删除并更新顺序')\n        await loadLines()\n    } else {\n        ElMessage.error(reorderRes?.message || '更新顺序失败')\n        await loadLines() // 以服务端为准\n    }\n}\n\n\nasync function updateLineRole(row) {\n    if (!row?.id || row.role_id === null) return\n    console.log('updateLineRole', row)\n    const res = await lineAPI.updateLine(row.id, {\n        chapter_id: row.chapter_id,\n        role_id: row.role_id,\n    })\n\n    if (res?.code === 200) {\n        ElMessage.success('角色已更新')\n        // \n    } else {\n        ElMessage.error(res?.message || '角色更新失败')\n    }\n}\n\n\nconst textLocked = ref(false) // 防止多次触发\n\nasync function updateLineText(row) {\n    if (!row?.id) return\n\n    // ✅ 如果没改动就不发请求\n    if (row.tempText === undefined || row.tempText === row.text_content) return\n\n    const oldText = row.text_content\n    row.text_content = row.tempText // 提交临时值\n\n    try {\n        const res = await lineAPI.updateLine(row.id, {\n            chapter_id: row.chapter_id,\n            text_content: row.text_content,\n        })\n\n        if (res?.code === 200) {\n            ElMessage.success('台词已更新')\n            delete row.tempText // 清空临时缓存\n\n            // ✅ 文本更新后自动重置状态\n            if (row.is_done !== 0) {\n\n                await updateLineIsDone(row, 0)\n                row.is_done = 0\n            }\n        } else {\n            // ❌ 失败回滚\n            row.text_content = oldText\n            ElMessage.error(res?.message || '更新失败')\n        }\n    } catch (err) {\n        // ❌ 网络或异常情况回滚\n        row.text_content = oldText\n        ElMessage.error('请求出错')\n    }\n}\n\n\n\n\n// —— 导出 Prompt / 导入第三方 JSON —— //\nconst dialogImportThird = ref(false)\nconst thirdJsonText = ref('')\n\nfunction openImportThirdDialog() {\n    thirdJsonText.value = ''\n    dialogImportThird.value = true\n}\n\n// 读取本地 .json 文件，填充到文本框\nasync function readThirdJsonFile(file) {\n    try {\n        const text = await file.text()\n        // 简单校验是否为数组\n        const parsed = JSON.parse(text)\n        if (!Array.isArray(parsed)) {\n            ElMessage.error('JSON 须为数组')\n            return false\n        }\n        thirdJsonText.value = JSON.stringify(parsed, null, 2)\n        return false // 阻止 el-upload 的默认上传\n    } catch (e) {\n        ElMessage.error('读取文件失败或 JSON 非法')\n        return false\n    }\n}\n\n// 导出 Prompt：调用 GET /export-llm-prompt/{project_id}/{chapter_id}，下载 .txt 文件\n\nasync function exportLLMPrompt() {\n    if (!projectId || !activeChapterId.value) return\n    const res = await chapterAPI.exportLLMPrompt(projectId, activeChapterId.value)\n    if (res?.code === 200) {\n        const text = res.data || ''\n        if (!text) {\n            ElMessage.warning('返回内容为空')\n            return\n        }\n\n        const action = await ElMessageBox.confirm(\n            '是否复制到剪贴板？（取消则下载文件）',\n            '导出方式',\n            {\n                confirmButtonText: '复制',\n                cancelButtonText: '下载',\n                type: 'info',\n                distinguishCancelAndClose: true\n            }\n        ).catch(() => 'download') // 如果关闭或取消，就走下载\n\n        if (action === 'confirm') {\n            await navigator.clipboard.writeText(text)\n            ElMessage.success('已复制到剪贴板')\n        } else {\n            const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })\n            const url = URL.createObjectURL(blob)\n            const a = document.createElement('a')\n            a.href = url\n            const chapterName = currentChapter.value?.title || `chapter_${activeChapterId.value}`\n            a.download = `prompt_${projectId}_${activeChapterId.value}_${chapterName}.txt`\n            document.body.appendChild(a)\n            a.click()\n            a.remove()\n            URL.revokeObjectURL(url)\n            ElMessage.success('Prompt 已导出')\n        }\n    } else {\n        ElMessage.error(res?.message || '导出失败')\n    }\n}\n\n\n\n// 导入第三方 JSON：先删除原台词，再导入\nasync function submitImportThird() {\n    if (!projectId || !activeChapterId.value) return\n    const raw = (thirdJsonText.value || '').trim()\n    if (!raw) return ElMessage.warning('请先粘贴 JSON 内容')\n\n    // 基础合法性校验\n    let parsed\n    try {\n        parsed = JSON.parse(raw)\n        if (!Array.isArray(parsed)) throw new Error()\n    } catch {\n        return ElMessage.error('JSON 非法：需要一个数组')\n    }\n\n    // 二次确认\n    try {\n        await ElMessageBox.confirm(\n            '导入将会【删除本章节现有全部台词】并用第三方 JSON 重建，是否继续？',\n            '确认导入',\n            { type: 'warning', confirmButtonText: '继续', cancelButtonText: '取消' }\n        )\n    } catch {\n        return // 用户取消\n    }\n\n    // 1) 先删除原有台词\n    const delRes = await lineAPI.deleteLinesByChapter(activeChapterId.value)\n    if (delRes?.code !== 200) {\n        return ElMessage.error(delRes?.message || '删除原有台词失败')\n    }\n    ElMessage.success('已清空原有台词')\n\n    // 2) 再导入第三方 JSON（multipart/form-data，字段名 data）\n    const fd = new FormData()\n    fd.append('data', JSON.stringify(parsed)) // 用规范化后的 JSON，避免多余空白\n\n    const res = await chapterAPI.importThirdLines(projectId, activeChapterId.value, fd)\n    if (res?.code === 200) {\n        ElMessage.success('导入成功')\n        dialogImportThird.value = false\n        await loadLines()\n        await loadRoles()\n    } else {\n        ElMessage.error(res?.message || '导入失败')\n        // 可选：导入失败后要不要把之前删除的内容回滚？前端无法回滚，必要时后端做事务。\n    }\n}\n\n// 完成配音，替换昵称\n\n// 保证跟行顺序一一对应；若后端返回已按 line_order 排好，这段可省略\n// const sortedLines = () => {\n//   const list = [...lines.value]\n//   // 如果有 line_order，就按它排；否则按当前顺序\n//   list.sort((a, b) => {\n//     const ao = a.line_order ?? Number.MAX_SAFE_INTEGER\n//     const bo = b.line_order ?? Number.MAX_SAFE_INTEGER\n//     return ao - bo\n//   })\n//   return list\n// }\n\n// 若你的后端返回的 lines 已经按 line_order 排好，可以直接用 lines.value\n\nfunction getFolderFromPath(audioPath) {\n    if (!audioPath) return ''\n    const sep = audioPath.includes('\\\\') ? '\\\\' : '/'\n    return audioPath.slice(0, audioPath.lastIndexOf(sep))\n}\nconst replaceFilename = (p, name) => (p ? p.replace(/[^/\\\\]+$/, name) : name)\nconst addTempPrefix = (p) => (p ? p.replace(/([^/\\\\]+)$/, 'temp_$1') : null)\n\nasync function markAllAsCompleted() {\n    const list = lines.value\n    if (!list.length) {\n        ElMessage.info('当前无台词')\n        return\n    }\n    try {\n        await ElMessageBox.confirm(\n            '此操作将会批量导出所有台词音频，是否继续？',\n            '提示',\n            {\n                confirmButtonText: '确认',\n                cancelButtonText: '取消',\n                type: 'warning',\n            }\n        )\n    } catch {\n        ElMessage.info('已取消操作')\n        return\n    }\n    const loading = ElLoading.service({\n        lock: true,\n        text: '正在批量修改 audio_path（阶段 1/3）...',\n        background: 'rgba(0,0,0,0.3)'\n    })\n\n    // —— 阶段 1：全部先加 temp_ 前缀 —— //\n    let ok1 = 0, skip1 = 0, fail1 = 0\n    for (const line of list) {\n        if (!line.audio_path) { skip1++; continue }\n        const base = /[^/\\\\]+$/.exec(line.audio_path)?.[0] || ''\n        if (base.startsWith('temp_')) { skip1++; continue }\n\n        const tmpPath = addTempPrefix(line.audio_path)\n        if (!tmpPath) { skip1++; continue }\n\n        try {\n            const res = await lineAPI.updateLineAudioPath(line.id, {\n                chapter_id: line.chapter_id,\n                audio_path: tmpPath\n            })\n            const success = res?.code === 200 || res === true || res?.data === true\n            if (success) {\n                line.audio_path = tmpPath\n                ok1++\n            } else {\n                fail1++\n                console.error(`阶段1失败 line#${line.id}:`, res)\n            }\n        } catch (e) {\n            fail1++\n            console.error(`阶段1异常 line#${line.id}:`, e?.response?.data || e)\n        }\n    }\n\n    // —— 阶段 2：按 line_order 重命名为 index{line_order}.wav —— //\n    loading.setText('正在批量修改 audio_path（阶段 2/3）...')\n    let ok2 = 0, skip2 = 0, fail2 = 0\n\n    for (const line of list) {\n        if (!line.audio_path) { skip2++; continue }\n\n        const ord = Number.isInteger(line.line_order) ? line.line_order : null\n        if (ord == null) { skip2++; continue }\n\n        // 取台词前10字作为文件名一部分\n        // 取台词前10字\n        const text = (line.text_content || '').trim().slice(0, 10)\n\n        // 去掉空格和中英文标点\n        const cleanText = text.replace(/[\\s\\p{P}]/gu, '')\n\n        // 再过滤掉文件名非法字符（Windows 不能包含 \\/:*?\"<>|）\n        const safeText = cleanText.replace(/[\\\\/:*?\"<>|]/g, '')\n\n        const newName = `${ord}_${safeText}.wav`\n        // const newName = `index${ord}.wav`\n        const currentName = /[^/\\\\]+$/.exec(line.audio_path)?.[0]\n        console.log('currentName=', currentName)\n        if (currentName === newName) { skip2++; continue }\n\n        const newPath = replaceFilename(line.audio_path, newName)\n        try {\n            const res = await lineAPI.updateLineAudioPath(line.id, {\n                chapter_id: line.chapter_id,\n                audio_path: newPath\n            })\n            const success = res?.code === 200 || res === true || res?.data === true\n            if (success) {\n                line.audio_path = newPath\n                ok2++\n            } else {\n                fail2++\n                console.error(`阶段2失败 line#${line.id}:`, res)\n            }\n        } catch (e) {\n            fail2++\n            console.error(`阶段2异常 line#${line.id}:`, e?.response?.data || e)\n        }\n    }\n\n    // —— 阶段 3：导出音频与字幕（也显示在 Loading 里） —— //\n    const total = list.length\n    const msg1 = `阶段1：成功 ${ok1}，跳过 ${skip1}，失败 ${fail1}`\n    const msg2 = `阶段2：成功 ${ok2}，跳过 ${skip2}，失败 ${fail2}`\n\n    if (fail1 === 0 && fail2 === 0) {\n        // 所有重命名成功，进入导出\n        loading.setText('正在导出音频与字幕（阶段 3/3）...')\n\n        try {\n            let isExportSingleSubtitle = false\n            try {\n                await ElMessageBox.confirm(\n                    '是否额外导出所有的单条字幕？<br><span style=\"color:#999;\">（额外导出会增加音频导出时间，推荐选择“否”）</span>',\n                    '导出设置',\n                    {\n                        dangerouslyUseHTMLString: true, // 允许用 HTML 格式\n                        confirmButtonText: '是',\n                        cancelButtonText: '否',\n                        type: 'info',\n                        cancelButtonClass: 'el-button--danger'    // 「否」= 蓝色重点按钮\n                    }\n                )\n                // 用户点击了“是”\n                isExportSingleSubtitle = true\n            } catch {\n                // 用户点击了“否” 或者关闭\n                isExportSingleSubtitle = false\n            }\n            const expRes = await lineAPI.exportLines(activeChapterId.value, isExportSingleSubtitle)\n\n            const data = expRes?.data || {}\n            const resCode = expRes?.code\n            \n            // 检查后端返回的结果\n            if (resCode !== 200 || data.success === false) {\n                // 后端返回了失败信息\n                const errorMsg = data.message || expRes?.message || '未知错误'\n                const missingFiles = data.missing_files || []\n                \n                console.error('导出失败详情：', { errorMsg, missingFiles, data })\n                \n                loading.setText(`导出失败（阶段 3/3）：${errorMsg}`)\n                \n                // 如果有缺失文件列表，显示更详细的信息\n                if (missingFiles.length > 0) {\n                    const missingInfo = missingFiles.slice(0, 5).join('\\n') + \n                        (missingFiles.length > 5 ? `\\n...还有${missingFiles.length - 5}条` : '')\n                    ElMessage.error({\n                        message: `导出失败：${errorMsg}\\n${missingInfo}`,\n                        duration: 15000,\n                        dangerouslyUseHTMLString: true\n                    })\n                } else {\n                    ElMessage.error(`重命名成功，但导出失败：${errorMsg}`)\n                }\n                loading.close()\n                return\n            }\n            \n            // 导出成功\n            const audioOut = data.audio_path\n            const srtOut = data.subtitle_path\n            const exportedCount = data.exported_count || total\n            const totalCount = data.total_count || total\n\n            // 在 Loading 里展示导出结果摘要\n            loading.setText(\n                `导出完成（阶段 3/3）：\\n` +\n                `- 音频：${audioOut ? audioOut : '已导出'}\\n` +\n                `- 字幕：${srtOut ? srtOut : '已导出'}\\n` +\n                `${msg1}；${msg2}`\n            )\n\n            // 友好提示\n            if (data.missing_files && data.missing_files.length > 0) {\n                ElMessage.warning(`导出完成（${exportedCount}/${totalCount}条），部分台词缺少音频`)\n            } else {\n                ElMessage.success(`全部完成（共 ${exportedCount} 条）。${msg1}；${msg2}；导出成功`)\n            }\n        } catch (e) {\n            console.error('导出失败：', e)\n            const errMsg = e?.response?.data?.message || e?.message || '未知错误'\n            loading.setText(`导出失败（阶段 3/3）：${errMsg}`)\n            ElMessage.error(`重命名成功，但导出失败：${errMsg}`)\n        } finally {\n            loading.close()\n        }\n    } else {\n        // 有失败就不做导出\n        loading.close()\n        ElMessage.warning(`部分失败。${msg1}；${msg2}（详见控制台）`)\n    }\n\n    // —— 自动打开输出文件夹 —— //\n    // 若导出接口返回了目录，可优先打开导出目录；否则仍按原逻辑打开第一条音频所在目录\n    try {\n        const firstLineWithAudio = lines.value[0]\n        const folderPath = firstLineWithAudio ? getFolderFromPath(firstLineWithAudio.audio_path) : ''\n        if (native?.openFolder && folderPath) {\n            native.openFolder(folderPath)\n        }\n    } catch { }\n}\n\n\n\nfunction playVoice(voiceId) {\n    if (!voiceId) return\n    const voice = voicesOptions.value.find(v => v.id === voiceId)\n    if (!voice || !voice.reference_path) {\n        ElMessage.warning('该音色未设置参考音频')\n        return\n    }\n\n    try {\n        const src = native?.pathToFileUrl ? native.pathToFileUrl(voice.reference_path) : voice.reference_path\n        audioPlayer.pause()\n        audioPlayer.src = src\n        audioPlayer.currentTime = 0\n        audioPlayer.play().catch(() => ElMessage.error('无法播放参考音频'))\n    } catch {\n        ElMessage.error('无法播放参考音频')\n    }\n}\n\n// 音频处理\nimport WaveCellPro from '../components/WaveCellPro.vue'\nimport { fa } from 'element-plus/es/locales.mjs'\n// 行音频版本号：lineId -> number\nconst audioVer = ref(new Map())\n\nconst getVer = (id) => audioVer.value.get(id) || 0\nconst bumpVer = (id) => audioVer.value.set(id, getVer(id) + 1)\n\n// 生成给 WaveCellPro 用的 key（强制重建）与 src（带 ?v= 反缓存）\nfunction waveKey(row) {\n    return `${row.id}-${getVer(row.id)}`\n}\nfunction waveSrc(row) {\n    if (!row.audio_path) return ''\n    const base = native?.pathToFileUrl ? native.pathToFileUrl(row.audio_path) : row.audio_path\n    const v = getVer(row.id)\n    return v ? `${base}${base.includes('?') ? '&' : '?'}v=${v}` : base\n}\n\n// 全局单实例播放（同页只允许一条在播）\n// 从 Set 换成 Map\nconst waveHandleMap = new Map()\n\nfunction registerWave({ handle, id }) {\n    if (handle && id) {\n        console.log('registerWave', id, handle)\n        waveHandleMap.set(id, handle)   // 直接覆盖\n    }\n}\n\nfunction unregisterWave({ handle, id }) {\n    console.log('unregisterWave', id)\n    if (id && waveHandleMap.has(id)) {\n        try { waveHandleMap.get(id)?.pause?.() } catch { }\n        waveHandleMap.delete(id)\n    }\n}\n\nfunction stopOthers(exceptHandle) {\n    waveHandleMap.forEach((h, id) => {\n        if (h && h !== exceptHandle) {\n            try { h.pause?.() } catch { }\n        }\n    })\n}\n\n\n// 确认后真正处理\nasync function confirmAndProcess(row, payload) {\n    // payload: {speed, volume, start_ms, end_ms}\n    const body = {\n        speed: Number(payload.speed || row._procSpeed || 1.0),\n        volume: Number(payload.volume || row._procVolume || 1.0),\n        start_ms: payload.start_ms ?? null,\n        end_ms: payload.end_ms ?? null,\n        silence_sec: Number(payload.silence_sec || 0),\n        current_ms: payload.current_ms ?? null\n    }\n    // 添加校验逻辑，裁剪和指定位置添加静音不能同时进行\n    // 1️⃣ 裁剪区间和“指定位置插入静音”不能同时存在\n    const hasCut = body.start_ms !== null && body.end_ms !== null && body.end_ms > body.start_ms\n    const hasInsertSilence = body.current_ms !== null && body.silence_sec !== 0\n\n    if (hasCut && hasInsertSilence) {\n        ElMessage.warning('❌ 裁剪区间与指定位置添加静音不能同时使用')\n        return\n    }\n    console.log('confirmAndProcess', row.id, body)\n    const res = await lineAPI.processAudio(row.id, body)\n    if (res?.code === 200) {\n        ElMessage.success('后端处理完成')\n        // ✅ 关键：递增该行版本号 → WaveCellPro 的 :key 和 :src 都会变化 → 强制重载最新音频\n        bumpVer(row.id)\n        //await loadLines()                 // 刷新拿新路径\n        // ✅ 重置完成状态\n        if (row.is_done !== 0) {\n            row.is_done = 0\n            // console.log(`台词 #${row.id} 音频处理后，状态重置为未完成`)\n            await updateLineIsDone(row, 0)\n        }\n\n    } else {\n        ElMessage.error(res?.message || '处理失败')\n    }\n}\n\n\n\n// 枚举下拉\nconst emotionOptions = ref([])\nconst strengthOptions = ref([])\n\nasync function loadEnums() {\n    const [emos, strengths] = await Promise.all([\n        enumAPI.fetchAllEmotions(),\n        enumAPI.fetchAllStrengths()\n    ])\n    emotionOptions.value = (emos || []).map(e => ({ value: e.id, label: e.name }))\n    strengthOptions.value = (strengths || []).map(s => ({ value: s.id, label: s.name }))\n}\n\n// 更新情绪\nasync function updateLineEmotion(row) {\n    if (!row?.id) return\n    const res = await lineAPI.updateLine(row.id, {\n        chapter_id: row.chapter_id,\n        emotion_id: row.emotion_id,\n    })\n    if (res?.code === 200) {\n        ElMessage.success('情绪已更新')\n    } else {\n        ElMessage.error(res?.message || '情绪更新失败')\n    }\n}\n\n// 更新强度\nasync function updateLineStrength(row) {\n    if (!row?.id) return\n    const res = await lineAPI.updateLine(row.id, {\n        chapter_id: row.chapter_id,\n        strength_id: row.strength_id,\n    })\n    if (res?.code === 200) {\n        ElMessage.success('强度已更新')\n    } else {\n        ElMessage.error(res?.message || '强度更新失败')\n    }\n}\n\nonMounted(() => {\n    loadEnums()\n})\nconst dialogSelectVoice = ref({\n    visible: false,\n    role: null,  // 当前操作的角色\n})\n\n// 打开弹窗\nfunction openVoiceDialog(role) {\n    dialogSelectVoice.value.visible = true\n    dialogSelectVoice.value.role = role\n}\n\n// 试听\nfunction playVoice2(voiceId) {\n    const voice = voicesOptions.value.find(v => v.id === voiceId)\n    if (!voice?.reference_path) return ElMessage.warning('该音色无参考音频')\n    try {\n        const src = native?.pathToFileUrl ? native.pathToFileUrl(voice.reference_path) : voice.reference_path\n        audioPlayer.pause()\n        audioPlayer.src = src\n        audioPlayer.currentTime = 0\n        audioPlayer.play().catch(() => ElMessage.error('无法播放音频'))\n    } catch {\n        ElMessage.error('无法播放音频')\n    }\n}\n\n// 确认绑定\nasync function confirmSelectVoice(voice) {\n    const role = dialogSelectVoice.value.role\n    if (!role) return\n    roleVoiceMap.value[role.id] = voice.id\n\n    // 更新到后端\n    const payload = {\n        name: role.name,\n        project_id: role.project_id,\n        default_voice_id: voice.id,\n    }\n    const res = await roleAPI.updateRole(role.id, payload)\n    if (res?.code === 200) {\n        ElMessage.success(`已为「${role.name}」绑定音色「${voice.name}」`)\n        dialogSelectVoice.value.visible = false\n    } else {\n        ElMessage.error(res?.message || '绑定失败')\n    }\n}\nconst filterTags = ref([])\n\n// 所有标签集合（从 voicesOptions 提取）\nconst allTags = computed(() => {\n    const set = new Set()\n    voicesOptions.value.forEach(v => {\n        (v.description ? v.description.split(',') : []).forEach(tag => {\n            if (tag.trim()) set.add(tag.trim())\n        })\n    })\n    return Array.from(set)\n})\n\n// 按标签筛选\nconst searchName = ref('') // 新增\n\nconst filteredVoices = computed(() => {\n    return voicesOptions.value.filter(v => {\n        // 先处理名字匹配\n        const matchName = !searchName.value || v.name.includes(searchName.value)\n\n        // 再处理标签匹配\n        if (!filterTags.value.length) return matchName\n        const tags = v.description ? v.description.split(',') : []\n        const matchTags = filterTags.value.every(ft => tags.includes(ft))\n\n        return matchName && matchTags\n    })\n})\n\nconst filterSelectRef = ref(null)\n\nfunction handleTagChange() {\n    // 等下一个 tick 再关闭，不然选中状态可能丢失\n    setTimeout(() => {\n        filterSelectRef.value.blur()\n    }, 0)\n}\nfunction cellStyle({ row, column }) {\n    // 角色列无数据\n    if (column.property === 'role_id' && !row.role_id) {\n        return { backgroundColor: '#ffecec', color: '#d93025' }\n    }\n\n    // 台词文本列无数据\n    if (column.label === '台词文本' && (!row.text_content || !row.text_content.trim())) {\n        return { backgroundColor: '#ffecec', color: '#d93025' }\n    }\n\n    // 情绪列无数据\n    if (column.label === '情绪' && !row.emotion_id) {\n        return { backgroundColor: '#ffecec', color: '#d93025' }\n    }\n\n    // 强度列无数据\n    if (column.label === '强度' && !row.strength_id) {\n        return { backgroundColor: '#ffecec', color: '#d93025' }\n    }\n\n    return {}\n}\n\n// 处理矫正下拉菜单命令\nasync function handleCorrectCommand(command) {\n    if (command === 'llm') {\n        await handleCorrectSubtitles(true)\n    } else {\n        await handleCorrectSubtitles(false)\n    }\n}\n\nasync function handleCorrectSubtitles(useLLM = false) {\n    // 打开等待窗口\n    const loading = ElLoading.service({\n        lock: true,\n        text: useLLM ? '正在使用LLM矫正字幕，请稍候...' : '正在矫正字幕，请稍候...',\n        background: 'rgba(0, 0, 0, 0.5)'\n    })\n\n    try {\n        let res\n        if (useLLM) {\n            // 使用LLM矫正（后端自动从项目配置获取LLM信息）\n            res = await lineAPI.correctLinesByLLM(activeChapterId.value)\n        } else {\n            // 使用拼音匹配矫正\n            res = await lineAPI.correctLinesByPinyin(activeChapterId.value)\n        }\n        if (res?.code !== 200) {\n            ElMessage.error(res?.message || '请先导出音频与字幕')\n        }\n        else {\n            ElMessage.success('字幕已矫正完成')\n            // —— 自动打开输出文件夹 —— //\n            // 若导出接口返回了目录，可优先打开导出目录；否则仍按原逻辑打开第一条音频所在目录\n            try {\n                const firstLineWithAudio = lines.value[0]\n                const folderPath = firstLineWithAudio ? getFolderFromPath(firstLineWithAudio.audio_path) : ''\n                if (native?.openFolder && folderPath) {\n                    native.openFolder(folderPath)\n                }\n            } catch { }\n        }\n        // TODO: 刷新数据\n    } catch (err) {\n        console.error('字幕矫正错误详情：', err)\n        ElMessage.error(`字幕矫正失败：${err.message || err}`)\n    } finally {\n        // 关闭等待窗口\n        loading.close()\n    }\n}\n\nasync function batchAddTailSilence() {\n    if (!lines.value.length) {\n        return ElMessage.info('当前无台词');\n    }\n\n    try {\n        const { value } = await ElMessageBox.prompt(\n            '请输入末尾静音时长（秒）(建议不要超过0.6秒)（可为负数，负数表示裁剪）',\n            '批量处理间隔时间',\n            {\n                confirmButtonText: '确定',\n                cancelButtonText: '取消',\n                // 支持整数、小数、负数\n                inputPattern: /^-?\\d+(\\.\\d+)?$/,\n                inputErrorMessage: '请输入合法数字（可为负数）',\n            }\n        );\n        const tailSec = Number(value);\n\n        const loading = ElLoading.service({\n            lock: true,\n            text: '正在批量处理音频...',\n            background: 'rgba(0,0,0,0.3)',\n        });\n\n        let ok = 0, fail = 0, skip = 0;\n\n        for (const row of lines.value) {\n            if (!row.audio_path) {\n                skip++;\n                continue;\n            }\n            try {\n                const res = await lineAPI.processAudio(row.id, {\n                    speed: row._procSpeed || 1.0,\n                    volume: row._procVolume || 1.0,\n                    start_ms: null,\n                    end_ms: null,\n                    silence_sec: tailSec, // 可正可负\n                    current_ms: null\n                });\n                if (res?.code === 200) {\n                    bumpVer(row.id); // 强制刷新 WaveCellPro\n                    ok++;\n                } else {\n                    fail++;\n                }\n            } catch {\n                fail++;\n            }\n        }\n\n        loading.close();\n        ElMessage.success(`批量完成：成功 ${ok} 条，跳过 ${skip} 条，失败 ${fail} 条`);\n    } catch {\n        // 用户取消输入\n    }\n}\n\nasync function batchProcessSpeed() {\n    const list = displayedLines.value\n    if (!list.length) return ElMessage.info('当前无台词')\n\n    try {\n        const { value } = await ElMessageBox.prompt(\n            '请输入速度倍率（0.5 ~ 2.0）',\n            '批量改变速度',\n            {\n                confirmButtonText: '确定',\n                cancelButtonText: '取消',\n                inputPattern: /^\\d+(\\.\\d+)?$/,\n                inputErrorMessage: '请输入合法数字',\n            }\n        )\n\n        const speed = Number(value)\n        if (!Number.isFinite(speed) || speed < 0.5 || speed > 2.0) {\n            return ElMessage.warning('速度倍率范围应为 0.5 ~ 2.0')\n        }\n\n        const loading = ElLoading.service({\n            lock: true,\n            text: '正在批量处理音频...',\n            background: 'rgba(0,0,0,0.3)',\n        })\n\n        let ok = 0, fail = 0, skip = 0\n        for (const row of list) {\n            if (!row.audio_path) {\n                skip++\n                continue\n            }\n\n            const startMs = Number.isFinite(Number(row.start_ms)) ? Number(row.start_ms) : null\n            const endMs = Number.isFinite(Number(row.end_ms)) ? Number(row.end_ms) : null\n            const useCut = startMs != null && endMs != null && endMs > startMs\n\n            try {\n                const res = await lineAPI.processAudio(row.id, {\n                    speed,\n                    volume: Number(row._procVolume || 1.0),\n                    start_ms: useCut ? startMs : null,\n                    end_ms: useCut ? endMs : null,\n                    silence_sec: 0,\n                    current_ms: null\n                })\n                if (res?.code === 200) {\n                    // row._procSpeed = speed\n                    bumpVer(row.id)\n                    ok++\n                    if (row.is_done !== 0) {\n                        row.is_done = 0\n                        await updateLineIsDone(row, 0)\n                    }\n                } else {\n                    fail++\n                }\n            } catch {\n                fail++\n            }\n        }\n\n        loading.close()\n        ElMessage.success(`批量完成：成功 ${ok} 条，跳过 ${skip} 条，失败 ${fail} 条`)\n    } catch {\n    }\n}\n\nasync function batchProcessVolume() {\n    const list = displayedLines.value\n    if (!list.length) return ElMessage.info('当前无台词')\n\n    try {\n        const { value } = await ElMessageBox.prompt(\n            '请输入音量倍率（0.0 ~ 2.0）',\n            '批量调整音量',\n            {\n                confirmButtonText: '确定',\n                cancelButtonText: '取消',\n                inputPattern: /^\\d+(\\.\\d+)?$/,\n                inputErrorMessage: '请输入合法数字',\n            }\n        )\n\n        const volume = Number(value)\n        if (!Number.isFinite(volume) || volume < 0 || volume > 2.0) {\n            return ElMessage.warning('音量倍率范围应为 0.0 ~ 2.0')\n        }\n\n        const loading = ElLoading.service({\n            lock: true,\n            text: '正在批量处理音频...',\n            background: 'rgba(0,0,0,0.3)',\n        })\n\n        let ok = 0, fail = 0, skip = 0\n        for (const row of list) {\n            if (!row.audio_path) {\n                skip++\n                continue\n            }\n\n            const startMs = Number.isFinite(Number(row.start_ms)) ? Number(row.start_ms) : null\n            const endMs = Number.isFinite(Number(row.end_ms)) ? Number(row.end_ms) : null\n            const useCut = startMs != null && endMs != null && endMs > startMs\n\n            try {\n                const res = await lineAPI.processAudio(row.id, {\n                    speed: Number(row._procSpeed || 1.0),\n                    volume,\n                    start_ms: useCut ? startMs : null,\n                    end_ms: useCut ? endMs : null,\n                    silence_sec: 0,\n                    current_ms: null\n                })\n                if (res?.code === 200) {\n                    // row._procVolume = volume\n                    bumpVer(row.id)\n                    ok++\n                    if (row.is_done !== 0) {\n                        row.is_done = 0\n                        await updateLineIsDone(row, 0)\n                    }\n                } else {\n                    fail++\n                }\n            } catch {\n                fail++\n            }\n        }\n\n        loading.close()\n        ElMessage.success(`批量完成：成功 ${ok} 条，跳过 ${skip} 条，失败 ${fail} 条`)\n    } catch {\n    }\n}\n// const playMode = ref('sequential') // 'single' = 单条, 'sequential' = 顺序\nconst playMode = ref('sequential')\ntry { playMode.value = localStorage.getItem('playMode') || 'sequential' } catch { }\n\nconst completionSoundEnabled = ref(false)\ntry {\n    const raw = localStorage.getItem('completionSoundEnabled')\n    completionSoundEnabled.value = raw === '1' || raw === 'true'\n} catch { }\n\n// 监听 playMode 变化并存储到本地\nwatch(playMode, (val) => {\n    try { localStorage.setItem('playMode', val) } catch { }\n})\nwatch(completionSoundEnabled, (val) => {\n    try { localStorage.setItem('completionSoundEnabled', val ? '1' : '0') } catch { }\n})\n// 处理 ended 事件\nfunction handleEnded({ handle, id }) {\n    console.log('handleEnded', id, playMode.value)\n    if (playMode.value !== 'sequential') return\n\n    // 拿到当前行列表（确保按 line_order 排序）\n    const list = [...displayedLines.value].sort((a, b) => a.line_order - b.line_order)\n    const idx = list.findIndex(l => l.id === id)\n    console.log('当前行索引', idx, '，总行数', list.length)\n    if (idx === -1 || idx === list.length - 1) return // 找不到或最后一条\n\n    if (idx === -1) {\n        console.warn('handleEnded: 未找到当前行，终止顺序播放')\n        return\n    }\n    if (idx === list.length - 1) {\n        console.log('handleEnded: 已是最后一行，顺序播放结束')\n        return\n    }\n\n    // 向后查找下一个有音频的行\n    let nextRow = null\n    for (let i = idx + 1; i < list.length; i++) {\n        if (list[i].audio_path) {\n            nextRow = list[i]\n            break\n        }\n    }\n\n    if (!nextRow) {\n        console.log('handleEnded: 后续没有可播放的音频，顺序播放结束')\n        return\n    }\n\n    // 找到下一行对应的 WaveCellPro 实例\n    console.log('下一行 ID:', nextRow.id)\n\n\n    const nextHandle = waveHandleMap.get(nextRow.id)\n\n    if (!nextHandle) {\n        console.warn('handleEnded: 未找到下一行的 WaveCellPro 实例，行ID:', nextRow.id)\n        return\n    }\n\n    if (nextHandle?.play) {\n        console.log('handleEnded: 播放下一行 => ID:', nextRow.id)\n        stopOthers(nextHandle) // 停止其他行\n        nextHandle.play()\n    } else {\n        console.warn('handleEnded: 下一行实例没有 play 方法 => ID:', nextRow.id)\n    }\n}\n// =============== ElTableV2 列配置 ===============\n// ✅ 通用高亮包装函数（放在 <script setup> 顶部或表格定义前）\nconst statusFilter = ref('')\nconst wrapCellHighlight = (condition, children) => {\n    return h(\n        'div',\n        {\n            style: {\n                width: '100%',\n                height: '100%',\n                backgroundColor: condition ? '#fde2e2' : 'transparent',\n                display: 'flex',\n                alignItems: 'center',\n                justifyContent: 'center',\n                flexDirection: 'column',\n                gap: '4px',\n                boxSizing: 'border-box',\n                padding: '4px',\n                borderRadius: '4px',\n                transition: 'background-color 0.3s ease',\n            },\n        },\n        children\n    )\n}\nimport { reactive } from 'vue'\nconst lineColumns = reactive([\n    {\n        key: 'line_order',\n        title: '序',\n        width: 60,\n        minWidth: 40,\n        maxWidth: 60,\n        align: 'center',\n        cellRenderer: ({ rowData }) => rowData.line_order,\n    },\n    {\n        key: 'role_id',\n        title: '角色',\n        width: 100,\n        minWidth: 50,\n        maxWidth: 150,\n        align: 'center',\n        cellRenderer: ({ rowData }) =>\n            wrapCellHighlight(!rowData.role_id, [\n                h(\n                    ElSelect,\n                    {\n                        modelValue: rowData.role_id,\n                        filterable: true,\n                        clearable: true,\n                        size: 'small',\n                        disabled: roleColumnLocked.value,\n                        placeholder: '选择角色',\n                        style: { width: '100%' },\n                        onChange: (val) => {\n                            rowData.role_id = val\n                            updateLineRole(rowData)\n                            // 角色切换后，变更状态为未完成\n                            // 2️⃣ 切换角色后自动置为未完成\n                            if (rowData.is_done !== 0) {\n\n                                // 3️⃣ 同步更新后端状态\n                                updateLineIsDone(rowData, 0)\n                                rowData.is_done = 0\n                            }\n                        },\n                    },\n                    () => roles.value.map((r) =>\n                        h(ElOption, { label: r.name, value: r.id })\n                    )\n                ),\n                h(\n                    ElTag,\n                    {\n                        size: 'small',\n                        type: getRoleVoiceName(rowData.role_id)\n                            ? 'success'\n                            : 'info',\n                    },\n                    () => getRoleVoiceName(rowData.role_id) || '未绑定音色'\n                ),\n            ]),\n    },\n    {\n        key: 'text_content',\n        title: '台词文本',\n        width: 250,\n        minWidth: 100,\n        maxWidth: 300,\n        align: 'center',\n        cellRenderer: ({ rowData }) =>\n            wrapCellHighlight(\n                !(rowData.tempText?.trim() || rowData.text_content?.trim()),\n                [\n                    h(ElInput, {\n                        modelValue: rowData.tempText ?? rowData.text_content,\n                        'onUpdate:modelValue': (val) => (rowData.tempText = val),\n                        size: 'small',\n                        type: 'textarea',\n                        autosize: { minRows: 2, maxRows: 9 }, // ✅ 只用 autosize 控高\n                        placeholder: '输入台词内容',\n                        disabled: textLocked.value,\n\n                        onBlur: () => updateLineText(rowData),\n\n                    }),\n                ]\n            ),\n    }\n    ,\n    {\n        key: 'emotion_id',\n        title: '情绪',\n        width: 120,\n        minWidth: 80,\n        maxWidth: 150,\n        align: 'center',\n        cellRenderer: ({ rowData }) =>\n            wrapCellHighlight(!rowData.emotion_id, [\n                h(\n                    ElSelect,\n                    {\n                        modelValue: rowData.emotion_id,\n                        size: 'small',\n                        placeholder: '选择情绪',\n                        disabled: emotionLocked.value,\n                        clearable: true,\n                        style: { width: '100%' },\n                        onChange: (val) => {\n                            rowData.emotion_id = val\n                            updateLineEmotion(rowData)\n                            if (rowData.is_done !== 0) {\n\n                                // 3️⃣ 同步更新后端状态\n                                updateLineIsDone(rowData, 0)\n                                rowData.is_done = 0\n                            }\n                        },\n                    },\n                    () =>\n                        emotionOptions.value.map((e) =>\n                            h(ElOption, { label: e.label, value: e.value })\n                        )\n                ),\n            ]),\n    },\n    {\n        key: 'strength_id',\n        title: '强度',\n        width: 120,\n        minWidth: 80,\n        maxWidth: 150,\n        align: 'center',\n        cellRenderer: ({ rowData }) =>\n            wrapCellHighlight(!rowData.strength_id, [\n                h(\n                    ElSelect,\n                    {\n                        modelValue: rowData.strength_id,\n                        size: 'small',\n                        placeholder: '选择强度',\n                        disabled: strengthLocked.value,\n                        clearable: true,\n                        style: { width: '100%' },\n                        onChange: (val) => {\n                            rowData.strength_id = val\n                            updateLineStrength(rowData)\n                            if (rowData.is_done !== 0) {\n                                rowData.is_done = 0\n                                // 3️⃣ 同步更新后端状态\n                                updateLineIsDone(rowData, 0)\n                            }\n                        },\n                    },\n                    () =>\n                        strengthOptions.value.map((s) =>\n                            h(ElOption, { label: s.label, value: s.value })\n                        )\n                ),\n            ]),\n    },\n    {\n        key: 'audio',\n        title: '试听 / 处理',\n        align: 'center',\n        width: 500,\n        minWidth: 300,\n        maxWidth: 500,\n        cellRenderer: ({ rowData }) =>\n            h('div', {\n                style: {\n                    width: '100%',\n                    height: '100%',           // ✅ 填满整行\n                    display: 'flex',          // ✅ 居中显示\n                    alignItems: 'center',\n                    justifyContent: 'center',\n                },\n            }, [\n                rowData.audio_path\n                    ? h(WaveCellPro, {\n                        key: waveKey(rowData),\n                        src: waveSrc(rowData),\n                        speed: rowData._procSpeed || 1.0,\n                        volume2x: rowData._procVolume ?? 1.0,\n                        'start-ms': rowData.start_ms,\n                        'end-ms': rowData.end_ms,\n                        style: {\n\n                            maxHeight: '100%',   // ✅ 防止溢出\n                            objectFit: 'contain',\n                        },\n                        onReady: (p) => registerWave({ handle: p, id: rowData.id }),\n                        onRequestStopOthers: stopOthers,\n                        onDispose: unregisterWave,\n                        onConfirm: (p) => confirmAndProcess(rowData, p),\n                        onEnded: (p) => handleEnded({ p, id: rowData.id }),\n                    })\n                    : h(ElText, { type: 'info' }, () => '无音频'),\n            ]),\n    },\n\n    {\n        key: 'edit',\n        title: '操作',\n        width: 150,\n        minWidth: 100,\n        maxWidth: 200,\n        align: 'center',\n        headerCellRenderer: () =>\n            h(\n                ElButton,\n                { size: 'small', type: 'success', plain: true, onClick: insertAtTop },\n                () => '首行插入'\n            ),\n        cellRenderer: ({ rowData }) =>\n            h('div', { style: 'display:flex;justify-content:center;gap:4px;' }, [\n                h(\n                    ElButton,\n                    {\n                        size: 'small',\n                        type: 'primary',\n                        plain: true,\n                        onClick: () => insertBelow(rowData),\n                    },\n                    () => '插入'\n                ),\n                h(\n                    ElPopconfirm,\n                    {\n                        title: '确认删除该台词？',\n                        onConfirm: () => deleteLine(rowData),\n                    },\n                    {\n                        reference: () =>\n                            h(\n                                ElButton,\n                                { size: 'small', type: 'danger', plain: true },\n                                () => '删除'\n                            ),\n                    }\n                ),\n            ]),\n    },\n    {\n        key: 'status',\n        title: '状态',\n        width: 100,\n        minWidth: 100,\n        maxWidth: 150,\n        align: 'center',\n        fixed: 'right',\n        // ✅ 自定义表头，包含“状态”文字 + 下拉框\n        headerCellRenderer: () =>\n            h(\n                'div',\n                { class: 'status-header' },\n                [\n                    // 左侧文字标签\n                    h('span', { class: 'status-title' }, '状态'),\n\n                    // 状态筛选下拉框\n                    h(\n                        ElSelect,\n                        {\n                            modelValue: statusFilter.value,\n                            placeholder: '全部',\n                            clearable: true,\n                            size: 'small',\n                            class: 'status-select',\n                            onChange: (val) => (statusFilter.value = val),\n                        },\n                        () => [\n                            h(ElOption, { label: '全部', value: '' }),\n                            h(ElOption, { label: '未生成', value: 'pending' }),\n                            h(ElOption, { label: '生成中', value: 'processing' }),\n                            h(ElOption, { label: '已生成', value: 'done' }),\n                            h(ElOption, { label: '生成失败', value: 'failed' }),\n                        ]\n                    ),\n                ]\n            ),\n\n        cellRenderer: ({ rowData }) =>\n            h(ElTag, { type: statusType(rowData.status) }, () =>\n                statusText(rowData.status)\n            ),\n    },\n    {\n        key: 'actions',\n        title: '操作',\n        width: 100,\n        align: 'center',\n        fixed: 'right',\n        cellRenderer: ({ rowData }) => {\n            return h(\n                'div',\n                {\n                    style: `\n          display: flex;\n          flex-direction: column;\n          align-items: center;\n          justify-content: center;\n        `,\n                },\n                [// 🎧 “生成配音”按钮\n                    h(\n                        ElButton,\n                        {\n                            size: 'small',\n                            type: 'primary',\n                            disabled: !canGenerate(rowData),\n                            onClick: () => generateOne(rowData),\n                        },\n                        () => '生成配音'\n                    ),\n                    // ✅ 绿色的 is_done 开关\n                    h(ElSwitch, {\n                        modelValue: rowData.is_done === 1 ? 'done' : 'undone',\n                        activeText: '已完成',\n                        inactiveText: '未完成',\n                        activeValue: 'done',\n                        inactiveValue: 'undone',\n                        inlinePrompt: true,\n                        size: 'small',\n                        style: {\n                            '--el-switch-on-color': '#67C23A',  // ✅ 激活时绿色\n                            '--el-switch-off-color': '#dcdfe6', // ✅ 未激活灰色\n                        },\n                        'onUpdate:modelValue': (val) => {\n                            const newVal = val === 'done' ? 1 : 0\n                            if (rowData.is_done === newVal) return\n                            rowData.is_done = newVal\n                            console.log('切换台词完成状态:', rowData.is_done)\n                            updateLineIsDone(rowData, newVal)\n                        },\n                    }),\n\n\n                ]\n            )\n        },\n    }\n\n\n\n])\n\n\n// 1) 如果还不是 reactive，先改成 reactive 数组：\n// import 里确保有 reactive / h\n// import { reactive, h } from 'vue'\n\n// 假设你原来是：const lineColumns = [ ... ]\n// 改成：\n// const lineColumns = reactive([ ... ])   // ✅ 让列对象可响应\n\n// 2) 给表头加一个“拖拽手柄”，拖动时修改对应列的 width\nfunction attachResizableHeader(col) {\n    const min = col.minWidth ?? 80\n    const max = col.maxWidth ?? Infinity\n    const origHeader = col.headerCellRenderer\n\n    col.headerCellRenderer = () => h(\n        'div',\n        { class: 'resizable-header' },\n        [\n            // 原表头内容保留（有就用，没有就显示标题）\n            origHeader ? origHeader() : h('span', col.title),\n            // 右侧拖拽手柄\n            h('span', {\n                class: 'resize-handle',\n                onMousedown: (e) => {\n                    const startX = e.clientX\n                    const startW = Number(col.width ?? min)\n\n                    const onMove = (ev) => {\n                        const delta = ev.clientX - startX\n                        const next = Math.min(max, Math.max(min, startW + delta))\n                        col.width = next  // ✅ 动态改列宽\n                    }\n                    const onUp = () => {\n                        window.removeEventListener('mousemove', onMove)\n                        window.removeEventListener('mouseup', onUp)\n                    }\n                    window.addEventListener('mousemove', onMove)\n                    window.addEventListener('mouseup', onUp)\n                }\n            })\n        ]\n    )\n}\n\n// 3) 指定哪些列可拖（按你的 key 来）\n;[\n    'role_id', 'text_content', 'emotion_id', 'strength_id',\n    'audio', 'edit', 'status', 'actions'\n].forEach(k => {\n    const c = lineColumns.find(col => col.key === k)\n    if (c) {\n        c.resizable = true\n        attachResizableHeader(c)\n    }\n})\n\nasync function updateLineIsDone(row, val) {\n    // ✅ 修正判断逻辑\n    if (!row || !row.id) return\n\n    try {\n        const res = await lineAPI.updateLine(row.id, {\n            chapter_id: row.chapter_id,\n            is_done: val,\n        })\n\n        if (res?.code === 200) {\n            ElMessage.success('台词完成度已更新')\n        } else {\n            ElMessage.error(res?.message || '台词完成度更新失败')\n        }\n    } catch (err) {\n        console.error('更新台词完成度出错:', err)\n        ElMessage.error('请求异常，请稍后重试')\n    }\n}\n\nimport { decodeUtf8OrGbk } from \"../utils/utf8-or-gbk.js\";\nasync function handleBatchImport() {\n    let loadingInstance = null\n    try {\n        // 1️⃣ 弹出确认框\n        await ElMessageBox.confirm(\n            '已存在的章节名不会重复导入，只会导入新的章节！',\n            '批量导入章节',\n            {\n                confirmButtonText: '确定',\n                cancelButtonText: '取消',\n                type: 'warning'\n            }\n        )\n\n\n        // 3️⃣ 打开文件选择框\n        const pickerResult = await window.showOpenFilePicker({\n            types: [{ description: '文本文件', accept: { 'text/plain': ['.txt'] } }],\n            excludeAcceptAllOption: true,\n            multiple: false,\n        }).catch(() => null)\n\n        if (!pickerResult || pickerResult.length === 0) {\n            ElMessage.info('已取消选择文件')\n            return\n        }\n\n        const [fileHandle] = pickerResult\n        const file = await fileHandle.getFile()\n        // ✅ 使用 TextDecoder 解决乱码\n        const arrayBuffer = await file.arrayBuffer()\n        // ✅ 仅 UTF-8 / GBK 自动识别\n        const { encoding, text } = decodeUtf8OrGbk(arrayBuffer);\n        console.log('TXT 文件内容:', text)\n        // 如果文件内容为空\n        if (!text.trim()) {\n            ElMessage.warning('TXT 文件为空，未执行导入')\n            return\n        }\n\n        // 4️⃣ 启动 loading 遮罩\n        loadingInstance = ElLoading.service({\n            lock: true,\n            text: '正在导入章节，请稍候...',\n            background: 'rgba(0, 0, 0, 0.4)',\n        })\n\n        // 5️⃣ 调用后端导入接口\n        const res = await projectAPI.importChapters(projectId, {\n            id: projectId,\n            content: text,\n        })\n        if (res?.code === 200) {\n            // ✅ 批量导入成功，更新章节列表\n            ElMessage.success('TXT 文件已成功导入')\n\n            await loadChapters()\n        } else {\n            ElMessage.error(res?.message || 'TXT 文件导入失败')\n        }\n\n    } catch (err) {\n        console.error('❌ 操作取消或出错:', err)\n        if (err !== 'cancel') {\n            ElMessage.info('已取消导入')\n        }\n    } finally {\n        // 7️⃣ 无论成功或失败都关闭 loading\n        if (loadingInstance) {\n            loadingInstance.close()\n        }\n    }\n}\nimport { onBeforeUnmount } from \"vue\";\nconst treeHeight = ref(500);\nfunction updateTreeHeight() {\n    // 根据窗口大小或 aside 可视区动态调整\n    treeHeight.value = window.innerHeight - 230; // 减去头部、搜索框、padding等高度\n}\n\nonMounted(() => {\n    updateTreeHeight();\n    window.addEventListener(\"resize\", updateTreeHeight);\n});\nonBeforeUnmount(() => {\n    window.removeEventListener(\"resize\", updateTreeHeight);\n});\n\n\n// 记忆功能\n/**\n * 保存当前项目的最后打开章节\n */\nfunction saveLastChapter() {\n    const key = 'lastChapterMap';\n    const map = JSON.parse(localStorage.getItem(key) || '{}');\n    map[projectId] = activeChapterId.value;\n    console.log('保存最后章节', map);\n    localStorage.setItem(key, JSON.stringify(map));\n}\n\n/**\n * 进入项目时自动恢复上次章节\n */\n// 滚动到选中章节\nconst chapterTreeRef = ref(null)  // ✅ 获取 Tree 实例\nfunction scrollToActiveChapter() {\n\n    if (!chapterTreeRef.value || !activeChapterId.value) return\n    chapterTreeRef.value.scrollToNode(activeChapterId.value, 'center')\n\n}\nfunction restoreLastChapter() {\n    const key = 'lastChapterMap';\n    const map = JSON.parse(localStorage.getItem(key) || '{}');\n    const last = map[projectId];\n\n    console.log('恢复最后章节', map, last);\n    if (last && chapters.value.find(c => c.id === last)) {\n        // 只有当上次选择的章节仍然存在时才恢复\n        activeChapterId.value = last;\n    } else {\n        // 不自动选择章节，让用户手动选择\n        activeChapterId.value = null;\n    }\n    console.log('最终选中章节', activeChapterId.value);\n}\n\n\n</script>\n\n<style scoped>\n.filter-bar {\n    margin-bottom: 16px;\n    display: flex;\n    align-items: center;\n    gap: 12px;\n}\n\n.voice-selection-container {\n    padding: 4px;\n}\n\n.voice-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n    gap: 16px;\n    padding: 10px 4px;\n}\n\n.voice-card {\n    cursor: pointer;\n    display: flex;\n    flex-direction: column;\n    justify-content: space-between;\n    min-height: 130px;\n    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    border: 1px solid var(--el-border-color-lighter);\n    border-radius: 12px;\n    overflow: hidden;\n}\n\n.voice-card:hover {\n    transform: translateY(-4px);\n    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);\n    border-color: #409eff;\n}\n\n.voice-card-head {\n    padding: 12px 14px;\n    flex: 1;\n}\n\n.voice-title {\n    font-weight: 600;\n    font-size: 15px;\n    color: var(--el-text-color-primary);\n    margin-bottom: 8px;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n.voice-desc {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px;\n}\n\n.voice-actions {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 10px 14px;\n    background-color: var(--el-fill-color-light);\n    border-top: 1px solid var(--el-border-color-lighter);\n}\n\n\n.page-wrap {\n    display: flex;\n    flex-direction: column;\n\n    width: 100%;\n    /* 承接父级高度（若父级未设，可换成 min-height:100vh） */\n    min-height: 0;\n\n\n}\n\n.header {\n    display: flex;\n    height: auto;\n    width: 100%;\n    align-items: center;\n    justify-content: space-between;\n    margin-bottom: 12px;\n}\n\n.title-side {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.proj-title {\n    margin: 0 4px 0 8px;\n    font-size: 20px;\n    font-weight: 700;\n}\n\n.ml8 {\n    margin-left: 8px;\n}\n\n.action-side {\n    display: flex;\n    align-items: center;\n}\n\n.main {\n\n    border-radius: 12px;\n\n\n    /* ✅ 真正滚动层 */\n}\n\n.aside {\n    height: 92vh;\n    padding: 5px;\n    background: var(--el-bg-color);\n    /* border: 1px red solid; */\n    overflow: auto;\n    position: relative;\n    transition: width 0.3s ease;\n}\n\n.aside-collapsed {\n    padding: 0;\n    overflow: hidden;\n}\n\n/* 标题栏右侧按钮组 */\n.title-right {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n}\n\n/* 展开按钮 - 侧边栏折叠后显示 */\n.aside-expand-btn {\n    position: relative;\n    width: 20px;\n    height: 92vh;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n    background: var(--el-fill-color-lighter);\n    color: var(--el-text-color-secondary);\n    transition: all 0.2s;\n    flex-shrink: 0;\n}\n\n.aside-expand-btn:hover {\n    background: var(--el-color-primary-light-9);\n    color: var(--el-color-primary);\n}\n\n.aside-head {\n\n    flex-shrink: 0;\n    flex-direction: column;\n    padding: 10px 12px;\n    border-bottom: 1px solid var(--el-border-color-lighter);\n    background-color: var(--el-fill-color-light);\n    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n\n.aside-title {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    /* 左右分布：标题在左，按钮在右 */\n    padding: 8px 12px;\n    background-color: var(--el-bg-color);\n    border-bottom: 1px solid var(--el-border-color-lighter);\n    border-radius: 6px;\n    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);\n\n    font-weight: 600;\n    font-size: 15px;\n    color: var(--el-text-color-primary);\n    margin-bottom: 12px;\n    transition: background-color 0.3s ease;\n}\n\n.aside-title:hover {\n    background-color: var(--el-fill-color-light);\n    /* 悬停时柔和高亮 */\n}\n\n.aside-title .title-left {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.aside-title .el-icon {\n    font-size: 18px;\n    color: var(--el-text-color-regular);\n    transition: color 0.2s ease;\n}\n\n.aside-title:hover .el-icon {\n    color: var(--el-color-primary);\n}\n\n\n.aside-actions {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    /* ✅ 居中关键 */\n    flex-shrink: 0;\n    gap: 10px;\n    /* 按钮间距 */\n    margin: 10px 0;\n    padding: 8px 0;\n\n\n    border-top: 1px solid var(--el-border-color-lighter);\n}\n\n.aside-actions .el-button {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 6px;\n\n    font-size: 11px;\n    font-weight: 500;\n    transition: all 0.2s ease;\n}\n\n/* 轻微悬浮动画（增强触感） */\n.aside-actions .el-button:hover {\n    transform: translateY(-1px);\n    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);\n}\n\n\n.el-input.mb8 .el-input__wrapper {\n    border-radius: 8px;\n    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);\n}\n\n\n\n/* =============================\n   📘 章节菜单最终优化版\n   ============================= */\n\n/* =============================\n   📘 固定宽度两栏布局版本\n   ============================= */\n/* 树容器自动撑满剩余空间 */\n.tree-container {\n    flex: 1;\n\n    overflow: hidden;\n}\n\n.chapter-menu {\n    border-right: none;\n\n    --transition-fast: 0.18s ease;\n    --border-radius: 8px;\n\n}\n\n/* 每个章节项 */\n.chapter-item {\n    display: flex;\n    align-items: center;\n\n\n    transition: background-color var(--transition-fast), transform 0.1s ease;\n}\n\n\n/* 左侧标题区：固定宽度 */\n.chapter-title {\n    width: 160px;\n    /* ✅ 固定标题宽度 */\n    font-size: 15px;\n    color: var(--el-text-color-primary);\n\n\n    transition: color var(--transition-fast);\n}\n\n.chapter-item:hover .chapter-title {\n    color: var(--el-color-primary);\n}\n\n/* 选中 */\n.chapter-item.is-active .chapter-title {\n    color: var(--el-color-primary);\n    font-weight: 600;\n    /* ✅ 选中加粗 */\n}\n\n/* =============================\n   📘 操作区整体\n   ============================= */\n.chapter-ops {\n    width: 0;\n    /* ✅ 固定操作宽度 */\n    display: flex;\n    justify-content: flex-end;\n    align-items: center;\n    opacity: 0;\n    transition: opacity 0.25s ease;\n}\n\n/* 悬停或激活显示 */\n.chapter-item:hover .chapter-ops,\n.chapter-item.is-active .chapter-ops {\n    opacity: 1;\n}\n\n/* =============================\n   🎛 操作按钮样式（非透明版本）\n   ============================= */\n.op-btn {\n    padding: 3px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border-radius: 6px;\n    background-color: transparent;\n    transform: scale(1);\n    transition:\n        background-color 0.15s ease,\n        color 0.15s ease,\n        transform 0.15s ease;\n}\n\n/* 悬停放大 + 明亮底色 */\n.op-btn:hover {\n    background-color: var(--el-color-success-light-9);\n    /* ✅ 非透明浅蓝底 */\n    color: var(--el-color-primary);\n    transform: scale(1.12);\n}\n\n/* 删除按钮 */\n.del-btn {\n    color: var(--el-color-danger-light-5);\n    background-color: transparent;\n}\n\n/* 删除按钮 hover */\n.del-btn:hover {\n    background-color: var(--el-color-danger-light-9);\n    /* ✅ 非透明浅红底 */\n    color: var(--el-color-danger);\n    transform: scale(1.12);\n}\n\n/* =============================\n   🟦 选中状态下（非透明版）\n   ============================= */\n.chapter-item .op-btn {\n    background-color: var(--el-color-success-light-9);\n    /* ✅ 纯白底，非透明 */\n    box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;\n}\n\n.chapter-item .del-btn {\n    background-color: var(--el-color-danger-light-9);\n    /* ✅ 纯白微红底 */\n    box-shadow: 0 0 0 1px var(--el-color-danger-light-5) inset;\n}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n.content {\n\n    background: var(--el-bg-color);\n    padding: 5px;\n    display: flex;\n    flex-direction: column;\n    flex: 1 1 auto;\n    min-height: 0;\n    /* border: 1px red solid; */\n\n\n}\n\n\n.chapter-card {\n    flex: 0 0 auto;\n    margin-bottom: 1px;\n\n}\n\n.chapter-card-head {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    flex: 0 0 auto;\n    /* 关键：不要抢高度 */\n}\n\n.chapter-card-head .left {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.chapter-card-head .title {\n    font-size: 15px;\n    font-weight: 700;\n    white-space: nowrap;\n    /* 不允许文字换行 */\n\n}\n\n.chapter-card-head .right {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.chapter-content-box {\n    margin-top: 8px;\n}\n\n.chapter-scroll {\n    max-height: 220px;\n    overflow: auto;\n    /* 必须 */\n}\n\n.chapter-text {\n    white-space: pre-wrap;\n    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n    line-height: 1.6;\n    color: var(--el-text-color-primary);\n    padding: 8px 2px;\n}\n\n.el-tabs-box {\n\n    flex: 1 1 auto;\n    display: flex;\n    flex-direction: column;\n    min-width: 0;\n    min-height: 0;\n\n}\n\n\n/* 表格容器吃掉剩余高度 */\n.toolbar {\n    height: 56px;\n    display: flex;\n    align-items: center;\n    /* justify-content: space-between; Removed to keep left alignment for first two groups */\n    border-bottom: 1px solid var(--el-border-color-lighter);\n    background: var(--el-bg-color);\n    padding: 0 20px;\n    gap: 16px;\n}\n\n.toolbar-group {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n}\n\n.filter-item.w200 {\n    width: 130px;\n}\n\n.filter-item.w220 {\n    width: 130px;\n}\n\n.toolbar-divider {\n    height: 24px;\n    margin: 0 8px;\n    border-color: var(--el-border-color);\n}\n\n.switch-item {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    cursor: pointer;\n    padding: 4px 8px;\n    border-radius: 6px;\n    transition: background-color 0.2s;\n}\n\n.switch-item:hover {\n    background-color: var(--el-fill-color-light);\n}\n\n.switch-label {\n    font-size: 13px;\n    color: var(--el-text-color-regular);\n    user-select: none;\n}\n\n\n.table-box {\n    position: absolute;\n    top: 57px;\n    /* ✅ 跟 toolbar 高度一致 */\n    bottom: 0;\n    left: 0;\n    right: 0;\n\n}\n\n/* 台词列表横向滚动条加粗 */\n.table-box :deep(.el-vl__horizontal) {\n    height: 14px !important;\n}\n\n.table-box :deep(.el-vl__horizontal .el-scrollbar__thumb) {\n    height: 12px !important;\n    border-radius: 6px;\n    background-color: rgba(144, 147, 153, 0.5);\n}\n\n.table-box :deep(.el-vl__horizontal .el-scrollbar__thumb:hover) {\n    background-color: rgba(144, 147, 153, 0.7);\n}\n\n/* 台词列表垂直滚动条加粗 */\n.table-box :deep(.el-vl__vertical) {\n    width: 14px !important;\n}\n\n.table-box :deep(.el-vl__vertical .el-scrollbar__thumb) {\n    width: 12px !important;\n    border-radius: 6px;\n    background-color: rgba(144, 147, 153, 0.5);\n}\n\n.table-box :deep(.el-vl__vertical .el-scrollbar__thumb:hover) {\n    background-color: rgba(144, 147, 153, 0.7);\n}\n\n/* 兼容 webkit 滚动条样式 */\n.table-box :deep(::-webkit-scrollbar) {\n    width: 14px;\n    height: 14px;\n}\n\n.table-box :deep(::-webkit-scrollbar-thumb) {\n    background-color: rgba(144, 147, 153, 0.5);\n    border-radius: 7px;\n}\n\n.table-box :deep(::-webkit-scrollbar-thumb:hover) {\n    background-color: rgba(144, 147, 153, 0.7);\n}\n\n.table-box :deep(::-webkit-scrollbar-track) {\n    background-color: var(--el-fill-color-light);\n    border-radius: 7px;\n}\n\n.lines-table {\n    border-radius: 10px;\n}\n\n.role-cell {\n    display: flex;\n    align-items: center;\n}\n\n.role-name {\n    font-weight: 600;\n    line-height: 1.2;\n}\n\n.role-voice {\n    font-size: 12px;\n    color: var(--el-text-color-regular);\n}\n\n.role-grid {\n    flex: 1;\n    overflow-y: auto;\n    padding: 12px;\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));\n    gap: 16px;\n    box-sizing: border-box;\n    min-height: 0;\n}\n\n.role-card {\n    border-radius: 12px;\n    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    border: 1px solid var(--el-border-color-lighter);\n}\n\n.role-card:hover {\n    transform: translateY(-4px);\n    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);\n    border-color: #409eff;\n}\n\n.role-card .card-header {\n    padding: 12px 14px;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    border-bottom: 1px solid var(--el-border-color-lighter);\n    background-color: var(--el-fill-color-light);\n}\n\n.role-info-side {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    min-width: 0;\n}\n\n.role-avatar {\n    background-color: #409eff;\n    color: #fff;\n    font-weight: bold;\n    flex-shrink: 0;\n}\n\n.role-card .role-title {\n    font-size: 15px;\n    font-weight: 600;\n    color: var(--el-text-color-primary);\n    margin: 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n.role-actions {\n    display: flex;\n    gap: 4px;\n    flex-shrink: 0;\n}\n\n.role-card .card-body {\n    padding: 14px;\n    display: flex;\n    flex-direction: column;\n    gap: 12px;\n}\n\n.role-card .role-desc {\n    font-size: 13px;\n    color: var(--el-text-color-secondary);\n    margin: 0;\n    line-height: 1.5;\n    height: 38px;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    display: -webkit-box;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n.bind-info {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-top: 4px;\n}\n\n.voice-tag-side {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    min-width: 0;\n}\n\n.voice-tag-side .el-tag {\n    max-width: 100px;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.play-btn {\n    transition: all 0.2s;\n}\n\n.play-btn:hover:not(:disabled) {\n    background-color: var(--el-color-primary-light-9);\n    color: #409eff;\n    transform: scale(1.1);\n}\n\n\n.queue-item .queue-title {\n    font-weight: 600;\n}\n\n.queue-item .queue-meta {\n    font-size: 12px;\n    color: var(--el-text-color-regular);\n}\n\n.w220 {\n    width: 220px;\n}\n\n.w260 {\n    width: 260px;\n}\n\n.w300 {\n    width: 300px;\n}\n\n.el-textarea__inner {\n    font-size: 14px;\n    line-height: 1.4;\n    max-height: 120px;\n    overflow-y: auto;\n}\n\n.voice-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n    gap: 12px;\n}\n\n.voice-card {\n    cursor: pointer;\n    border-radius: 12px;\n    transition: 0.2s;\n}\n\n.voice-card:hover {\n    border-color: var(--el-color-primary);\n}\n\n.voice-card-head {\n    margin-bottom: 8px;\n}\n\n.voice-title {\n    font-weight: 600;\n}\n\n.voice-desc {\n    font-size: 12px;\n    color: var(--el-text-color-regular);\n}\n\n.voice-actions {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n}\n\n.lines-table {\n    border-radius: 10px;\n    overflow: hidden;\n    border: 1px solid var(--el-border-color-lighter);\n    background: var(--el-bg-color);\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);\n    font-size: 13px;\n}\n\n:deep(.el-table-v2__header) {\n    background: var(--el-fill-color-light);\n    font-weight: 600;\n    color: var(--el-text-color-primary);\n    border-bottom: 1px solid var(--el-border-color-lighter);\n}\n\n:deep(.el-table-v2__row) {\n    transition: background-color 0.15s ease;\n}\n\n:deep(.el-table-v2__row:hover) {\n    background-color: var(--el-fill-color-light);\n}\n\n\n\n:deep(.el-tag) {\n    border-radius: 6px;\n}\n\n:deep(.el-button--small) {\n    border-radius: 6px;\n}\n\n:deep(.el-textarea__inner) {\n    font-size: 13px;\n    line-height: 1.4;\n    min-height: 60px;\n}\n\n:deep(.lines-table .el-textarea__inner) {\n    max-height: 132px;\n    overflow: auto;\n}\n\n:deep(.el-table-v2__cell) {\n    padding: 4px 8px;\n}\n\n.status-header {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 8px;\n    padding: 6px 10px;\n    background-color: var(--el-fill-color-light);\n    border-radius: 6px;\n    border: 1px solid var(--el-border-color-lighter);\n    transition: all 0.2s ease;\n}\n\n.status-header:hover {\n    background-color: #f0f6ff;\n    border-color: #d0e2ff;\n}\n\n.status-title {\n    font-weight: 600;\n    color: var(--el-text-color-primary);\n    font-size: 13px;\n    user-select: none;\n}\n\n.status-select {\n    width: 92px;\n    transition: all 0.2s ease;\n}\n\n.status-select:hover {\n    transform: translateY(-1px);\n    box-shadow: 0 0 4px rgba(64, 158, 255, 0.15);\n    border-radius: 4px;\n}\n\n:deep(.resizable-header) {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    /* 或 space-between，看表头内容 */\n    height: 100%;\n    padding-right: 6px;\n    /* 给手柄留空间 */\n    user-select: none;\n}\n\n:deep(.resize-handle) {\n    position: absolute;\n    top: 0;\n    right: 0;\n    width: 6px;\n    height: 100%;\n    cursor: col-resize;\n}\n\n/* 未选择章节的占位提示 */\n.no-chapter-placeholder {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    height: 100%;\n    min-height: 400px;\n    background: var(--el-fill-color-light);\n    border-radius: 12px;\n    color: var(--el-text-color-secondary);\n}\n\n/* 进度条未生成部分高亮 */\n.gen-progress :deep(.el-progress-bar__outer) {\n    background-color: #ffe6e6 !important;\n    border: 1px solid #ffcaca;\n}\n\n/* 进度条内部文字加粗加黑 */\n.gen-progress :deep(.el-progress-bar__innerText) {\n    color: #000000 !important;\n    font-weight: bold;\n    font-size: 13px;\n    text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/pages/ProjectList.vue",
    "content": "<template>\n    <div>\n        <!-- 标题 + 创建按钮 -->\n        <div class=\"header-bar\">\n            <h2>项目管理</h2>\n            <el-button type=\"primary\" @click=\"dialogVisible = true\">\n                <el-icon>\n                    <Plus />\n                </el-icon>\n                <span style=\"margin-left:4px;\">新建项目</span>\n            </el-button>\n        </div>\n\n        <!-- 项目卡片网格 -->\n        <el-row :gutter=\"20\">\n            <el-col v-for=\"item in projects\" :key=\"item.id\" :xs=\"24\" :sm=\"12\" :md=\"8\" :lg=\"6\" :xl=\"6\"\n                style=\"margin-bottom:20px;\">\n                <el-card shadow=\"hover\" class=\"project-card\" :body-style=\"{ padding: '0px' }\">\n                    <!-- 卡片头部 -->\n                    <div class=\"card-header\">\n                        <h3 class=\"project-title\" :title=\"item.name\">{{ item.name }}</h3>\n                        <el-popconfirm title=\"确认删除这个项目吗？\" confirm-button-text=\"删除\" cancel-button-text=\"取消\"\n                            @confirm=\"handleDelete(item.id)\">\n                            <template #reference>\n                                <el-button link type=\"danger\" size=\"small\">\n                                    <el-icon>\n                                        <Delete />\n                                    </el-icon>\n                                </el-button>\n                            </template>\n                        </el-popconfirm>\n                    </div>\n\n                    <!-- 项目信息 -->\n                    <div class=\"project-card-body\">\n                        <p class=\"project-desc\" :title=\"item.description\">{{ item.description || '暂无描述' }}</p>\n\n                        <div class=\"project-meta-grid\">\n                            <div v-if=\"item.llmProviderId\" class=\"meta-item\">\n                                <el-icon><Cpu /></el-icon>\n                                <span class=\"meta-label\">LLM:</span>\n                                <span class=\"meta-value\">{{ getLLMProviderName(item.llmProviderId) }}</span>\n                            </div>\n                            <div v-if=\"item.ttsProviderId\" class=\"meta-item\">\n                                <el-icon><Mic /></el-icon>\n                                <span class=\"meta-label\">TTS:</span>\n                                <span class=\"meta-value\">{{ getTTSProviderName(item.ttsProviderId) }}</span>\n                            </div>\n                            <div v-if=\"item.promptId\" class=\"meta-item\">\n                                <el-icon><Document /></el-icon>\n                                <span class=\"meta-label\">提示词:</span>\n                                <span class=\"meta-value\">{{ getPromptName(item.promptId) }}</span>\n                            </div>\n                            <div class=\"meta-item\">\n                                <el-icon>\n                                    <CircleCheck v-if=\"item.is_precise_fill == 1\" class=\"precise-on\" />\n                                    <CircleClose v-else class=\"precise-off\" />\n                                </el-icon>\n                                <span class=\"meta-label\">精确填充:</span>\n                                <el-tag size=\"small\" :type=\"item.is_precise_fill == 1 ? 'success' : 'info'\" effect=\"plain\">\n                                    {{ item.is_precise_fill == 1 ? '开启' : '关闭' }}\n                                </el-tag>\n                            </div>\n                        </div>\n\n                        <div class=\"project-footer\">\n                            <div class=\"time-info\">\n                                <el-icon><Clock /></el-icon>\n                                <span>{{ new Date(item.createdAt).toLocaleDateString() }}</span>\n                            </div>\n                            <el-button type=\"primary\" size=\"small\" round\n                                @click=\"$router.push(`/projects/${item.id}/dubbing`)\">\n                                🎙 继续配音\n                            </el-button>\n                        </div>\n                    </div>\n                </el-card>\n            </el-col>\n        </el-row>\n\n        <!-- 创建项目弹窗 -->\n        <el-dialog title=\"创建新项目\" v-model=\"dialogVisible\" width=\"500px\">\n            <el-form :model=\"form\" :rules=\"rules\" ref=\"formRef\" label-width=\"100px\">\n                <!-- 项目名称 -->\n                <el-form-item label=\"项目名称\" prop=\"name\">\n                    <el-input v-model=\"form.name\" placeholder=\"请输入项目名称\"></el-input>\n                </el-form-item>\n\n                <!-- 项目描述 -->\n                <el-form-item label=\"项目描述\" prop=\"description\">\n                    <el-input v-model=\"form.description\" type=\"textarea\" placeholder=\"请输入项目描述\" :rows=\"3\"></el-input>\n                </el-form-item>\n\n                <!-- LLM 提供商 -->\n                <el-form-item label=\"LLM 提供商\">\n                    <el-select v-model=\"form.llm_provider_id\" placeholder=\"请选择 LLM 提供商\" clearable style=\"width: 100%;\">\n                        <el-option v-for=\"provider in llmProviders\" :key=\"provider.id\" :label=\"provider.name\"\n                            :value=\"provider.id\" />\n                    </el-select>\n                </el-form-item>\n\n                <!-- LLM 模型 -->\n                <el-form-item label=\"LLM 模型\">\n                    <el-select v-model=\"form.llm_model\" placeholder=\"请选择 LLM 模型\" clearable style=\"width: 100%;\">\n                        <el-option v-for=\"model in availableModels\" :key=\"model\" :label=\"model\" :value=\"model\" />\n                    </el-select>\n                </el-form-item>\n\n                <!-- TTS 提供商 -->\n                <el-form-item label=\"TTS 引擎\">\n                    <el-select v-model=\"form.tts_provider_id\" placeholder=\"请选择 TTS 引擎\" clearable style=\"width: 100%;\">\n                        <el-option v-for=\"tts in ttsProviders\" :key=\"tts.id\" :label=\"tts.name\" :value=\"tts.id\" />\n                    </el-select>\n                </el-form-item>\n                <!-- 提示词模板 -->\n                <el-form-item label=\"提示词模板\" prop=\"prompt_id\">\n                    <el-select v-model=\"form.prompt_id\" placeholder=\"请选择提示词模板\" clearable style=\"width: 100%;\">\n                        <el-option v-for=\"p in prompts\" :key=\"p.id\" :label=\"p.name\" :value=\"p.id\" />\n                    </el-select>\n                </el-form-item>\n                <!-- ✅ 是否精确填充（0/1） -->\n                <el-form-item label=\"精确填充\">\n                    <el-switch v-model=\"form.is_precise_fill\" :active-value=\"1\" :inactive-value=\"0\" active-text=\"开启\"\n                        inactive-text=\"关闭\" />\n                </el-form-item>\n                <!-- 项目根路径（可选） -->\n                <!-- 项目根路径（选择文件夹 + 只读可复制） -->\n                <el-form-item label=\"项目根路径\" prop=\"project_root_path\">\n                    <el-input v-model=\"form.project_root_path\" readonly\n                        placeholder=\"例如：D:\\\\Works\\\\MyProject 或 /Users/me/Projects/demo\">\n                        <template #append>\n                            <el-button @click=\"pickRootDir\">选择</el-button>\n                        </template>\n                    </el-input>\n                </el-form-item>\n\n\n\n\n            </el-form>\n\n            <template #footer>\n                <el-button @click=\"dialogVisible = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"handleSubmit\">确定</el-button>\n            </template>\n        </el-dialog>\n    </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport { ElMessage, ElLoading } from 'element-plus'\n// import { Plus, Delete } from '@element-plus/icons-vue'\nimport { fetchProjects, createProject, deleteProject } from '../api/project'\nimport { fetchLLMProviders, fetchTTSProviders } from '../api/provider'\nimport { fetchPromptList } from '../api/prompt'\nimport { Plus, Delete, Cpu, Mic, Document, Clock, CircleCheck, CircleClose, Folder } from \"@element-plus/icons-vue\"\nconst prompts = ref([])\n\nconst projects = ref([])\nconst dialogVisible = ref(false)\n\n// 表单数据\nconst form = ref({\n    name: '',\n    description: '',\n    llm_provider_id: null,\n    llm_model: null,\n    tts_provider_id: null,\n    prompt_id: null,\n    is_precise_fill: 0,      // ✅ 新增字段\n    project_root_path: null,\n})\n\n// 校验规则\nconst rules = {\n    name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],\n    description: [{ required: true, message: '请输入项目描述', trigger: 'blur' }],\n    prompt_id: [{ required: true, message: '请选择提示词模版', trigger: 'change' }],\n    project_root_path: [{ required: true, message: '请输入项目根路径', trigger: 'blur' }],\n}\n\nconst formRef = ref(null)\n\n// 下拉框数据\nconst llmProviders = ref([])\nconst availableModels = ref([])\nconst ttsProviders = ref([])\n\n// 加载项目和 Provider 数据\nonMounted(async () => {\n    projects.value = await fetchProjects()\n    llmProviders.value = await fetchLLMProviders()\n    ttsProviders.value = await fetchTTSProviders()\n    prompts.value = await fetchPromptList()   // ✅ 加载提示词\n})\n\n\n/** ===================== 名称映射工具 ===================== */\nconst getLLMProviderName = (id) => {\n    const p = llmProviders.value.find(x => x.id === id)\n    return p ? p.name : id\n}\nconst getTTSProviderName = (id) => {\n    const p = ttsProviders.value.find(x => x.id === id)\n    console.log(\"getTTSProviderName\", id, p)\n    return p ? p.name : id\n}\nconst getPromptName = (id) => {\n    const p = prompts.value.find(x => x.id === id)\n    return p ? p.name : id\n}\n\n// 监听 LLM provider 切换，更新模型列表\nwatch(\n    () => form.value.llm_provider_id,\n    (newVal) => {\n        const provider = llmProviders.value.find(p => p.id === newVal)\n        availableModels.value = provider ? provider.model_list.split(',') : []\n        form.value.llm_model = null // 重置模型选择\n    }\n)\n\n// 删除项目\nconst handleDelete = async (id) => {\n    const loading = ElLoading.service({\n        lock: true,\n        text: '章节内容较多，删除较久，请稍等...',\n        background: 'rgba(0, 0, 0, 0.3)',\n    })\n\n    try {\n        await deleteProject(id)\n        projects.value = projects.value.filter(p => p.id !== id)\n        ElMessage.success('删除成功')\n    } catch (e) {\n        ElMessage.error('删除失败')\n    } finally {\n        loading.close()\n    }\n}\n\n\n// 提交表单\nconst handleSubmit = () => {\n    formRef.value.validate(async (valid) => {\n        if (valid) {\n            try {\n                const res = await createProject(form.value)\n                if (res?.code === 200) {\n                    ElMessage.success('项目创建成功')\n                    dialogVisible.value = false\n\n                    // ✅ 重置表单\n                    Object.assign(form.value, {\n                        name: '',\n                        description: '',\n                        llm_provider_id: null,\n                        llm_model: null,\n                        tts_provider_id: null,\n                        prompt_id: null,\n                        is_precise_fill: 0,\n                        project_root_path: null,\n                    })\n\n                    projects.value = await fetchProjects()\n                } else {\n                    // ✅ 正常请求但业务失败\n                    ElMessage.error(`创建失败：${res?.message || '未知错误'}`)\n                }\n            } catch (e) {\n                ElMessage.error(`创建失败：${e?.message || '网络异常'}`)\n            }\n        }\n    })\n}\n\n\nconst native = window.native\nconst pickRootDir = async () => {\n    try {\n        const dir = await native?.selectDir()\n        if (dir) {\n            form.value.project_root_path = dir\n            // 如果设为必填，选完后立即触发该字段校验（可选）\n            // formRef.value?.validateField?.('project_root_path')\n        }\n    } catch (e) {\n        ElMessage.error(`选择失败：${e?.message || '未知错误'}`)\n    }\n}\n</script>\n\n<style scoped>\n.header-bar {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 20px;\n}\n\n.project-card {\n    border-radius: 12px;\n    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    height: 100%;\n    border: 1px solid var(--el-border-color-lighter);\n}\n\n.project-card:hover {\n    transform: translateY(-6px);\n    box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);\n    border-color: #409eff;\n}\n\n/* 卡片头部 */\n.card-header {\n    padding: 14px 16px;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    border-bottom: 1px solid var(--el-border-color-lighter);\n    background-color: var(--el-fill-color-light);\n}\n\n.project-title {\n    font-size: 16px;\n    font-weight: 600;\n    color: var(--el-text-color-primary);\n    margin: 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    max-width: 180px;\n}\n\n.project-card-body {\n    padding: 16px;\n    display: flex;\n    flex-direction: column;\n    height: calc(100% - 49px);\n}\n\n.project-desc {\n    font-size: 13px;\n    color: var(--el-text-color-secondary);\n    margin: 0 0 16px 0;\n    line-height: 1.5;\n    height: 40px;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    display: -webkit-box;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n.project-meta-grid {\n    flex: 1;\n    display: grid;\n    grid-template-columns: 1fr;\n    gap: 8px;\n    margin-bottom: 16px;\n}\n\n.meta-item {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 13px;\n    color: var(--el-text-color-regular);\n}\n\n.meta-item .el-icon {\n    font-size: 14px;\n    color: var(--el-text-color-secondary);\n}\n\n.meta-label {\n    color: var(--el-text-color-secondary);\n    min-width: 60px;\n}\n\n.meta-value {\n    color: var(--el-text-color-primary);\n    font-weight: 500;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n.project-footer {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding-top: 12px;\n    border-top: 1px solid var(--el-border-color-lighter);\n}\n\n.time-info {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    font-size: 12px;\n    color: var(--el-text-color-secondary);\n}\n\n.precise-on {\n    color: #67C23A;\n}\n\n.precise-off {\n    color: #F56C6C;\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/pages/PromptManager.vue",
    "content": "<template>\n  <div class=\"prompt-manager\">\n    <!-- 顶部栏 -->\n    <div class=\"header-bar\">\n      <h2 class=\"page-title\">提示词管理</h2>\n      <div class=\"toolbar\">\n        <el-input v-model=\"search\" placeholder=\"搜索提示词名称\" style=\"width: 200px; margin-right: 10px\" clearable\n          @clear=\"loadPrompts\" @input=\"loadPrompts\" />\n        <el-button type=\"primary\" @click=\"openDialog()\">新增提示词</el-button>\n      </div>\n    </div>\n    <!-- 公告栏 -->\n\n    <!-- 公告按钮 -->\n    <el-button type=\"danger\" plain round right @click=\"noticeVisible = true\">\n      <el-icon style=\"margin-right: 6px;\">\n        <WarningFilled />\n      </el-icon>\n      提示词说明\n    </el-button>\n\n\n    <!-- 公告弹框 -->\n    <el-dialog v-model=\"noticeVisible\" title=\"📢 提示词必备格式说明\" width=\"750px\">\n      <div class=\"notice-content\">\n        <p>⚠️ 创建提示词时，必须遵守以下规则，否则会创建失败：</p>\n\n        <p>\n          ✅ 必须包含 <strong>小说原文</strong>：\n          <code>\n&lt;novel_content&gt;<br />\n{novel_content}<br />\n&lt;/novel_content&gt;\n      </code>\n        </p>\n\n        <p style=\"color: #e53935; font-weight: bold;\">\n  ⚠️ 注意：<strong>输出必须严格为 JSON 格式！</strong><br>\n  <span style=\"color: #999; font-weight: normal;\">\n    （不再使用 <code>&lt;result&gt;</code> 标签格式）\n  </span>\n</p>\n\n\n\n        <p>\n          ✅ <strong>输出 JSON 数组</strong> 中的每个对象必须包含以下四个参数：\n          <code>\n{<br />\n&nbsp;&nbsp;\"role_name\" ,<br />\n&nbsp;&nbsp;\"text_content\" <br />\n&nbsp;&nbsp;\"emotion_name\" <br />\n&nbsp;&nbsp;\"strength_name\"<br />\n}\n      </code>\n        </p>\n\n        <p>\n          ➕ 以下标签为 <strong>可选</strong>（根据需要添加，不需要可省略）：\n        </p>\n\n        <p>\n          <code>\n&lt;possible_characters&gt;<br />\n{possible_characters}<br />\n&lt;/possible_characters&gt;\n      </code>\n        </p>\n\n        <p>\n          <code>\n&lt;possible_emotions&gt;<br />\n{possible_emotions}<br />\n&lt;/possible_emotions&gt;\n      </code>\n        </p>\n\n        <p>\n          <code>\n&lt;possible_strengths&gt;<br />\n{possible_strengths}<br />\n&lt;/possible_strengths&gt;\n      </code>\n        </p>\n      </div>\n\n      <template #footer>\n        <el-button type=\"primary\" @click=\"noticeVisible = false\">我已了解</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 提示词卡片网格 -->\n    <el-row :gutter=\"20\">\n      <el-col v-for=\"item in prompts\" :key=\"item.id\" :xs=\"24\" :sm=\"12\" :md=\"8\" :lg=\"6\" style=\"margin-bottom: 20px;\">\n        <el-card shadow=\"hover\" class=\"prompt-card\" :body-style=\"{ padding: '0px' }\">\n          <!-- 卡片头部 -->\n          <div class=\"card-header\">\n            <h3 class=\"prompt-title\" :title=\"item.name\">{{ item.name }}</h3>\n            <div class=\"actions\">\n              <el-button link type=\"primary\" size=\"small\" @click=\"openDialog(item)\">\n                <el-icon>\n                  <Edit />\n                </el-icon>\n              </el-button>\n              <el-popconfirm title=\"确定要删除该提示词吗？\" @confirm=\"removePrompt(item)\">\n                <template #reference>\n                  <el-button link type=\"danger\" size=\"small\">\n                    <el-icon>\n                      <Delete />\n                    </el-icon>\n                  </el-button>\n                </template>\n              </el-popconfirm>\n            </div>\n          </div>\n\n          <div class=\"card-body\">\n            <div class=\"meta-info\">\n              <el-tag size=\"small\" effect=\"plain\" class=\"task-tag\">{{ item.task }}</el-tag>\n            </div>\n\n            <!-- 描述 -->\n            <p class=\"prompt-desc\" :title=\"item.description\">{{ item.description || '暂无描述' }}</p>\n\n            <!-- 内容 -->\n            <div class=\"content-preview\">\n              <p class=\"prompt-content\">{{ item.content }}</p>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n    </el-row>\n\n    <!-- 新增/编辑对话框 -->\n    <el-dialog v-model=\"dialogVisible\" :title=\"form.id ? '编辑提示词' : '新增提示词'\" width=\"800px\">\n      <el-form :model=\"form\" label-width=\"80px\">\n        <el-form-item label=\"名称\">\n          <el-input v-model=\"form.name\" placeholder=\"请输入提示词名称\" />\n        </el-form-item>\n        <el-form-item label=\"任务\" prop=\"task\">\n          <el-select v-model=\"form.task\" placeholder=\"请选择任务\">\n            <el-option v-for=\"t in tasks\" :key=\"t\" :label=\"t\" :value=\"t\" />\n          </el-select>\n        </el-form-item>\n\n        <el-form-item label=\"描述\">\n          <el-input v-model=\"form.description\" placeholder=\"请输入描述\" />\n        </el-form-item>\n        <el-form-item label=\"内容\">\n          <el-input v-model=\"form.content\" type=\"textarea\" placeholder=\"请输入提示词内容\" rows=\"10\" />\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"dialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"savePrompt\">保存</el-button>\n      </template>\n    </el-dialog>\n\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from \"vue\"\nimport { ElMessage, ElMessageBox } from \"element-plus\"\nimport { Edit, Delete, QuestionFilled } from \"@element-plus/icons-vue\"\nimport { WarningFilled } from \"@element-plus/icons-vue\"\n\nimport {\n  fetchPromptList,\n  createPrompt,\n  updatePrompt,\n  deletePrompt,\n  fetchAllTasks\n} from \"../api/prompt\"\nconst noticeVisible = ref(false)\nconst prompts = ref([])\nconst search = ref(\"\")\nconst dialogVisible = ref(false)\nconst form = ref({ id: null, name: \"\", description: \"\", content: \"\", task: \"\" })\nconst tasks = ref([])\n// 加载提示词列表\nasync function loadPrompts() {\n  const data = await fetchPromptList()\n  if (search.value) {\n    prompts.value = data.filter(p => p.name.includes(search.value))\n  } else {\n    prompts.value = data\n  }\n}\n\nonMounted(async () => {\n  tasks.value = await fetchAllTasks()\n  loadPrompts()\n})\n\n\n// 打开对话框（新增 / 编辑）\nfunction openDialog(row) {\n  if (row) {\n    form.value = { ...row }\n  } else {\n    form.value = { id: null, name: \"\", description: \"\", content: \"\" }\n  }\n  dialogVisible.value = true\n}\n\n// 保存提示词（新增或更新）\nasync function savePrompt() {\n  if (!form.value.name) {\n    ElMessage.warning(\"名称不能为空\")\n    return\n  }\n  try {\n    let res\n    if (form.value.id) {\n      res = await updatePrompt(form.value.id, form.value)\n    } else {\n      res = await createPrompt(form.value)\n    }\n\n    // ✅ 统一根据 code 判断\n    if (res.code === 200) {\n      ElMessage.success(res.message || \"操作成功\")\n      dialogVisible.value = false\n      await loadPrompts()\n    } else {\n      ElMessage.error(res.message || \"操作失败\")\n    }\n  } catch (err) {\n    ElMessage.error(\"操作失败\")\n    console.error(err)\n  }\n}\n\n// 删除提示词\nfunction removePrompt(row) {\n  ElMessageBox.confirm(`确定要删除提示词「${row.name}」吗？`, \"提示\", {\n    type: \"warning\"\n  })\n    .then(async () => {\n      try {\n        await deletePrompt(row.id)\n        ElMessage.success(\"已删除\")\n        await loadPrompts()\n      } catch (err) {\n        ElMessage.error(\"删除失败\")\n        console.error(err)\n      }\n    })\n    .catch(() => { })\n}\n</script>\n\n<style scoped>\n/* 顶部标题栏 */\n.header-bar {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 15px;\n}\n\n.page-title {\n  font-size: 20px;\n  font-weight: bold;\n  margin: 0;\n}\n\n.toolbar {\n  display: flex;\n  align-items: center;\n}\n\n/* 卡片 */\n.prompt-card {\n  border-radius: 12px;\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n  height: 100%;\n  border: 1px solid var(--el-border-color-lighter);\n}\n\n.prompt-card:hover {\n  transform: translateY(-6px);\n  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);\n  border-color: #409eff;\n}\n\n/* 卡片头部 */\n.card-header {\n  padding: 14px 16px;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  border-bottom: 1px solid var(--el-border-color-lighter);\n  background-color: var(--el-fill-color-light);\n}\n\n.prompt-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--el-text-color-primary);\n  margin: 0;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  max-width: 180px;\n}\n\n.actions {\n  display: flex;\n  gap: 8px;\n}\n\n.card-body {\n  padding: 16px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.meta-info {\n  display: flex;\n  align-items: center;\n}\n\n.task-tag {\n  border-radius: 4px;\n  font-weight: 500;\n}\n\n/* 描述 */\n.prompt-desc {\n  font-size: 13px;\n  color: var(--el-text-color-secondary);\n  margin: 0;\n  line-height: 1.5;\n  height: 40px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n/* 内容预览区域 */\n.content-preview {\n  background-color: var(--el-fill-color-light);\n  border-radius: 6px;\n  padding: 10px;\n  border: 1px solid var(--el-border-color-lighter);\n}\n\n.prompt-content {\n  font-size: 12px;\n  color: var(--el-text-color-regular);\n  margin: 0;\n  line-height: 1.6;\n  height: 58px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  font-family: monospace;\n}\n\n.el-button.is-plain.el-button--danger {\n  border: 1px solid var(--el-color-danger);\n  color: var(--el-color-danger);\n  background: var(--el-color-danger-light-9);\n  font-weight: 600;\n  transition: all 0.2s ease;\n}\n\n.el-button.is-plain.el-button--danger:hover {\n  background: var(--el-color-danger);\n  color: var(--el-color-white);\n  box-shadow: 0 2px 8px rgba(245, 108, 108, 0.3);\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/pages/VoiceManager.vue",
    "content": "<template>\n  <div>\n    <div class=\"page-header\">\n      <h2>音色管理</h2>\n      <div class=\"actions\">\n        <el-select v-model=\"selectedTTS\" placeholder=\"选择 TTS 引擎\" class=\"tts-select\" @change=\"loadVoices\">\n          <el-option v-for=\"t in ttsProviders\" :key=\"t.id\" :label=\"t.name\" :value=\"t.id\" />\n        </el-select>\n        <el-button type=\"primary\" :disabled=\"!selectedTTS\" @click=\"openDialog()\">新增音色</el-button>\n        <el-button type=\"success\" plain :disabled=\"!selectedTTS || selectedCount === 0\" @click=\"handleExportSelected\">导出音色库（选中）</el-button>\n        <el-popconfirm\n          title=\"确认删除选中的音色？\"\n          confirm-button-text=\"确定\"\n          cancel-button-text=\"取消\"\n          @confirm=\"handleBatchDelete\"\n        >\n          <template #reference>\n            <el-button type=\"danger\" plain :disabled=\"!selectedTTS || selectedCount === 0\">批量删除（选中）</el-button>\n          </template>\n        </el-popconfirm>\n        <el-button type=\"warning\" :disabled=\"!selectedTTS\" @click=\"handleImport\">导入音色库</el-button>\n      </div>\n    </div>\n\n    <div class=\"filter-bar\">\n      <el-select\n        ref=\"filterSelectRef\"\n        v-model=\"filterTags\"\n        multiple\n        filterable\n        clearable\n        collapse-tags\n        collapse-tags-tooltip\n        placeholder=\"标签筛选\"\n        class=\"filter-tags\"\n        @change=\"handleFilterTagChange\"\n      >\n        <el-option v-for=\"tag in allTags\" :key=\"tag\" :label=\"tag\" :value=\"tag\" />\n      </el-select>\n      <el-input v-model=\"searchName\" placeholder=\"按名称搜索\" clearable class=\"filter-search\" />\n      <el-button plain :disabled=\"!searchName && !filterTags.length\" @click=\"resetFilters\">重置筛选</el-button>\n      <div class=\"filter-result\">共 {{ filteredVoices.length }} 条</div>\n    </div>\n\n    <el-table\n      :data=\"filteredVoices\"\n      ref=\"voiceTableRef\"\n      border\n      stripe\n      highlight-current-row\n      class=\"voice-table\"\n      :header-cell-style=\"headerCellStyle\"\n      :cell-style=\"cellStyle\"\n      row-key=\"id\"\n      @selection-change=\"handleSelectionChange\"\n    >\n      <el-table-column type=\"selection\" width=\"48\" align=\"center\" />\n      <el-table-column label=\"#\" width=\"80\" align=\"center\">\n        <template #default=\"{ $index }\">{{ $index + 1 }}</template>\n      </el-table-column>\n\n      <el-table-column prop=\"name\" label=\"名称\" min-width=\"180\" />\n\n      <el-table-column label=\"播放\" width=\"160\" align=\"center\">\n        <template #default=\"{ row }\">\n          <el-button\n            size=\"small\"\n            :type=\"row.reference_path ? 'primary' : 'default'\"\n            :plain=\"!row.reference_path\"\n            :disabled=\"!row.reference_path\"\n            @click=\"togglePlay(row.reference_path)\"\n          >\n            <el-icon style=\"margin-right:4px;\">\n              <Headset />\n            </el-icon>\n            {{ isPlaying && currentPath === row.reference_path ? '暂停' : '播放' }}\n          </el-button>\n        </template>\n      </el-table-column>\n\n      <!-- 描述改为 tag 展示 -->\n      <el-table-column prop=\"description\" label=\"标签\" min-width=\"220\">\n        <template #default=\"{ row }\">\n          <div class=\"tags-wrap\">\n            <el-tag\n              v-for=\"(tag, index) in (row.description ? row.description.split(',') : [])\"\n              :key=\"index\"\n              type=\"info\"\n              effect=\"plain\"\n              style=\"margin-right: 6px;\"\n            >\n              {{ tag }}\n            </el-tag>\n            <span v-if=\"!row.description\">—</span>\n          </div>\n        </template>\n      </el-table-column>\n\n      <el-table-column label=\"参考音频/路径\" min-width=\"200\" align=\"center\">\n        <template #default=\"{ row }\">\n          <el-tooltip :content=\"row.reference_path ? row.reference_path : '未设置参考音频'\" placement=\"top\">\n            <span class=\"path-ellipsis\">{{ row.reference_path || '（未设置）' }}</span>\n          </el-tooltip>\n        </template>\n      </el-table-column>\n\n      <el-table-column label=\"创建时间\" width=\"180\">\n        <template #default=\"{ row }\">{{ formatDateTime(row.created_at) }}</template>\n      </el-table-column>\n      <el-table-column label=\"更新时间\" width=\"180\">\n        <template #default=\"{ row }\">{{ formatDateTime(row.updated_at) }}</template>\n      </el-table-column>\n\n      <el-table-column label=\"操作\" width=\"320\" fixed=\"right\" align=\"center\">\n  <template #default=\"{ row }\">\n    <div class=\"flex justify-center gap-2\">\n      <el-button \n        type=\"primary\" \n        size=\"small\" \n        plain \n        @click=\"openDialog(row)\">\n        编辑\n      </el-button>\n      <el-button \n        type=\"success\" \n        size=\"small\" \n        plain\n        @click=\"openCopyDialog(row)\">\n        复制\n      </el-button>\n      <el-button \n        type=\"warning\" \n        size=\"small\" \n        plain\n        :disabled=\"!row.reference_path\"\n        @click=\"openAudioEditor(row)\">\n        音频编辑\n      </el-button>\n      <el-popconfirm\n        title=\"确认删除该音色？\"\n        confirm-button-text=\"确定\"\n        cancel-button-text=\"取消\"\n        @confirm=\"handleDelete(row.id)\"\n      >\n        <template #reference>\n          <el-button \n            type=\"danger\" \n            size=\"small\" \n            plain>\n            删除\n          </el-button>\n        </template>\n      </el-popconfirm>\n    </div>\n  </template>\n</el-table-column>\n\n    </el-table>\n\n    <!-- 弹窗：新增/编辑 -->\n    <el-dialog :title=\"form.id ? '编辑音色' : '新增音色'\" v-model=\"dialogVisible\" width=\"720px\">\n      <el-form :model=\"form\" :rules=\"rules\" ref=\"formRef\" label-width=\"110px\">\n        <el-form-item label=\"名称\" prop=\"name\">\n          <el-input v-model=\"form.name\" placeholder=\"请输入音色名称\" />\n        </el-form-item>\n\n        <!-- 描述改为标签输入 -->\n        <el-form-item label=\"标签\" class=\"tag-item\">\n  <div class=\"tag-hint\">可直接选择下方标签，也可以输入自定义标签后回车添加</div>\n  <el-select\n    ref=\"tagSelectRef\"\n    v-model=\"form.tags\"\n    multiple\n    filterable\n    allow-create\n    default-first-option\n    placeholder=\"输入或选择标签（回车添加）\"\n    style=\"width: 100%;\"\n    @change=\"handleTagChange\"\n  >\n    <el-option\n      v-for=\"opt in defaultTags\"\n      :key=\"opt\"\n      :label=\"opt\"\n      :value=\"opt\"\n    />\n  </el-select>\n</el-form-item>\n\n\n\n        <el-form-item label=\"参考音频\">\n          <div class=\"pick-line\">\n            <el-input v-model=\"form.reference_path\" placeholder=\"请选择本地音频文件\" readonly style=\"width:420px\" />\n            <el-button @click=\"pickLocalAudioForBase\" style=\"margin-left:8px\">选择文件</el-button>\n            <el-button v-if=\"form.reference_path\" type=\"danger\" link @click=\"clearReferencePath\">清除</el-button>\n          </div>\n\n          <div class=\"preview\" v-if=\"form.reference_path\">\n            <el-alert title=\"已选择本地音频文件\" type=\"success\" :closable=\"false\" show-icon class=\"mb8\" />\n            <div class=\"path-text\">{{ form.reference_path }}</div>\n            <el-button type=\"primary\" size=\"small\" @click=\"togglePlay(form.reference_path)\">\n              {{ isPlaying && currentPath === form.reference_path ? '暂停' : '播放' }}\n            </el-button>\n          </div>\n        </el-form-item>\n      </el-form>\n\n      <template #footer>\n        <el-button @click=\"dialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" @click=\"submitForm\">确定</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 弹窗：音频编辑 -->\n    <el-dialog title=\"音频编辑\" v-model=\"audioEditorVisible\" width=\"900px\" :close-on-click-modal=\"false\">\n      <div class=\"audio-editor-info\" v-if=\"editingVoice\">\n        <span class=\"audio-editor-label\">音色名称：</span>\n        <span class=\"audio-editor-value\">{{ editingVoice.name }}</span>\n      </div>\n      <div class=\"wave-editor-wrap\" v-if=\"editingVoice?.reference_path && waveEditorKey\">\n        <WaveCellPro\n          :key=\"waveEditorKey\"\n          :src=\"editingVoice.reference_path\"\n          :speed=\"1.0\"\n          :volume2x=\"1.0\"\n          @confirm=\"handleWaveConfirm\"\n          @ready=\"handleWaveReady\"\n        />\n      </div>\n      <template #footer>\n        <el-button @click=\"audioEditorVisible = false\">关闭</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 弹窗：导入音色库 -->\n    <el-dialog title=\"导入音色库\" v-model=\"importDialogVisible\" width=\"600px\">\n      <el-form :model=\"importForm\" label-width=\"120px\">\n        <el-form-item label=\"音色库文件\">\n          <div class=\"pick-line\">\n            <el-input v-model=\"importForm.zipPath\" placeholder=\"请选择音色库zip文件\" readonly style=\"flex:1\" />\n            <el-button @click=\"pickImportZip\" style=\"margin-left:8px\">选择文件</el-button>\n          </div>\n        </el-form-item>\n        <el-form-item label=\"音色保存目录\">\n          <div class=\"pick-line\">\n            <el-input v-model=\"importForm.targetDir\" placeholder=\"音色文件保存目录\" style=\"flex:1\" />\n            <el-button @click=\"pickImportDir\" style=\"margin-left:8px\">选择目录</el-button>\n          </div>\n          <div class=\"form-hint\">导入的音色文件将保存到此目录</div>\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"importDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" :disabled=\"!importForm.zipPath || !importForm.targetDir\" @click=\"confirmImport\">确认导入</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 弹窗：复制音色 -->\n    <el-dialog title=\"复制音色\" v-model=\"copyDialogVisible\" width=\"600px\">\n      <el-form :model=\"copyForm\" :rules=\"copyRules\" ref=\"copyFormRef\" label-width=\"120px\">\n        <el-form-item label=\"原音色名称\">\n          <el-input :value=\"copyForm.sourceName\" disabled />\n        </el-form-item>\n        <el-form-item label=\"新音色名称\" prop=\"newName\">\n          <el-input v-model=\"copyForm.newName\" placeholder=\"请输入新音色名称\" />\n        </el-form-item>\n        <el-form-item label=\"保存目录\">\n          <div class=\"pick-line\">\n            <el-input v-model=\"copyForm.targetDir\" placeholder=\"留空则保存到原音色同目录\" style=\"flex:1\" />\n            <el-button @click=\"pickCopyTargetDir\" style=\"margin-left:8px\">选择目录</el-button>\n          </div>\n          <div class=\"form-hint\">留空则将新音色文件保存到原音色所在目录</div>\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"copyDialogVisible = false\">取消</el-button>\n        <el-button type=\"primary\" :disabled=\"!copyForm.newName\" @click=\"confirmCopy\">确认复制</el-button>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, nextTick, computed } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { Headset } from '@element-plus/icons-vue'\nimport dayjs from 'dayjs'\nimport { createVoice, fetchVoicesByTTS, updateVoice, deleteVoice, exportVoices, importVoices, processVoiceAudio, copyVoice } from '../api/voice'\nimport { fetchTTSProviders } from '../api/provider'\nimport WaveCellPro from '../components/WaveCellPro.vue'\n\n\nconst defaultTags = ref([\n  '男',\n  '女',\n  '小孩',\n  '青年',\n  '中年',\n  '老年'\n])\n\n// @ts-ignore - 由 preload 暴露\nconst native = window.native\n\nconst ttsProviders = ref([])\nconst selectedTTS = ref(null)\nconst voices = ref([])\nconst voiceTableRef = ref(null)\nconst selectedRows = ref([])\nconst selectedIds = computed(() => (selectedRows.value || []).map(v => v?.id).filter(v => v !== null && v !== undefined))\nconst selectedCount = computed(() => selectedIds.value.length)\n\nconst filterTags = ref([])\nconst searchName = ref('')\nconst filterSelectRef = ref(null)\n\nconst allTags = computed(() => {\n  const set = new Set()\n  defaultTags.value.forEach(t => t && set.add(t))\n  voices.value.forEach(v => {\n    const tags = v.description ? v.description.split(',') : []\n    tags.map(t => t.trim()).filter(Boolean).forEach(t => set.add(t))\n  })\n  return Array.from(set)\n})\n\nconst filteredVoices = computed(() => {\n  const name = searchName.value.trim()\n  const nameLower = name.toLowerCase()\n  return voices.value.filter(v => {\n    const voiceName = (v.name || '').toString()\n    const matchName = !name || voiceName.toLowerCase().includes(nameLower)\n    if (!filterTags.value.length) return matchName\n    const tags = v.description ? v.description.split(',').map(t => t.trim()).filter(Boolean) : []\n    const matchTags = filterTags.value.every(ft => tags.includes(ft))\n    return matchName && matchTags\n  })\n})\n\nfunction handleSelectionChange(rows) {\n  selectedRows.value = rows || []\n}\n\nfunction formatDateTime(value) {\n  if (!value) return '—'\n  const d = dayjs(value)\n  if (!d.isValid()) return String(value)\n  return d.format('YYYY-MM-DD HH:mm:ss')\n}\n\nasync function clearTableSelection() {\n  await nextTick()\n  voiceTableRef.value?.clearSelection?.()\n  selectedRows.value = []\n}\n\n// ====== 音频播放控制 ======\nconst audioPlayer = new Audio()\nconst isPlaying = ref(false)\nconst currentPath = ref(null)\n\nfunction togglePlay(absPath) {\n  if (!absPath) return\n  const url = toFileUrl(absPath)\n  if (!url) {\n    ElMessage.error('无法播放该音频文件')\n    return\n  }\n\n  if (currentPath.value === absPath) {\n    if (isPlaying.value) {\n      audioPlayer.pause()\n    } else {\n      audioPlayer.play().catch(() => ElMessage.error('无法播放该音频文件'))\n    }\n    return\n  }\n\n  audioPlayer.pause()\n  audioPlayer.src = url\n  audioPlayer.currentTime = 0\n  currentPath.value = absPath\n  audioPlayer.play().catch(() => ElMessage.error('无法播放该音频文件'))\n}\n\naudioPlayer.addEventListener('play', () => { isPlaying.value = true })\naudioPlayer.addEventListener('pause', () => { isPlaying.value = false })\naudioPlayer.addEventListener('ended', () => {\n  isPlaying.value = false\n  currentPath.value = null\n})\n\nconst dialogVisible = ref(false)\nwatch(dialogVisible, v => { \n  if (!v) {\n    audioPlayer.pause()\n  }\n})\n\n// ====== 独立音频编辑弹窗 ======\nconst audioEditorVisible = ref(false)\nconst editingVoice = ref(null)\n\nwatch(audioEditorVisible, v => {\n  if (!v) {\n    audioPlayer.pause()\n    waveEditorKey.value = null\n    editingVoice.value = null\n  }\n})\n\nfunction openAudioEditor(row) {\n  if (!row.reference_path) {\n    ElMessage.warning('该音色没有参考音频')\n    return\n  }\n  audioPlayer.pause()\n  editingVoice.value = row\n  waveEditorKey.value = Date.now()\n  audioEditorVisible.value = true\n}\n\n// 表单\nconst formRef = ref(null)\nconst form = ref({\n  id: null,\n  name: '',\n  tags: [],\n  reference_path: '',\n  tts_provider_id: null\n})\n\nconst rules = {\n  name: [{ required: true, message: '请输入音色名称', trigger: 'blur' }]\n}\n\n// 表格样式\nconst headerCellStyle = () => ({\n  background: 'var(--el-fill-color-light)',\n  color: 'var(--el-text-color-primary)',\n  fontWeight: 600\n})\nconst cellStyle = () => ({ padding: '10px 12px' })\n\n// 加载 TTS\nconst loadTTS = async () => {\n  ttsProviders.value = await fetchTTSProviders()\n  const def = ttsProviders.value.find(t => t.id === 1) || ttsProviders.value[0]\n  if (def) {\n    selectedTTS.value = def.id\n    await loadVoices()\n  }\n}\n\nconst loadVoices = async () => {\n  if (!selectedTTS.value) return\n  const list = await fetchVoicesByTTS(selectedTTS.value)\n  voices.value = list || []\n  await clearTableSelection()\n}\n\nfunction openDialog(row) {\n  if (row) {\n    form.value = {\n      id: row.id,\n      name: row.name,\n      reference_path: row.reference_path || '',\n      tts_provider_id: row.tts_provider_id || selectedTTS.value || 1,\n      tags: row.description ? row.description.split(',') : []\n    }\n  } else {\n    form.value = {\n      id: null,\n      name: '',\n      reference_path: '',\n      tts_provider_id: selectedTTS.value || 1,\n      tags: []\n    }\n  }\n  dialogVisible.value = true\n}\n\nasync function pickLocalAudioForBase() {\n  const p = await native?.pickAudio?.()\n  if (!p) return\n  form.value.reference_path = p\n}\n\nfunction clearReferencePath() {\n  form.value.reference_path = ''\n}\n\n// ====== 音频编辑器相关 ======\nconst waveEditorKey = ref(null)\n\nfunction handleWaveReady(ws) {\n  console.log('WaveCellPro ready', ws)\n}\n\n// 处理音频编辑确认\nasync function handleWaveConfirm(payload) {\n  if (!editingVoice.value?.reference_path) return\n  \n  try {\n    const res = await processVoiceAudio(editingVoice.value.reference_path, {\n      speed: payload.speed,\n      volume: payload.volume,\n      start_ms: payload.start_ms,\n      end_ms: payload.end_ms,\n      silence_sec: payload.silence_sec,\n      current_ms: payload.current_ms\n    })\n    \n    if (res.code === 200) {\n      ElMessage.success('音频处理完成')\n      // 刷新编辑器\n      waveEditorKey.value = Date.now()\n    } else {\n      ElMessage.error(res.message || '音频处理失败')\n    }\n  } catch (e) {\n    console.error(e)\n    ElMessage.error('音频处理失败')\n  }\n}\n\nfunction toFileUrl(p) {\n  try { return native.pathToFileUrl(p) } catch { return '' }\n}\n\nfunction submitForm() {\n  formRef.value.validate(async (valid) => {\n    if (!valid) return\n    try {\n      const payload = {\n        name: form.value.name,\n        description: form.value.tags.length ? form.value.tags.join(',') : null,\n        tts_provider_id: form.value.tts_provider_id,\n        reference_path: form.value.reference_path || null\n      }\n\n      if (form.value.id) {\n        // 添加id\n        payload.id = form.value.id\n        await updateVoice(form.value.id, payload)\n        ElMessage.success('修改成功')\n      } else {\n        \n        await createVoice(payload)\n        ElMessage.success('创建成功')\n      }\n\n      dialogVisible.value = false\n      await loadVoices()\n    } catch (e) {\n      console.error(e)\n      ElMessage.error('操作失败')\n    }\n  })\n}\n\nasync function handleDelete(id) {\n  try {\n    audioPlayer.pause()\n    await deleteVoice(id)\n    ElMessage.success('删除成功')\n    await loadVoices()\n  } catch {\n    ElMessage.error('删除失败')\n  }\n}\n\nasync function handleBatchDelete() {\n  const ids = selectedIds.value\n  if (!ids.length) {\n    ElMessage.warning('请先选择要删除的音色')\n    return\n  }\n\n  audioPlayer.pause()\n\n  const results = await Promise.allSettled(\n    ids.map(id =>\n      deleteVoice(id).then(res => {\n        if (res?.code !== 200) throw new Error(res?.message || '删除失败')\n        return res\n      })\n    )\n  )\n\n  const failed = results.filter(r => r.status === 'rejected')\n  const successCount = ids.length - failed.length\n\n  if (failed.length === 0) {\n    ElMessage.success(`删除成功：${successCount} 个`)\n  } else {\n    ElMessage.warning(`删除完成：成功 ${successCount} 个，失败 ${failed.length} 个`)\n  }\n\n  await loadVoices()\n}\n\nonMounted(async () => {\n  await loadTTS()\n})\n\nconst tagSelectRef = ref(null)\n\nfunction handleTagChange() {\n  // 等 DOM 更新完再收起下拉框\n  setTimeout(() => {\n    tagSelectRef.value?.blur()\n  }, 0)\n}\n\nfunction handleFilterTagChange() {\n  setTimeout(() => {\n    filterSelectRef.value?.blur()\n  }, 0)\n}\n\nfunction resetFilters() {\n  searchName.value = ''\n  filterTags.value = []\n}\n\nasync function handleExportSelected() {\n  if (!selectedTTS.value) {\n    ElMessage.warning('请先选择 TTS 引擎')\n    return\n  }\n  const ids = selectedIds.value\n  if (ids.length === 0) {\n    ElMessage.warning('请先选择要导出的音色')\n    return\n  }\n\n  try {\n    const savePath = await native?.saveFile?.({\n      title: '导出选中音色',\n      defaultPath: 'voices_selected_export.zip',\n      filters: [{ name: 'ZIP 文件', extensions: ['zip'] }]\n    })\n\n    if (!savePath) return\n\n    const res = await exportVoices(selectedTTS.value, savePath, ids)\n    if (res.code === 200) {\n      ElMessage.success('导出成功：' + savePath)\n    } else {\n      ElMessage.error(res.message || '导出失败')\n    }\n  } catch (e) {\n    console.error(e)\n    ElMessage.error('导出失败')\n  }\n}\n\n// ====== 导入音色库弹窗 ======\nconst importDialogVisible = ref(false)\nconst importForm = ref({\n  zipPath: '',\n  targetDir: ''\n})\n\n// 获取默认音色保存目录\nfunction getDefaultVoiceDir() {\n  // 默认为用户目录下的 SonicVale/voices\n  const userHome = native?.getUserHome?.() || ''\n  return userHome ? `${userHome}/SonicVale/voices` : ''\n}\n\n// 选择导入的zip文件\nasync function pickImportZip() {\n  const zipPath = await native?.pickFile?.({\n    title: '选择音色库文件',\n    filters: [{ name: 'ZIP 文件', extensions: ['zip'] }]\n  })\n  if (zipPath) {\n    importForm.value.zipPath = zipPath\n  }\n}\n\n// 选择导入目标目录\nasync function pickImportDir() {\n  const dir = await native?.pickDirectory?.({\n    title: '选择音色保存目录'\n  })\n  if (dir) {\n    importForm.value.targetDir = dir\n  }\n}\n\n// 打开导入弹窗\nasync function handleImport() {\n  if (!selectedTTS.value) {\n    ElMessage.warning('请先选择 TTS 引擎')\n    return\n  }\n  \n  // 设置默认值\n  importForm.value = {\n    zipPath: '',\n    targetDir: getDefaultVoiceDir()\n  }\n  importDialogVisible.value = true\n}\n\n// 确认导入\nasync function confirmImport() {\n  if (!importForm.value.zipPath) {\n    ElMessage.warning('请选择音色库文件')\n    return\n  }\n  if (!importForm.value.targetDir) {\n    ElMessage.warning('请设置音色保存目录')\n    return\n  }\n\n  try {\n    const res = await importVoices(\n      selectedTTS.value, \n      importForm.value.zipPath, \n      importForm.value.targetDir\n    )\n    if (res.code === 200) {\n      const data = res.data\n      let msg = `导入完成：成功 ${data.success_count} 个`\n      if (data.skipped_count > 0) {\n        msg += `，跳过 ${data.skipped_count} 个（名称已存在）`\n      }\n      ElMessage.success(msg)\n      importDialogVisible.value = false\n      await loadVoices() // 刷新列表\n    } else {\n      ElMessage.error(res.message || '导入失败')\n    }\n  } catch (e) {\n    console.error(e)\n    ElMessage.error('导入失败')\n  }\n}\n\n// ====== 复制音色弹窗 ======\nconst copyDialogVisible = ref(false)\nconst copyFormRef = ref(null)\nconst copyForm = ref({\n  sourceId: null,\n  sourceName: '',\n  newName: '',\n  targetDir: ''\n})\n\nconst copyRules = {\n  newName: [{ required: true, message: '请输入新音色名称', trigger: 'blur' }]\n}\n\n// 打开复制弹窗\nfunction openCopyDialog(row) {\n  copyForm.value = {\n    sourceId: row.id,\n    sourceName: row.name,\n    newName: row.name + '_复制',\n    targetDir: ''\n  }\n  copyDialogVisible.value = true\n}\n\n// 选择复制目标目录\nasync function pickCopyTargetDir() {\n  const dir = await native?.pickDirectory?.({\n    title: '选择新音色保存目录'\n  })\n  if (dir) {\n    copyForm.value.targetDir = dir\n  }\n}\n\n// 确认复制\nasync function confirmCopy() {\n  if (!copyForm.value.newName) {\n    ElMessage.warning('请输入新音色名称')\n    return\n  }\n\n  try {\n    const res = await copyVoice(\n      copyForm.value.sourceId,\n      copyForm.value.newName,\n      copyForm.value.targetDir || null\n    )\n    if (res.code === 200) {\n      ElMessage.success('复制成功')\n      copyDialogVisible.value = false\n      await loadVoices() // 刷新列表\n    } else {\n      ElMessage.error(res.message || '复制失败')\n    }\n  } catch (e) {\n    console.error(e)\n    ElMessage.error('复制失败')\n  }\n}\n\n</script>\n\n<style scoped>\n.tag-hint {\n  font-size: 12px;\n  color: #409EFF; /* Element Plus 主色蓝 */\n  margin-bottom: 6px;\n}\n\n.page-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 14px;\n}\n.page-header h2 {\n  font-size: 20px;\n  font-weight: 700;\n  margin: 0;\n}\n.actions {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n.filter-bar {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  margin-bottom: 12px;\n}\n.filter-tags {\n  width: 260px;\n}\n.filter-search {\n  width: 220px;\n}\n.filter-result {\n  color: var(--el-text-color-secondary);\n  font-size: 12px;\n}\n.tts-select {\n  width: 240px;\n}\n.voice-table {\n  border-radius: 12px;\n  overflow: hidden;\n  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);\n}\n.tags-wrap {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n.path-ellipsis {\n  display: inline-block;\n  max-width: 380px;\n  vertical-align: middle;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  color: var(--el-text-color-regular);\n}\n.pick-line {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 8px;\n}\n.preview {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.path-text {\n  font-size: 12px;\n  color: var(--el-text-color-regular);\n  word-break: break-all;\n}\n.mb8 {\n  margin-bottom: 8px;\n}\n.form-hint {\n  font-size: 12px;\n  color: var(--el-text-color-secondary);\n  margin-top: 4px;\n}\n.wave-editor-wrap {\n  padding: 12px;\n  background: var(--el-fill-color-light);\n  border-radius: 8px;\n  border: 1px solid var(--el-border-color);\n}\n.audio-editor-info {\n  margin-bottom: 16px;\n  padding: 10px 12px;\n  background: var(--el-fill-color-light);\n  border-radius: 6px;\n}\n.audio-editor-label {\n  color: var(--el-text-color-secondary);\n  font-size: 13px;\n}\n.audio-editor-value {\n  color: var(--el-text-color-primary);\n  font-weight: 600;\n  font-size: 14px;\n}\n</style>\n"
  },
  {
    "path": "sonicvale-front/src/router/index.js",
    "content": "import { createRouter, createWebHashHistory  } from 'vue-router'\n\nconst routes = [\n  {\n    path: '/',\n    redirect: '/projects'\n  },\n  {\n    path: '/projects',\n    name: 'Projects',\n    component: () => import('../pages/ProjectList.vue')\n  },\n  {\n    path: '/config',\n    name: 'ConfigCenter',\n    component: () => import('../pages/ConfigCenter.vue')\n  },\n  {\n    path: '/voices',\n    name: 'VoiceManager',\n    component: () => import('../pages/VoiceManager.vue')\n  },\n  // 配音详情页面（带项目 ID 参数）\n  { \n    path: '/projects/:id/dubbing', \n    name: 'ProjectDubbingDetail', \n    component:  () => import('../pages/ProjectDubbingDetail.vue')\n  },\n  { path: '/prompts',\n    name: 'PromptManager', \n    component:() => import('../pages/PromptManager.vue') },    // 新增路由\n]\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes\n})\n\nexport default router\n"
  },
  {
    "path": "sonicvale-front/src/style.css",
    "content": ":root {\n  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;\n  line-height: 1.5;\n  font-weight: 400;\n  font-synthesis: none;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\nhtml {\n  color-scheme: light;\n}\n\nhtml.dark {\n  color-scheme: dark;\n}\n\nhtml,\nbody,\n#app {\n  height: 100%;\n}\n\nbody {\n  margin: 0;\n  min-width: 320px;\n  background: var(--el-bg-color-page);\n  color: var(--el-text-color-primary);\n}\n\n#app {\n  min-height: 100%;\n}\n"
  },
  {
    "path": "sonicvale-front/src/utils/utf8-or-gbk.js",
    "content": "// utf8-or-gbk.js\nexport function decodeUtf8OrGbk(arrayBuffer) {\n  const u8 = new Uint8Array(arrayBuffer);\n\n  function hasUtf8BOM(bytes) {\n    return bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF;\n  }\n\n  function tryDecode(buf, enc, fatal) {\n    try {\n      const dec = new TextDecoder(enc, fatal ? { fatal: true } : undefined);\n      return dec.decode(buf);\n    } catch {\n      return null;\n    }\n  }\n\n  // 1) BOM → UTF-8\n  if (hasUtf8BOM(u8)) {\n    const text = tryDecode(arrayBuffer, \"utf-8\", false);\n    return { encoding: \"utf-8\", text };\n  }\n\n  // 2) 先试 UTF-8（fatal 判非法序列）\n  const utf8 = tryDecode(arrayBuffer, \"utf-8\", true);\n  if (utf8 != null) return { encoding: \"utf-8\", text: utf8 };\n\n  // 3) 回退 GBK\n  const gbk = tryDecode(arrayBuffer, \"gbk\", false);\n  if (gbk != null) return { encoding: \"gbk\", text: gbk };\n\n  // 4) 都不行\n  throw new Error(\"该文件不是 UTF-8 或 GBK 编码，请先转换为 UTF-8/GBK 后再导入。\");\n}\n"
  },
  {
    "path": "sonicvale-front/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  base: './',  // 关键设置：让资源路径相对\n  plugins: [vue()],\n})\n"
  }
]