mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15811c820c |
@@ -10,10 +10,12 @@ coverage/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
resources/connectors/wechat/
|
resources/connectors/wechat/
|
||||||
|
resources/connectors/wechat-personal/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
services/share-card-worker/.wrangler/
|
services/share-card-worker/.wrangler/
|
||||||
services/share-card-worker/wrangler.local.jsonc
|
services/share-card-worker/wrangler.local.jsonc
|
||||||
|
skills-lock.json
|
||||||
docs/design/
|
docs/design/
|
||||||
docs/ui-redesign-plan.md
|
docs/ui-redesign-plan.md
|
||||||
docs/ui-redesign-spec.md
|
docs/ui-redesign-spec.md
|
||||||
@@ -21,3 +23,4 @@ AGENTS.md
|
|||||||
findings.md
|
findings.md
|
||||||
progress.md
|
progress.md
|
||||||
task_plan.md
|
task_plan.md
|
||||||
|
.agents
|
||||||
|
|||||||
+674
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2026 yincongcyincong
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# wechat_chatter / OneBot 第三方组件说明
|
||||||
|
|
||||||
|
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
||||||
|
|
||||||
|
- 项目:`yincongcyincong/wechat_chatter`
|
||||||
|
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
||||||
|
- 当前运行时版本:`v0.0.18`
|
||||||
|
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
||||||
|
- 许可证:GNU General Public License version 3(GPL-3.0)
|
||||||
|
- 上游版权:Copyright (C) 2026 yincongcyincong
|
||||||
|
- TraceMemo 修改日期:2026-08-17
|
||||||
|
|
||||||
|
## 集成方式
|
||||||
|
|
||||||
|
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
||||||
|
|
||||||
|
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
||||||
|
|
||||||
|
## 本地修改
|
||||||
|
|
||||||
|
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
||||||
|
|
||||||
|
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
||||||
|
- `src/main/services/personal-wechat-runtime-manager.ts`
|
||||||
|
|
||||||
|
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
||||||
|
|
||||||
|
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
||||||
@@ -25,6 +25,7 @@ extraResources:
|
|||||||
to: resources
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
|
- '!connectors/wechat-personal/**'
|
||||||
- from: docs/skill/tracememo-reader
|
- from: docs/skill/tracememo-reader
|
||||||
to: skill/tracememo-reader
|
to: skill/tracememo-reader
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
+2
-1
@@ -34,6 +34,7 @@
|
|||||||
"cp:env": "node scripts/ensure-env.cjs",
|
"cp:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:env": "node scripts/ensure-env.cjs",
|
"prepare:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||||
|
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||||
@@ -135,4 +136,4 @@
|
|||||||
"ffmpeg-static"
|
"ffmpeg-static"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ANCHOR='com.tencent.wechat.update'
|
||||||
|
ANCHOR_FILE='/etc/pf.anchors/wechat-update'
|
||||||
|
PF_CONF='/etc/pf.conf'
|
||||||
|
BACKUP_DIR='/etc/pf.conf.wechat-backups'
|
||||||
|
CACHE_DIR="${HOME}/Library/Caches/com.tencent.xinWeChat"
|
||||||
|
|
||||||
|
domains='dldir1.qq.com dldir2.qq.com dldir3.qq.com dldir1v6.qq.com'
|
||||||
|
|
||||||
|
die() { printf '%s\n' "error: $*" >&2; exit 1; }
|
||||||
|
need_root() { [ "$(id -u)" -eq 0 ] || die '请使用 sudo 执行此脚本'; }
|
||||||
|
|
||||||
|
resolve_ips() {
|
||||||
|
command -v dig >/dev/null 2>&1 || die '需要 dig(macOS 通常已内置)'
|
||||||
|
for domain in $domains; do
|
||||||
|
dig +short A "$domain"
|
||||||
|
dig +short AAAA "$domain"
|
||||||
|
done | awk '/^[0-9]+(\.[0-9]+){3}$/ || /^[0-9A-Fa-f:]+:[0-9A-Fa-f:]+/' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
need_root
|
||||||
|
ips="$(resolve_ips)"
|
||||||
|
[ -n "$ips" ] || die '域名解析没有返回 IP,未修改 PF'
|
||||||
|
mkdir -p /etc/pf.anchors "$BACKUP_DIR"
|
||||||
|
backup="$BACKUP_DIR/pf.conf.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp -p "$PF_CONF" "$backup"
|
||||||
|
{
|
||||||
|
printf 'table <wechat_update> persist { '
|
||||||
|
printf '%s' "$ips" | tr '\n' ' '
|
||||||
|
printf '}\nblock drop out quick to <wechat_update>\n'
|
||||||
|
} > "$ANCHOR_FILE"
|
||||||
|
if ! grep -Fq 'anchor "com.tencent.wechat.update"' "$PF_CONF"; then
|
||||||
|
printf '\n# TraceMemo: block WeChat update endpoints\nanchor "com.tencent.wechat.update"\nload anchor "com.tencent.wechat.update" from "/etc/pf.anchors/wechat-update"\n' >> "$PF_CONF"
|
||||||
|
fi
|
||||||
|
pfctl -vnf "$PF_CONF"
|
||||||
|
pfctl -f "$PF_CONF"
|
||||||
|
pfctl -E >/dev/null 2>&1 || true
|
||||||
|
printf '已启用微信更新拦截,规则备份:%s\n' "$backup"
|
||||||
|
printf '当前 IP:\n%s\n' "$ips"
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
need_root
|
||||||
|
pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
|
||||||
|
printf '已清空微信更新 PF anchor。要完全移除配置行,请从 /etc/pf.conf 删除 TraceMemo 标记的三行。\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
pfctl -s info 2>&1 | head -20
|
||||||
|
printf '\n微信更新 anchor:\n'
|
||||||
|
pfctl -a "$ANCHOR" -sr 2>&1 || true
|
||||||
|
printf '\n缓存目录:\n%s\n' "$CACHE_DIR"
|
||||||
|
ls -ldO "$CACHE_DIR" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
enable) enable ;;
|
||||||
|
disable) disable ;;
|
||||||
|
status) status ;;
|
||||||
|
*) die "用法:sudo $0 {enable|disable|status}" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* wechat_chatter runtime integration
|
||||||
|
*
|
||||||
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
|
* Runtime version: v0.0.18
|
||||||
|
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
||||||
|
*
|
||||||
|
* This file applies local compatibility patches to the upstream
|
||||||
|
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const release = 'v0.0.18'
|
||||||
|
const asset = 'onebot_mac_arm64.tar.gz'
|
||||||
|
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const outputDir = path.join(
|
||||||
|
projectRoot,
|
||||||
|
'resources',
|
||||||
|
'connectors',
|
||||||
|
'wechat-personal',
|
||||||
|
'darwin-arm64'
|
||||||
|
)
|
||||||
|
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
||||||
|
const appleSilicon =
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
||||||
|
|
||||||
|
if (!appleSilicon) {
|
||||||
|
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
let archiveReady = false
|
||||||
|
if (fs.existsSync(archive)) {
|
||||||
|
try {
|
||||||
|
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
||||||
|
archiveReady = true
|
||||||
|
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
||||||
|
} catch {
|
||||||
|
// The archive is partial or invalid; curl will resume it below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!archiveReady) {
|
||||||
|
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
||||||
|
execFileSync(
|
||||||
|
'/usr/bin/curl',
|
||||||
|
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
||||||
|
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
||||||
|
try {
|
||||||
|
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(extractionDir, 'onebot', 'script.js'),
|
||||||
|
path.join(outputDir, 'onebot', 'script.js')
|
||||||
|
)
|
||||||
|
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
||||||
|
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
||||||
|
fs.chmodSync(stagedExecutable, 0o755)
|
||||||
|
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(extractionDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const executable = path.join(outputDir, 'onebot', 'onebot')
|
||||||
|
const script = path.join(outputDir, 'onebot', 'script.js')
|
||||||
|
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
||||||
|
for (const required of [executable, script, config]) {
|
||||||
|
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonPackages = path.join(outputDir, 'python')
|
||||||
|
const pilkPackage = path.join(pythonPackages, 'pilk')
|
||||||
|
if (!fs.existsSync(pilkPackage)) {
|
||||||
|
console.log('[wechat-personal] 安装项目内语音编码器 pilk')
|
||||||
|
fs.mkdirSync(pythonPackages, { recursive: true })
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
'python3',
|
||||||
|
[
|
||||||
|
'-m',
|
||||||
|
'pip',
|
||||||
|
'install',
|
||||||
|
'--disable-pip-version-check',
|
||||||
|
'--no-compile',
|
||||||
|
'--target',
|
||||||
|
pythonPackages,
|
||||||
|
'pilk==0.2.4'
|
||||||
|
],
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
console.warn('[wechat-personal] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPerSendPayload(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||||
|
|
||||||
|
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||||
|
const patchedDeclarations =
|
||||||
|
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||||
|
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
|
triggerX1Payload.writeByteArray(payloadData);
|
||||||
|
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
|
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||||
|
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||||
|
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
|
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||||
|
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||||
|
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
|
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||||
|
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||||
|
|
||||||
|
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||||
|
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||||
|
}
|
||||||
|
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||||
|
source = source.replace(
|
||||||
|
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||||
|
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||||
|
)
|
||||||
|
source = source.replace(
|
||||||
|
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||||
|
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||||
|
)
|
||||||
|
source = source.replace(
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||||
|
)
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchImageHookReadiness(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (
|
||||||
|
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||||
|
source.includes('图片上传 Hook Setup Complete')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||||
|
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||||
|
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||||
|
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||||
|
\t\t\t}
|
||||||
|
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||||
|
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
||||||
|
source = source.replace(original, patched)
|
||||||
|
source = source.replace(
|
||||||
|
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||||
|
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||||
|
)
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchWechatCoreModuleBase(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('WeChat core module base:')) return
|
||||||
|
const initMarker = 'function initAddresses() {'
|
||||||
|
const initIndex = source.indexOf(initMarker)
|
||||||
|
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||||
|
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
||||||
|
}
|
||||||
|
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||||
|
var module = Process.enumerateModules().find(function(m) {
|
||||||
|
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||||
|
});
|
||||||
|
if (!module) {
|
||||||
|
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||||
|
}
|
||||||
|
var moduleBase = module.base;
|
||||||
|
var baseAddr = moduleBase;
|
||||||
|
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||||
|
setImmediate(initAddresses);
|
||||||
|
|
||||||
|
`
|
||||||
|
source = patchedHeader + source.slice(initIndex)
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModifiedWorkNotice(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||||
|
|
||||||
|
const notice = `/*
|
||||||
|
* TraceMemo wechat_chatter compatibility modifications
|
||||||
|
* Modified: 2026-08-17
|
||||||
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
|
* Runtime version: v0.0.18
|
||||||
|
* License: GNU General Public License version 3 (GPL-3.0)
|
||||||
|
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
||||||
|
* These modifications are not provided by the upstream author.
|
||||||
|
*/
|
||||||
|
|
||||||
|
`
|
||||||
|
source = notice + source
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
||||||
|
}
|
||||||
|
|
||||||
|
patchWechatCoreModuleBase(script)
|
||||||
|
patchPerSendPayload(script)
|
||||||
|
patchImageHookReadiness(script)
|
||||||
|
addModifiedWorkNotice(script)
|
||||||
|
fs.chmodSync(executable, 0o755)
|
||||||
|
console.log('[wechat-personal] 运行时准备完成')
|
||||||
+93
-2
@@ -16,9 +16,8 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
protocol
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { dirname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { existsSync, promises as fsPromises } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
import { extname } from 'path'
|
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
@@ -112,6 +111,15 @@ import {
|
|||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
|
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
|
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
||||||
|
import type { PersonalWechatSendRequest } from '../shared/personal-wechat'
|
||||||
|
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||||
|
import type {
|
||||||
|
ListTextToSpeechVoicesRequest,
|
||||||
|
SaveTextToSpeechSettingsRequest,
|
||||||
|
SynthesizeTextToSpeechRequest
|
||||||
|
} from '../shared/text-to-speech'
|
||||||
import { appLogger } from './app-logger'
|
import { appLogger } from './app-logger'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import { appUpdateService } from './services/app-update-service'
|
import { appUpdateService } from './services/app-update-service'
|
||||||
@@ -158,6 +166,8 @@ let videoAssetService: VideoAssetService | null = null
|
|||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
|
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||||
|
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||||
@@ -591,6 +601,11 @@ app.whenReady().then(async () => {
|
|||||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
personalWechatRuntimeManager.setProgressListener((status) => {
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
|
||||||
|
}
|
||||||
|
})
|
||||||
protocol.handle('wxe-media', async (request) => {
|
protocol.handle('wxe-media', async (request) => {
|
||||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||||
if (!filePath) return new Response('Not found', { status: 404 })
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
@@ -1135,6 +1150,28 @@ app.whenReady().then(async () => {
|
|||||||
aiProviderService.migrateLegacy(config)
|
aiProviderService.migrateLegacy(config)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle('tts:getSettings', () => textToSpeechSettingsService.get())
|
||||||
|
ipcMain.handle('tts:saveSettings', (_, request: SaveTextToSpeechSettingsRequest) =>
|
||||||
|
textToSpeechSettingsService.save(request)
|
||||||
|
)
|
||||||
|
ipcMain.handle('tts:listVoices', (_, request?: ListTextToSpeechVoicesRequest) =>
|
||||||
|
textToSpeechSettingsService.listVoices(request)
|
||||||
|
)
|
||||||
|
ipcMain.handle('tts:synthesize', (_, request: SynthesizeTextToSpeechRequest) =>
|
||||||
|
textToSpeechSettingsService.synthesize(request)
|
||||||
|
)
|
||||||
|
ipcMain.handle('tts:removeGeneratedAudio', (_, filePath: string) =>
|
||||||
|
textToSpeechSettingsService.removeGeneratedAudio(String(filePath || ''))
|
||||||
|
)
|
||||||
|
ipcMain.handle('tts:openApiKeys', async () => {
|
||||||
|
try {
|
||||||
|
await shell.openExternal('https://fish.audio/app/api-keys/')
|
||||||
|
return { success: true }
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '无法打开 Fish Audio API Key 页面' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
||||||
try {
|
try {
|
||||||
if (typeof imageSource !== 'string' || !imageSource) {
|
if (typeof imageSource !== 'string' || !imageSource) {
|
||||||
@@ -1702,6 +1739,59 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||||
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||||
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||||
|
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
||||||
|
ipcMain.handle('wechat-personal:getRuntimeStatus', () =>
|
||||||
|
personalWechatRuntimeManager.getStatus()
|
||||||
|
)
|
||||||
|
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
||||||
|
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
||||||
|
success: personalWechatRuntimeManager.cancelDownload()
|
||||||
|
}))
|
||||||
|
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
||||||
|
await personalWechatSendService.terminate()
|
||||||
|
return personalWechatRuntimeManager.remove()
|
||||||
|
})
|
||||||
|
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
||||||
|
const status = await personalWechatRuntimeManager.getStatus()
|
||||||
|
const directory = status.directory || personalWechatRuntimeManager.directory
|
||||||
|
await fsPromises.mkdir(directory, { recursive: true })
|
||||||
|
const error = await shell.openPath(directory)
|
||||||
|
return error ? { success: false, error } : { success: true }
|
||||||
|
})
|
||||||
|
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
||||||
|
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
|
||||||
|
personalWechatSendService.send(request)
|
||||||
|
)
|
||||||
|
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
|
title: '选择要通过个人微信测试发送的图片',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||||
|
{ name: '所有文件', extensions: ['*'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths[0]) return { canceled: true }
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
path: result.filePaths[0],
|
||||||
|
name: basename(result.filePaths[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
|
title: '选择要通过个人微信测试发送的语音',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
|
||||||
|
{ name: '所有文件', extensions: ['*'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths[0]) return { canceled: true }
|
||||||
|
return { canceled: false, path: result.filePaths[0], name: basename(result.filePaths[0]) }
|
||||||
|
})
|
||||||
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
@@ -1761,6 +1851,7 @@ app.on('before-quit', (event) => {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
agentHubService.stop()
|
agentHubService.stop()
|
||||||
|
personalWechatSendService.stop()
|
||||||
flushBootstrapCacheWritesSync()
|
flushBootstrapCacheWritesSync()
|
||||||
const [, nativeCallsDrained] = await Promise.all([
|
const [, nativeCallsDrained] = await Promise.all([
|
||||||
apiServer.stop().catch(() => undefined),
|
apiServer.stop().catch(() => undefined),
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { app, net } from 'electron'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import type {
|
||||||
|
PersonalWechatRuntimeDownloadResult,
|
||||||
|
PersonalWechatRuntimeStatus
|
||||||
|
} from '../../shared/personal-wechat-runtime'
|
||||||
|
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const RUNTIME_VERSION = 'v0.0.18'
|
||||||
|
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
|
||||||
|
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
|
||||||
|
const ARCHIVE_SIZE = 66_599_785
|
||||||
|
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
|
||||||
|
|
||||||
|
function patchPerSendPayload(scriptPath: string): void {
|
||||||
|
let source = readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||||
|
|
||||||
|
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||||
|
const patchedDeclarations =
|
||||||
|
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||||
|
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
|
triggerX1Payload.writeByteArray(payloadData);
|
||||||
|
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
|
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||||
|
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||||
|
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
|
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||||
|
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||||
|
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
|
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||||
|
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||||
|
|
||||||
|
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||||
|
throw new Error('下载的发送组件与当前应用不兼容')
|
||||||
|
}
|
||||||
|
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||||
|
source = source.replace(
|
||||||
|
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||||
|
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||||
|
)
|
||||||
|
source = source.replace(
|
||||||
|
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||||
|
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||||
|
)
|
||||||
|
source = source.replace(
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||||
|
)
|
||||||
|
writeFileSync(scriptPath, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchImageHookReadiness(scriptPath: string): void {
|
||||||
|
let source = readFileSync(scriptPath, 'utf8')
|
||||||
|
if (
|
||||||
|
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||||
|
source.includes('图片上传 Hook Setup Complete')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||||
|
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||||
|
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||||
|
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||||
|
\t\t\t}
|
||||||
|
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||||
|
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
|
||||||
|
source = source.replace(original, patched)
|
||||||
|
source = source.replace(
|
||||||
|
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||||
|
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||||
|
)
|
||||||
|
writeFileSync(scriptPath, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchWechatCoreModuleBase(scriptPath: string): void {
|
||||||
|
let source = readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('WeChat core module base:')) return
|
||||||
|
const initMarker = 'function initAddresses() {'
|
||||||
|
const initIndex = source.indexOf(initMarker)
|
||||||
|
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||||
|
throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||||
|
}
|
||||||
|
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||||
|
var module = Process.enumerateModules().find(function(m) {
|
||||||
|
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||||
|
});
|
||||||
|
if (!module) {
|
||||||
|
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||||
|
}
|
||||||
|
var moduleBase = module.base;
|
||||||
|
var baseAddr = moduleBase;
|
||||||
|
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||||
|
setImmediate(initAddresses);
|
||||||
|
|
||||||
|
`
|
||||||
|
source = patchedHeader + source.slice(initIndex)
|
||||||
|
writeFileSync(scriptPath, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModifiedWorkNotice(scriptPath: string): void {
|
||||||
|
let source = readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||||
|
source = `/*
|
||||||
|
* TraceMemo wechat_chatter compatibility modifications
|
||||||
|
* Modified: 2026-08-17
|
||||||
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
|
* Runtime version: v0.0.18
|
||||||
|
* License: GNU General Public License version 3 (GPL-3.0)
|
||||||
|
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
||||||
|
* These modifications are not provided by the upstream author.
|
||||||
|
*/
|
||||||
|
|
||||||
|
${source}`
|
||||||
|
writeFileSync(scriptPath, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PersonalWechatRuntimeManager {
|
||||||
|
private downloadController: AbortController | null = null
|
||||||
|
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
|
||||||
|
private downloadedBytes = 0
|
||||||
|
private lastProgressAt = 0
|
||||||
|
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
|
||||||
|
|
||||||
|
get directory(): string {
|
||||||
|
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private get archivePath(): string {
|
||||||
|
return join(
|
||||||
|
app.getPath('userData'),
|
||||||
|
'downloads',
|
||||||
|
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
|
||||||
|
this.progressListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
return this.buildStatus(
|
||||||
|
'unsupported',
|
||||||
|
0,
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'Windows 暂不支持个人微信发送组件'
|
||||||
|
: `当前系统暂不支持个人微信发送组件:${process.platform} ${process.arch}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||||
|
|
||||||
|
const runtime = findPersonalWechatRuntime()
|
||||||
|
if (runtime) {
|
||||||
|
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPartialInstall = await this.hasPartialInstall()
|
||||||
|
return this.buildStatus(
|
||||||
|
hasPartialInstall ? 'invalid' : 'missing',
|
||||||
|
0,
|
||||||
|
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
|
||||||
|
hasPartialInstall ? this.directory : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
download(): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
|
||||||
|
}
|
||||||
|
if (this.downloadPromise) return this.downloadPromise
|
||||||
|
this.downloadedBytes = 0
|
||||||
|
this.downloadController = new AbortController()
|
||||||
|
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
|
||||||
|
this.downloadPromise = null
|
||||||
|
this.downloadController = null
|
||||||
|
})
|
||||||
|
return this.downloadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelDownload(): boolean {
|
||||||
|
if (!this.downloadController) return false
|
||||||
|
this.downloadController.abort()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(): Promise<PersonalWechatRuntimeStatus> {
|
||||||
|
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||||
|
await Promise.all([
|
||||||
|
rm(this.directory, { recursive: true, force: true }),
|
||||||
|
rm(this.archivePath, { force: true }),
|
||||||
|
rm(`${this.archivePath}.partial`, { force: true })
|
||||||
|
])
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||||
|
const archive = this.archivePath
|
||||||
|
const downloadsDirectory = dirname(archive)
|
||||||
|
const partial = `${archive}.partial`
|
||||||
|
let extractionDirectory = ''
|
||||||
|
let stagedDirectory = ''
|
||||||
|
try {
|
||||||
|
await mkdir(downloadsDirectory, { recursive: true })
|
||||||
|
await rm(partial, { force: true })
|
||||||
|
const response = await net.fetch(ARCHIVE_URL, { signal })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await open(partial, 'w')
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
try {
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
|
||||||
|
const chunk = Buffer.from(value)
|
||||||
|
await handle.write(chunk)
|
||||||
|
hash.update(chunk)
|
||||||
|
this.downloadedBytes += chunk.length
|
||||||
|
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
|
||||||
|
throw new Error('发送组件校验失败,请重新下载')
|
||||||
|
}
|
||||||
|
await rm(archive, { force: true })
|
||||||
|
await rename(partial, archive)
|
||||||
|
|
||||||
|
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
|
||||||
|
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
|
||||||
|
stagedDirectory = `${this.directory}.installing-${process.pid}`
|
||||||
|
await rm(stagedDirectory, { recursive: true, force: true })
|
||||||
|
await mkdir(dirname(stagedDirectory), { recursive: true })
|
||||||
|
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
|
||||||
|
|
||||||
|
const sourceOneBot = join(extractionDirectory, 'onebot')
|
||||||
|
const sourceVersions = join(extractionDirectory, 'wechat_version')
|
||||||
|
await Promise.all([
|
||||||
|
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
}),
|
||||||
|
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
|
||||||
|
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
|
||||||
|
])
|
||||||
|
|
||||||
|
const executable = join(stagedDirectory, 'onebot', 'onebot')
|
||||||
|
const script = join(stagedDirectory, 'onebot', 'script.js')
|
||||||
|
patchWechatCoreModuleBase(script)
|
||||||
|
patchPerSendPayload(script)
|
||||||
|
patchImageHookReadiness(script)
|
||||||
|
addModifiedWorkNotice(script)
|
||||||
|
await chmod(executable, 0o755)
|
||||||
|
|
||||||
|
const pythonPackages = join(stagedDirectory, 'python')
|
||||||
|
await mkdir(pythonPackages, { recursive: true })
|
||||||
|
try {
|
||||||
|
await execFileAsync('/usr/bin/env', [
|
||||||
|
'python3',
|
||||||
|
'-m',
|
||||||
|
'pip',
|
||||||
|
'install',
|
||||||
|
'--disable-pip-version-check',
|
||||||
|
'--no-compile',
|
||||||
|
'--target',
|
||||||
|
pythonPackages,
|
||||||
|
'pilk==0.2.4'
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[PersonalWechatRuntime] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const required of [
|
||||||
|
executable,
|
||||||
|
script,
|
||||||
|
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
||||||
|
]) {
|
||||||
|
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(this.directory, { recursive: true, force: true })
|
||||||
|
await rename(stagedDirectory, this.directory)
|
||||||
|
stagedDirectory = ''
|
||||||
|
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
|
||||||
|
this.reportProgress(status, true)
|
||||||
|
return { success: true, status }
|
||||||
|
} catch (error) {
|
||||||
|
const cancelled = signal.aborted
|
||||||
|
const message = cancelled
|
||||||
|
? '发送组件下载已取消'
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error)
|
||||||
|
await rm(partial, { force: true })
|
||||||
|
const status = this.buildStatus(
|
||||||
|
cancelled ? 'missing' : 'error',
|
||||||
|
this.downloadedBytes,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
this.reportProgress(status, true)
|
||||||
|
return { success: false, status, error: message }
|
||||||
|
} finally {
|
||||||
|
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
|
||||||
|
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasPartialInstall(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(this.directory)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupported(): boolean {
|
||||||
|
return process.platform === 'darwin' && process.arch === 'arm64'
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatus(
|
||||||
|
state: PersonalWechatRuntimeStatus['state'],
|
||||||
|
downloadedBytes: number,
|
||||||
|
error?: string,
|
||||||
|
directory?: string
|
||||||
|
): PersonalWechatRuntimeStatus {
|
||||||
|
return {
|
||||||
|
version: RUNTIME_VERSION,
|
||||||
|
state,
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes: ARCHIVE_SIZE,
|
||||||
|
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
|
||||||
|
platform: process.platform,
|
||||||
|
architecture: process.arch,
|
||||||
|
supported: this.isSupported(),
|
||||||
|
removable: directory === this.directory,
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
|
...(error ? { error } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && now - this.lastProgressAt < 100) return
|
||||||
|
this.lastProgressAt = now
|
||||||
|
this.progressListener?.(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { execFile, spawn, type ChildProcess } from 'child_process'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
||||||
|
import { createConnection } from 'net'
|
||||||
|
import { homedir } from 'os'
|
||||||
|
import { delimiter, dirname, join, sep } from 'path'
|
||||||
|
import ffmpegStaticPath from 'ffmpeg-static'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import type {
|
||||||
|
PersonalWechatSendRequest,
|
||||||
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
|
} from '../../shared/personal-wechat'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const DEFAULT_HOST = '127.0.0.1:58080'
|
||||||
|
const START_TIMEOUT_MS = 20_000
|
||||||
|
const REQUEST_TIMEOUT_MS = 20_000
|
||||||
|
const STOP_TIMEOUT_MS = 3_000
|
||||||
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||||
|
const MAX_VOICE_BYTES = 20 * 1024 * 1024
|
||||||
|
const WECHAT_APP_PATH = '/Applications/WeChat.app'
|
||||||
|
const WECHAT_FILES_ROOT = join(
|
||||||
|
homedir(),
|
||||||
|
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface RuntimeLayout {
|
||||||
|
root: string
|
||||||
|
executable: string
|
||||||
|
workingDirectory: string
|
||||||
|
configDirectory: string
|
||||||
|
logPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonalWechatHookReadiness = 'unknown' | 'initializing' | 'ready' | 'failed'
|
||||||
|
|
||||||
|
export function parsePersonalWechatHookLog(log: string): {
|
||||||
|
readiness: PersonalWechatHookReadiness
|
||||||
|
attached: boolean
|
||||||
|
baseAddress?: string
|
||||||
|
textHookInstalled: boolean
|
||||||
|
textHookReady: boolean
|
||||||
|
imageHookInstalled: boolean
|
||||||
|
imageHookReady: boolean
|
||||||
|
messageListenerReady: boolean
|
||||||
|
boundWechatPid?: number
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
let readiness: PersonalWechatHookReadiness = 'unknown'
|
||||||
|
let attached = false
|
||||||
|
let baseAddress: string | undefined
|
||||||
|
let textHookInstalled = false
|
||||||
|
let textHookReady = false
|
||||||
|
let imageHookInstalled = false
|
||||||
|
let imageHookReady = false
|
||||||
|
let messageListenerReady = false
|
||||||
|
let boundWechatPid: number | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
for (const line of log.split(/\r?\n/)) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as {
|
||||||
|
payload?: string
|
||||||
|
err?: string
|
||||||
|
message?: string
|
||||||
|
PID?: number
|
||||||
|
type?: string
|
||||||
|
result?: string | number
|
||||||
|
}
|
||||||
|
const text = `${entry.payload || ''} ${entry.err || ''} ${entry.message || ''}`
|
||||||
|
// wechat_chatter writes the task receipt and its result as two separate
|
||||||
|
// JSON log records: the first has type=send_image, while the next has
|
||||||
|
// the result and the Chinese result message but no type field.
|
||||||
|
if (
|
||||||
|
(entry.type === 'send_image' || text.includes('发送图片任务执行结果')) &&
|
||||||
|
String(entry.result) === '1'
|
||||||
|
) {
|
||||||
|
imageHookInstalled = true
|
||||||
|
imageHookReady = true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(entry.type === 'image' || text.includes('上传图片任务执行结果')) &&
|
||||||
|
String(entry.result) === '0'
|
||||||
|
) {
|
||||||
|
imageHookInstalled = true
|
||||||
|
}
|
||||||
|
if (text.includes('使用指定的微信进程 PID')) {
|
||||||
|
readiness = 'unknown'
|
||||||
|
attached = false
|
||||||
|
baseAddress = undefined
|
||||||
|
textHookInstalled = false
|
||||||
|
textHookReady = false
|
||||||
|
imageHookInstalled = false
|
||||||
|
imageHookReady = false
|
||||||
|
messageListenerReady = false
|
||||||
|
boundWechatPid = Number(entry.PID) || undefined
|
||||||
|
error = undefined
|
||||||
|
} else if (
|
||||||
|
text.includes("Cannot find 'req2buf' keyword") ||
|
||||||
|
text.includes('Attach 失败') ||
|
||||||
|
text.includes('unable to intercept function')
|
||||||
|
) {
|
||||||
|
readiness = 'failed'
|
||||||
|
error = entry.err || entry.message
|
||||||
|
} else if (text.includes('成功 Attach 微信进程')) {
|
||||||
|
attached = true
|
||||||
|
boundWechatPid = Number(entry.PID) || boundWechatPid
|
||||||
|
} else if (text.includes('Base address from range:')) {
|
||||||
|
baseAddress = text.match(/Base address from range:\s*(0x[0-9a-f]+)/i)?.[1]
|
||||||
|
} else if (text.includes('WeChat core module base:')) {
|
||||||
|
baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1]
|
||||||
|
} else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) {
|
||||||
|
readiness = 'initializing'
|
||||||
|
error = '微信底层 Hook 尚未就绪,消息没有发出'
|
||||||
|
} else if (text.includes('捕获到 StartTask 调用')) {
|
||||||
|
readiness = 'ready'
|
||||||
|
textHookReady = true
|
||||||
|
error = undefined
|
||||||
|
} else if (text.includes('Dynamic Text Message Setup Complete')) {
|
||||||
|
textHookInstalled = true
|
||||||
|
if (readiness === 'unknown') readiness = 'initializing'
|
||||||
|
} else if (text.includes('捕获到图片上传上下文')) {
|
||||||
|
imageHookReady = true
|
||||||
|
} else if (text.includes('图片上传 Hook Setup Complete')) {
|
||||||
|
imageHookInstalled = true
|
||||||
|
} else if (text.includes('HTTP 服务启动在')) {
|
||||||
|
messageListenerReady = true
|
||||||
|
} else if (text.includes('发送数据')) {
|
||||||
|
messageListenerReady = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON or partially written log lines.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
readiness,
|
||||||
|
attached,
|
||||||
|
...(baseAddress ? { baseAddress } : {}),
|
||||||
|
textHookInstalled,
|
||||||
|
textHookReady,
|
||||||
|
imageHookInstalled,
|
||||||
|
imageHookReady,
|
||||||
|
messageListenerReady,
|
||||||
|
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||||
|
...(error ? { error } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreflightResult {
|
||||||
|
status: PersonalWechatSenderStatus
|
||||||
|
runtime?: RuntimeLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConfigFileName(version: string): string {
|
||||||
|
return `${version.replace(/\./g, '_')}_mac.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeCandidates(): string[] {
|
||||||
|
const override = String(process.env['WECHAT_CHATTER_RUNTIME_DIR'] || '').trim()
|
||||||
|
const relative = ['connectors', 'wechat-personal', `${process.platform}-${process.arch}`]
|
||||||
|
const downloaded = join(app.getPath('userData'), ...relative)
|
||||||
|
const packaged = join(process.resourcesPath, 'resources', ...relative)
|
||||||
|
const development = join(app.getAppPath(), 'resources', ...relative)
|
||||||
|
const ordered = isPackagedRuntime()
|
||||||
|
? [downloaded, packaged, development]
|
||||||
|
: [downloaded, development, packaged]
|
||||||
|
return override ? [override, ...ordered] : ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPersonalWechatRuntime(candidates = runtimeCandidates()): RuntimeLayout | null {
|
||||||
|
for (const root of candidates) {
|
||||||
|
const nestedExecutable = join(root, 'onebot', 'onebot')
|
||||||
|
const flatExecutable = join(root, 'onebot')
|
||||||
|
if (existsSync(nestedExecutable) && existsSync(join(root, 'onebot', 'script.js'))) {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
executable: nestedExecutable,
|
||||||
|
workingDirectory: join(root, 'onebot'),
|
||||||
|
configDirectory: join(root, 'wechat_version'),
|
||||||
|
logPath: join(root, 'onebot', 'log', 'macos.log')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existsSync(flatExecutable) && existsSync(join(root, 'script.js'))) {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
executable: flatExecutable,
|
||||||
|
workingDirectory: root,
|
||||||
|
configDirectory: join(root, 'wechat_version'),
|
||||||
|
logPath: join(root, 'log', 'macos.log')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPersonalWechatOneBotRequest(
|
||||||
|
request: PersonalWechatSendRequest,
|
||||||
|
fileBase64?: string
|
||||||
|
): {
|
||||||
|
endpoint: string
|
||||||
|
body: Record<string, unknown>
|
||||||
|
} {
|
||||||
|
const target = request.to.trim()
|
||||||
|
const isGroup = request.isGroup || target.endsWith('@chatroom')
|
||||||
|
const message =
|
||||||
|
request.type === 'text'
|
||||||
|
? [{ type: 'text', data: { text: request.text.trim() } }]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: request.type === 'voice' ? 'record' : 'image',
|
||||||
|
data: { file: `base64://${fileBase64 || ''}` }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
endpoint: isGroup ? '/send_group_msg' : '/send_private_msg',
|
||||||
|
body: {
|
||||||
|
...(isGroup ? { group_id: target } : { user_id: target }),
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimePath(): string {
|
||||||
|
const existing = String(process.env['PATH'] || '')
|
||||||
|
const bundledFfmpeg = String(ffmpegStaticPath || '')
|
||||||
|
.replace('app.asar', 'app.asar.unpacked')
|
||||||
|
.trim()
|
||||||
|
if (!bundledFfmpeg || !existsSync(bundledFfmpeg)) return existing
|
||||||
|
return [dirname(bundledFfmpeg), existing].filter(Boolean).join(delimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimePythonPath(runtimeRoot: string): string {
|
||||||
|
return [join(runtimeRoot, 'python'), String(process.env['PYTHONPATH'] || '')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(delimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer {
|
||||||
|
const header = Buffer.alloc(44)
|
||||||
|
header.write('RIFF', 0)
|
||||||
|
header.writeUInt32LE(36 + pcm.length, 4)
|
||||||
|
header.write('WAVE', 8)
|
||||||
|
header.write('fmt ', 12)
|
||||||
|
header.writeUInt32LE(16, 16)
|
||||||
|
header.writeUInt16LE(1, 20)
|
||||||
|
header.writeUInt16LE(channels, 22)
|
||||||
|
header.writeUInt32LE(sampleRate, 24)
|
||||||
|
header.writeUInt32LE(sampleRate * channels * 2, 28)
|
||||||
|
header.writeUInt16LE(channels * 2, 32)
|
||||||
|
header.writeUInt16LE(16, 34)
|
||||||
|
header.write('data', 36)
|
||||||
|
header.writeUInt32LE(pcm.length, 40)
|
||||||
|
return Buffer.concat([header, pcm])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareVoiceFile(filePath: string): Promise<Buffer> {
|
||||||
|
const data = readFileSync(filePath)
|
||||||
|
if (!data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) return data
|
||||||
|
const decoded = await new SilkAudioDecoder().decode({
|
||||||
|
data,
|
||||||
|
codec: 'silk',
|
||||||
|
sourceHash: createHash('sha256').update(data).digest('hex')
|
||||||
|
})
|
||||||
|
return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWechatVersion(): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
||||||
|
'-c',
|
||||||
|
'Print :WeChatBundleVersion',
|
||||||
|
join(WECHAT_APP_PATH, 'Contents', 'Info.plist')
|
||||||
|
])
|
||||||
|
return stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWechatPid(): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat'])
|
||||||
|
const pid = Number(stdout.trim().split(/\s+/)[0])
|
||||||
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate the current account's temporary image directory required by the upstream hook. */
|
||||||
|
export function findWechatImagePath(
|
||||||
|
root = WECHAT_FILES_ROOT,
|
||||||
|
now = new Date()
|
||||||
|
): string | undefined {
|
||||||
|
try {
|
||||||
|
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const candidates: Array<{ path: string; mtime: number }> = []
|
||||||
|
const fallbacks: Array<{ path: string; mtime: number }> = []
|
||||||
|
for (const account of readdirSync(root)) {
|
||||||
|
const tempRoot = join(root, account, 'temp')
|
||||||
|
if (!existsSync(tempRoot)) continue
|
||||||
|
fallbacks.push({
|
||||||
|
path: join(tempRoot, 'ImageTemp', month),
|
||||||
|
mtime: statSync(tempRoot).mtimeMs
|
||||||
|
})
|
||||||
|
const imageTempPath = join(tempRoot, 'ImageTemp', month)
|
||||||
|
if (existsSync(imageTempPath)) {
|
||||||
|
candidates.push({ path: imageTempPath, mtime: statSync(imageTempPath).mtimeMs })
|
||||||
|
}
|
||||||
|
for (const tempId of readdirSync(tempRoot)) {
|
||||||
|
const imagePath = join(tempRoot, tempId, month, 'Img')
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
candidates.push({ path: imagePath, mtime: statSync(imagePath).mtimeMs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
fallbacks.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
const selected = candidates[0]?.path || fallbacks[0]?.path
|
||||||
|
return selected ? `${selected}${sep}` : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSipDisabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||||
|
return `${stdout}\n${stderr}`.toLowerCase().includes('disabled')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OneBotProcessInfo {
|
||||||
|
pid: number
|
||||||
|
boundWechatPid?: number
|
||||||
|
imagePath?: string
|
||||||
|
command: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOneBotProcessInfo(): Promise<OneBotProcessInfo | undefined> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('/usr/sbin/lsof', [
|
||||||
|
'-nP',
|
||||||
|
'-t',
|
||||||
|
`-iTCP:${DEFAULT_HOST.split(':')[1]}`,
|
||||||
|
'-sTCP:LISTEN'
|
||||||
|
])
|
||||||
|
const pid = Number(stdout.trim().split(/\s+/)[0])
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return undefined
|
||||||
|
const { stdout: commandOutput } = await execFileAsync('/bin/ps', [
|
||||||
|
'-p',
|
||||||
|
String(pid),
|
||||||
|
'-o',
|
||||||
|
'command='
|
||||||
|
])
|
||||||
|
const command = commandOutput.trim()
|
||||||
|
if (!/(^|\/)onebot(?:\s|$)/.test(command)) return undefined
|
||||||
|
const boundWechatPid = Number(command.match(/-wechat_pid=(\d+)/)?.[1]) || undefined
|
||||||
|
const imagePath = command.match(/-image_path=(\S+)/)?.[1]
|
||||||
|
return {
|
||||||
|
pid,
|
||||||
|
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||||
|
...(imagePath ? { imagePath } : {}),
|
||||||
|
command
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateOneBot(info: OneBotProcessInfo): Promise<void> {
|
||||||
|
if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return
|
||||||
|
try {
|
||||||
|
process.kill(info.pid, 'SIGTERM')
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const startedAt = Date.now()
|
||||||
|
while (Date.now() - startedAt < STOP_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
process.kill(info.pid, 0)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithTimeout(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
timeoutMs = 2_000
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal })
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PersonalWechatSendService {
|
||||||
|
private child: ChildProcess | null = null
|
||||||
|
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
||||||
|
private lastError = ''
|
||||||
|
|
||||||
|
async getStatus(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
const preflight = await this.preflight()
|
||||||
|
const [endpointReady, oneBot] = await Promise.all([
|
||||||
|
this.isEndpointOnline(),
|
||||||
|
readOneBotProcessInfo()
|
||||||
|
])
|
||||||
|
const hook = this.readHookReadiness(preflight.runtime)
|
||||||
|
const boundWechatPid = oneBot?.boundWechatPid || hook.boundWechatPid
|
||||||
|
const boundToCurrentWechat = Boolean(
|
||||||
|
preflight.status.wechatPid && boundWechatPid === preflight.status.wechatPid
|
||||||
|
)
|
||||||
|
const common = {
|
||||||
|
...preflight.status,
|
||||||
|
endpointReady,
|
||||||
|
...(oneBot?.pid ? { oneBotPid: oneBot.pid } : {}),
|
||||||
|
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||||
|
attachReady: hook.attached,
|
||||||
|
...(hook.baseAddress ? { baseAddress: hook.baseAddress } : {}),
|
||||||
|
baseAddressReady: Boolean(hook.baseAddress),
|
||||||
|
textHookInstalled: hook.textHookInstalled,
|
||||||
|
textHookReady: hook.textHookReady,
|
||||||
|
imageHookInstalled: hook.imageHookInstalled,
|
||||||
|
imageHookReady: hook.imageHookReady,
|
||||||
|
messageListenerReady: hook.messageListenerReady
|
||||||
|
}
|
||||||
|
if (!endpointReady) return common
|
||||||
|
if (!boundToCurrentWechat) {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
state: 'hook_not_ready',
|
||||||
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hook.readiness === 'failed') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
state: 'error',
|
||||||
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
message: '微信发送 Hook 初始化失败,请尝试重新绑定',
|
||||||
|
...(hook.error ? { error: hook.error } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseReady = hook.attached && Boolean(hook.baseAddress) && hook.textHookInstalled
|
||||||
|
const canSendText = baseReady && hook.textHookReady
|
||||||
|
const imagePathBound = Boolean(oneBot?.imagePath)
|
||||||
|
const canSendImage = baseReady && hook.imageHookReady && imagePathBound
|
||||||
|
const canSendVoice = baseReady && hook.imageHookReady
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
state: canSendText || canSendImage || canSendVoice ? 'online' : 'hook_not_ready',
|
||||||
|
canSend: canSendText || canSendImage || canSendVoice,
|
||||||
|
canSendText,
|
||||||
|
canSendImage,
|
||||||
|
canSendVoice,
|
||||||
|
message:
|
||||||
|
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
|
||||||
|
? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”'
|
||||||
|
: canSendText || canSendImage || canSendVoice
|
||||||
|
? '个人微信已绑定,可使用已初始化的消息类型'
|
||||||
|
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
|
||||||
|
...(hook.error ? { error: hook.error } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
|
||||||
|
const to = String(request?.to || '').trim()
|
||||||
|
if (!to) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
return { success: false, status, error: '接收者不能为空' }
|
||||||
|
}
|
||||||
|
let fileBase64: string | undefined
|
||||||
|
if (request.type === 'text') {
|
||||||
|
const text = String(request.text || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
return { success: false, status, error: '文字内容不能为空' }
|
||||||
|
}
|
||||||
|
if (text.length > 2_000) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
return { success: false, status, error: '测试消息不能超过 2000 个字符' }
|
||||||
|
}
|
||||||
|
request = { ...request, to, text }
|
||||||
|
} else {
|
||||||
|
const filePath = String(request.filePath || '').trim()
|
||||||
|
if (!filePath || !existsSync(filePath)) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status,
|
||||||
|
error: `请选择有效的${request.type === 'voice' ? '语音' : '图片'}文件`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const size = statSync(filePath).size
|
||||||
|
const maxBytes = request.type === 'voice' ? MAX_VOICE_BYTES : MAX_IMAGE_BYTES
|
||||||
|
if (size <= 0 || size > maxBytes) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status,
|
||||||
|
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileBase64 = (
|
||||||
|
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
|
||||||
|
).toString('base64')
|
||||||
|
request = { ...request, to, filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.ensureRunning()
|
||||||
|
const typeReady =
|
||||||
|
request.type === 'text'
|
||||||
|
? status.canSendText
|
||||||
|
: request.type === 'voice'
|
||||||
|
? status.canSendVoice
|
||||||
|
: status.canSendImage
|
||||||
|
if (!typeReady) {
|
||||||
|
const guidance =
|
||||||
|
request.type === 'text'
|
||||||
|
? '请先在微信中给任意好友手动发送一条文字,再重新检测'
|
||||||
|
: request.type === 'voice'
|
||||||
|
? '语音复用媒体上传 Hook,请先在微信中手动发送一张普通图片,再重新检测'
|
||||||
|
: '请先在微信中给任意好友手动发送一张普通图片,再重新检测'
|
||||||
|
return { success: false, status, error: status.error || guidance }
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
|
||||||
|
try {
|
||||||
|
const response = await requestWithTimeout(
|
||||||
|
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(oneBot.body)
|
||||||
|
},
|
||||||
|
request.type === 'voice' ? 60_000 : REQUEST_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
const responseText = await response.text()
|
||||||
|
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
|
||||||
|
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
|
||||||
|
if (parsed.status && parsed.status !== 'ok') throw new Error(responseText)
|
||||||
|
return { success: true, status: await this.getStatus() }
|
||||||
|
} catch (error) {
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error)
|
||||||
|
const failedStatus = await this.getStatus()
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: { ...failedStatus, state: 'error', error: this.lastError },
|
||||||
|
error: `发送失败:${this.lastError}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebind(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
const preflight = await this.preflight()
|
||||||
|
if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) {
|
||||||
|
return preflight.status
|
||||||
|
}
|
||||||
|
const currentStatus = await this.getStatus()
|
||||||
|
const oneBot = await readOneBotProcessInfo()
|
||||||
|
if (
|
||||||
|
oneBot?.boundWechatPid === preflight.status.wechatPid &&
|
||||||
|
currentStatus.attachReady &&
|
||||||
|
currentStatus.baseAddressReady &&
|
||||||
|
currentStatus.textHookInstalled &&
|
||||||
|
currentStatus.imageHookInstalled &&
|
||||||
|
(!preflight.status.imagePath || Boolean(oneBot.imagePath)) &&
|
||||||
|
currentStatus.state !== 'error'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...currentStatus,
|
||||||
|
message: '当前 OneBot 已绑定此微信进程,无需重复注入'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oneBot) await terminateOneBot(oneBot)
|
||||||
|
this.child = null
|
||||||
|
this.startPromise = null
|
||||||
|
this.lastError = ''
|
||||||
|
let status = await this.startRuntime()
|
||||||
|
const retryableHookFailure =
|
||||||
|
status.state === 'error' &&
|
||||||
|
/unable to intercept function|cannot find ['"]req2buf|hook 初始化失败/i.test(
|
||||||
|
`${status.error || ''} ${status.message || ''}`
|
||||||
|
)
|
||||||
|
if (!retryableHookFailure) return status
|
||||||
|
const failedOneBot = await readOneBotProcessInfo()
|
||||||
|
if (failedOneBot) await terminateOneBot(failedOneBot)
|
||||||
|
this.child = null
|
||||||
|
this.lastError = ''
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
||||||
|
status = await this.startRuntime()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
const child = this.child
|
||||||
|
this.child = null
|
||||||
|
this.startPromise = null
|
||||||
|
// A second Attach to the same WeChat process is unstable. Leave OneBot alive;
|
||||||
|
// it monitors the WeChat PID and exits when that process ends.
|
||||||
|
if (child && child.exitCode === null) child.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(): Promise<void> {
|
||||||
|
const oneBot = await readOneBotProcessInfo()
|
||||||
|
if (oneBot) await terminateOneBot(oneBot)
|
||||||
|
this.child = null
|
||||||
|
this.startPromise = null
|
||||||
|
this.lastError = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRunning(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
const currentStatus = await this.getStatus()
|
||||||
|
if (currentStatus.state === 'online' || currentStatus.state === 'hook_not_ready') {
|
||||||
|
return currentStatus
|
||||||
|
}
|
||||||
|
if (this.startPromise) return this.startPromise
|
||||||
|
this.startPromise = this.startRuntime().finally(() => {
|
||||||
|
this.startPromise = null
|
||||||
|
})
|
||||||
|
return this.startPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startRuntime(): Promise<PersonalWechatSenderStatus> {
|
||||||
|
const preflight = await this.preflight()
|
||||||
|
if (!preflight.runtime || !preflight.status.configPath) {
|
||||||
|
return preflight.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = preflight.status.wechatPid
|
||||||
|
const imagePath = findWechatImagePath()
|
||||||
|
this.lastError = ''
|
||||||
|
const child = spawn(
|
||||||
|
preflight.runtime.executable,
|
||||||
|
[
|
||||||
|
'-type=local',
|
||||||
|
`-receive_host=${DEFAULT_HOST}`,
|
||||||
|
`-wechat_conf=${preflight.status.configPath}`,
|
||||||
|
`-wechat_pid=${pid}`,
|
||||||
|
...(imagePath ? [`-image_path=${imagePath}`] : []),
|
||||||
|
'-send_interval=1000',
|
||||||
|
'-log_level=info'
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: preflight.runtime.workingDirectory,
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: buildRuntimePath(),
|
||||||
|
PYTHONPATH: buildRuntimePythonPath(preflight.runtime.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.child = child
|
||||||
|
child.unref()
|
||||||
|
child.once('error', (error) => {
|
||||||
|
this.lastError = error.message
|
||||||
|
})
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (this.child === child) this.child = null
|
||||||
|
if (code && !this.lastError) this.lastError = `发送服务退出(code=${code})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
while (Date.now() - startedAt < START_TIMEOUT_MS) {
|
||||||
|
const status = await this.getStatus()
|
||||||
|
if (status.state === 'online') return status
|
||||||
|
if (status.state === 'hook_not_ready' && status.attachReady && status.baseAddressReady) {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
if (status.state === 'error') return status
|
||||||
|
if (this.child?.exitCode !== null && this.child?.exitCode !== undefined) break
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
const status = await this.preflight()
|
||||||
|
return {
|
||||||
|
...status.status,
|
||||||
|
state: 'error',
|
||||||
|
canSend: false,
|
||||||
|
message: '个人微信发送服务启动失败',
|
||||||
|
error: this.lastError || '启动超时,请查看应用日志'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async preflight(): Promise<PreflightResult> {
|
||||||
|
const base = {
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
sipDisabled: false,
|
||||||
|
wechatRunning: false,
|
||||||
|
runtimeReady: false,
|
||||||
|
endpoint: DEFAULT_HOST,
|
||||||
|
endpointReady: false,
|
||||||
|
attachReady: false,
|
||||||
|
baseAddressReady: false,
|
||||||
|
textHookInstalled: false,
|
||||||
|
textHookReady: false,
|
||||||
|
imageHookInstalled: false,
|
||||||
|
imageHookReady: false,
|
||||||
|
messageListenerReady: false,
|
||||||
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
canSendVoice: false
|
||||||
|
}
|
||||||
|
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
state: 'unsupported_platform',
|
||||||
|
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sipDisabled, wechatPid] = await Promise.all([isSipDisabled(), readWechatPid()])
|
||||||
|
if (!wechatPid) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
sipDisabled,
|
||||||
|
state: 'wechat_not_running',
|
||||||
|
message: '请先启动并登录 macOS 微信'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sipDisabled) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid,
|
||||||
|
state: 'sip_enabled',
|
||||||
|
message: '当前 SIP 未关闭,无法直接连接微信进程'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wechatVersion = ''
|
||||||
|
try {
|
||||||
|
wechatVersion = await readWechatVersion()
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
sipDisabled,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid,
|
||||||
|
state: 'error',
|
||||||
|
message: '无法读取微信精确版本',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = findPersonalWechatRuntime()
|
||||||
|
if (!runtime) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
sipDisabled,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid,
|
||||||
|
wechatVersion,
|
||||||
|
state: 'runtime_missing',
|
||||||
|
message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 文字转语音”下载'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const configPath = join(runtime.configDirectory, toConfigFileName(wechatVersion))
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
sipDisabled,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid,
|
||||||
|
wechatVersion,
|
||||||
|
runtimeReady: true,
|
||||||
|
executablePath: runtime.executable,
|
||||||
|
state: 'unsupported_version',
|
||||||
|
message: `当前微信版本 ${wechatVersion} 暂不支持,请前往文字转语音设置查看支持的版本`,
|
||||||
|
error: `缺少 ${configPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = findWechatImagePath()
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
status: {
|
||||||
|
...base,
|
||||||
|
sipDisabled,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid,
|
||||||
|
wechatVersion,
|
||||||
|
runtimeReady: true,
|
||||||
|
executablePath: runtime.executable,
|
||||||
|
configPath,
|
||||||
|
...(imagePath ? { imagePath } : {}),
|
||||||
|
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
|
||||||
|
message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isEndpointOnline(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [host, portText] = DEFAULT_HOST.split(':')
|
||||||
|
const port = Number(portText)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = createConnection({ host, port })
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('timeout'))
|
||||||
|
}, 800)
|
||||||
|
socket.once('connect', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
socket.destroy()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
socket.once('error', (error) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
socket.destroy()
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readHookReadiness(
|
||||||
|
runtime?: RuntimeLayout
|
||||||
|
): ReturnType<typeof parsePersonalWechatHookLog> {
|
||||||
|
if (!runtime || !existsSync(runtime.logPath)) {
|
||||||
|
return {
|
||||||
|
readiness: 'unknown',
|
||||||
|
attached: false,
|
||||||
|
textHookInstalled: false,
|
||||||
|
textHookReady: false,
|
||||||
|
imageHookInstalled: false,
|
||||||
|
imageHookReady: false,
|
||||||
|
messageListenerReady: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parsePersonalWechatHookLog(readFileSync(runtime.logPath, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
readiness: 'unknown',
|
||||||
|
attached: false,
|
||||||
|
textHookInstalled: false,
|
||||||
|
textHookReady: false,
|
||||||
|
imageHookInstalled: false,
|
||||||
|
imageHookReady: false,
|
||||||
|
messageListenerReady: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const personalWechatSendService = new PersonalWechatSendService()
|
||||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
import type { TextToSpeechModel } from '../../shared/text-to-speech'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
||||||
@@ -36,6 +37,8 @@ export interface AppSettings {
|
|||||||
appearanceTheme: 'system' | 'light' | 'dark'
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
|
ttsSelectedVoiceId: string
|
||||||
|
ttsModel: TextToSpeechModel
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -135,7 +138,9 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
autoLoginPreferenceSet: false,
|
autoLoginPreferenceSet: false,
|
||||||
appearanceTheme: 'system',
|
appearanceTheme: 'system',
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
showStartupProgress: true
|
showStartupProgress: true,
|
||||||
|
ttsSelectedVoiceId: '',
|
||||||
|
ttsModel: 's2.1-pro-free'
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
const SETTINGS_FILE = path.join(
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
ListTextToSpeechVoicesRequest,
|
||||||
|
ListTextToSpeechVoicesResult,
|
||||||
|
SaveTextToSpeechSettingsRequest,
|
||||||
|
SynthesizeTextToSpeechRequest,
|
||||||
|
SynthesizeTextToSpeechResult,
|
||||||
|
TextToSpeechKeySource,
|
||||||
|
TextToSpeechModel,
|
||||||
|
TextToSpeechSettingsResult,
|
||||||
|
TextToSpeechVoice
|
||||||
|
} from '../../shared/text-to-speech'
|
||||||
|
import { AIProviderKeyStore } from '../ai-provider-key-store'
|
||||||
|
import { loadSettings, updateSettings } from './settings-store'
|
||||||
|
|
||||||
|
const FISH_AUDIO_KEY_ID = 'fish-audio-tts'
|
||||||
|
const FISH_AUDIO_BASE_URL = 'https://api.fish.audio'
|
||||||
|
const FISH_AUDIO_PUBLIC_ASSET_URL = 'https://public-platform.r2.fish.audio/'
|
||||||
|
const DEFAULT_PAGE_SIZE = 24
|
||||||
|
const MAX_PAGE_SIZE = 100
|
||||||
|
const GENERATED_AUDIO_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface FishAudioModelEntity {
|
||||||
|
_id: string
|
||||||
|
type: 'svc' | 'tts'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
cover_image?: string
|
||||||
|
state: 'created' | 'training' | 'trained' | 'failed'
|
||||||
|
tags?: string[]
|
||||||
|
languages?: string[]
|
||||||
|
default_text?: string
|
||||||
|
samples?: Array<{ title: string; text: string; task_id: string; audio: string }>
|
||||||
|
task_count?: number
|
||||||
|
like_count?: number
|
||||||
|
mark_count?: number
|
||||||
|
author?: { _id: string; nickname: string; avatar: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishAudioModelListResponse {
|
||||||
|
total: number
|
||||||
|
items: FishAudioModelEntity[]
|
||||||
|
has_more?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextToSpeechSettingsService {
|
||||||
|
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||||
|
|
||||||
|
get(): TextToSpeechSettingsResult {
|
||||||
|
const resolved = this.resolveKey()
|
||||||
|
const settings = loadSettings()
|
||||||
|
return {
|
||||||
|
success: resolved.success,
|
||||||
|
settings: {
|
||||||
|
provider: 'fish-audio',
|
||||||
|
hasApiKey: Boolean(resolved.key),
|
||||||
|
hasStoredApiKey: resolved.hasStoredApiKey,
|
||||||
|
hasEnvironmentApiKey: resolved.hasEnvironmentApiKey,
|
||||||
|
keySource: resolved.source,
|
||||||
|
encryptionAvailable: resolved.encryptionAvailable,
|
||||||
|
selectedVoiceId: normalizeSelectedVoiceId(settings.ttsSelectedVoiceId),
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
model: normalizeModel(settings.ttsModel),
|
||||||
|
phase: 'ready'
|
||||||
|
},
|
||||||
|
voices: [],
|
||||||
|
error: resolved.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(request: SaveTextToSpeechSettingsRequest): TextToSpeechSettingsResult {
|
||||||
|
const apiKey = request.apiKey?.trim()
|
||||||
|
if (request.clearApiKey) {
|
||||||
|
const cleared = this.keyStore.clear(FISH_AUDIO_KEY_ID)
|
||||||
|
if (!cleared.success) return { ...this.get(), success: false, error: cleared.error }
|
||||||
|
} else if (apiKey) {
|
||||||
|
const saved = this.keyStore.save(FISH_AUDIO_KEY_ID, apiKey)
|
||||||
|
if (!saved.success) return { ...this.get(), success: false, error: saved.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: { ttsSelectedVoiceId?: string; ttsModel?: TextToSpeechModel } = {}
|
||||||
|
if (request.selectedVoiceId !== undefined) {
|
||||||
|
patch.ttsSelectedVoiceId = request.selectedVoiceId.trim()
|
||||||
|
}
|
||||||
|
if (request.model !== undefined) patch.ttsModel = normalizeModel(request.model)
|
||||||
|
if (Object.keys(patch).length) updateSettings(patch)
|
||||||
|
return this.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
async listVoices(
|
||||||
|
request: ListTextToSpeechVoicesRequest = {}
|
||||||
|
): Promise<ListTextToSpeechVoicesResult> {
|
||||||
|
const pageNumber = clampInteger(request.pageNumber, 1, Number.MAX_SAFE_INTEGER, 1)
|
||||||
|
const pageSize = clampInteger(request.pageSize, 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)
|
||||||
|
const resolved = this.resolveKey()
|
||||||
|
if (!resolved.key) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
hasMore: false,
|
||||||
|
error: resolved.error || '请先配置语音服务 API Key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL('/model', FISH_AUDIO_BASE_URL)
|
||||||
|
url.searchParams.set('page_size', String(pageSize))
|
||||||
|
url.searchParams.set('page_number', String(pageNumber))
|
||||||
|
url.searchParams.set('sort_by', 'score')
|
||||||
|
if (request.title?.trim()) url.searchParams.set('title', request.title.trim())
|
||||||
|
if (request.language?.trim()) url.searchParams.set('language', request.language.trim())
|
||||||
|
for (const tag of request.tags || []) {
|
||||||
|
if (tag.trim()) url.searchParams.append('tag', tag.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${resolved.key}` },
|
||||||
|
signal: AbortSignal.timeout(30_000)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw await fishAudioError(response)
|
||||||
|
const payload = (await response.json()) as FishAudioModelListResponse
|
||||||
|
let items = payload.items
|
||||||
|
.filter((model) => model.type === 'tts' && model.state === 'trained')
|
||||||
|
.map(toVoice)
|
||||||
|
|
||||||
|
const selectedVoiceId = normalizeSelectedVoiceId(loadSettings().ttsSelectedVoiceId)
|
||||||
|
if (
|
||||||
|
pageNumber === 1 &&
|
||||||
|
!request.title?.trim() &&
|
||||||
|
!(request.tags || []).length &&
|
||||||
|
selectedVoiceId
|
||||||
|
) {
|
||||||
|
if (!items.some((item) => item.id === selectedVoiceId)) {
|
||||||
|
const selected = await this.getVoice(selectedVoiceId, resolved.key)
|
||||||
|
if (selected) items = [selected, ...items]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
items,
|
||||||
|
total: payload.total,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
hasMore: payload.has_more ?? pageNumber * pageSize < payload.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
hasMore: false,
|
||||||
|
error: safeFishAudioError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(request: SynthesizeTextToSpeechRequest): Promise<SynthesizeTextToSpeechResult> {
|
||||||
|
const text = request.text.trim()
|
||||||
|
const referenceId = request.referenceId.trim()
|
||||||
|
if (!text) return { success: false, error: '请输入要生成语音的文字' }
|
||||||
|
if (text.length > 1000) return { success: false, error: '单次生成文字不能超过 1000 个字符' }
|
||||||
|
if (!referenceId) return { success: false, error: '请先选择音色' }
|
||||||
|
|
||||||
|
const resolved = this.resolveKey()
|
||||||
|
if (!resolved.key) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: resolved.error || '请先配置语音服务 API Key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${FISH_AUDIO_BASE_URL}/v1/tts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${resolved.key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
model: normalizeModel(loadSettings().ttsModel)
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
reference_id: referenceId,
|
||||||
|
format: 'mp3',
|
||||||
|
mp3_bitrate: 128,
|
||||||
|
latency: 'normal',
|
||||||
|
normalize: true
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(120_000)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw await fishAudioError(response)
|
||||||
|
const audio = Buffer.from(await response.arrayBuffer())
|
||||||
|
if (audio.length < 128) throw new Error('语音服务返回的音频为空')
|
||||||
|
|
||||||
|
const directory = this.generatedAudioDirectory()
|
||||||
|
fs.ensureDirSync(directory)
|
||||||
|
this.cleanupGeneratedAudio(directory)
|
||||||
|
const filePath = path.join(directory, `fish-audio-${Date.now()}-${randomUUID()}.mp3`)
|
||||||
|
fs.writeFileSync(filePath, audio, { mode: 0o600 })
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
audioDataUrl: `data:audio/mpeg;base64,${audio.toString('base64')}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: safeFishAudioError(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGeneratedAudio(filePath: string): { success: boolean; error?: string } {
|
||||||
|
const directory = this.generatedAudioDirectory()
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
if (!resolvedPath.startsWith(`${path.resolve(directory)}${path.sep}`)) {
|
||||||
|
return { success: false, error: '拒绝删除非 Fish Audio 临时文件' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.removeSync(resolvedPath)
|
||||||
|
return { success: true }
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '临时语音文件清理失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveKey(): {
|
||||||
|
success: boolean
|
||||||
|
key?: string
|
||||||
|
source: TextToSpeechKeySource
|
||||||
|
hasStoredApiKey: boolean
|
||||||
|
hasEnvironmentApiKey: boolean
|
||||||
|
encryptionAvailable: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const stored = this.keyStore.get(FISH_AUDIO_KEY_ID)
|
||||||
|
const environmentKey = String(process.env.FISH_API_KEY || '').trim()
|
||||||
|
const storedKey = stored.key?.trim()
|
||||||
|
const key = storedKey || environmentKey || undefined
|
||||||
|
return {
|
||||||
|
success: stored.success || Boolean(environmentKey),
|
||||||
|
key,
|
||||||
|
source: storedKey ? 'secure-storage' : environmentKey ? 'environment' : 'missing',
|
||||||
|
hasStoredApiKey: Boolean(storedKey),
|
||||||
|
hasEnvironmentApiKey: Boolean(environmentKey),
|
||||||
|
encryptionAvailable: stored.available,
|
||||||
|
error: key ? undefined : stored.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getVoice(id: string, key: string): Promise<TextToSpeechVoice | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${FISH_AUDIO_BASE_URL}/model/${encodeURIComponent(id)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
signal: AbortSignal.timeout(15_000)
|
||||||
|
})
|
||||||
|
if (!response.ok) return null
|
||||||
|
const model = (await response.json()) as FishAudioModelEntity
|
||||||
|
return model.type === 'tts' && model.state === 'trained' ? toVoice(model) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generatedAudioDirectory(): string {
|
||||||
|
return path.join(app.getPath('temp'), 'wechatexplorer-fish-audio')
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupGeneratedAudio(directory: string): void {
|
||||||
|
try {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const name of fs.readdirSync(directory)) {
|
||||||
|
const candidate = path.join(directory, name)
|
||||||
|
if (!name.startsWith('fish-audio-') || !name.endsWith('.mp3')) continue
|
||||||
|
if (now - fs.statSync(candidate).mtimeMs > GENERATED_AUDIO_MAX_AGE_MS)
|
||||||
|
fs.removeSync(candidate)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 临时文件清理失败不应阻断当前语音生成。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVoice(model: FishAudioModelEntity): TextToSpeechVoice {
|
||||||
|
const sample = model.samples?.find((item) => item.audio) || model.samples?.[0]
|
||||||
|
return {
|
||||||
|
id: model._id,
|
||||||
|
name: model.title || '未命名音色',
|
||||||
|
description: model.description || sample?.text || '公开音色',
|
||||||
|
tags: Array.isArray(model.tags) ? model.tags : [],
|
||||||
|
languages: Array.isArray(model.languages) ? model.languages : [],
|
||||||
|
source: 'fish-audio',
|
||||||
|
coverImage: resolvePublicAssetUrl(model.cover_image || model.author?.avatar),
|
||||||
|
previewUrl: sample?.audio || undefined,
|
||||||
|
previewText: sample?.text || model.default_text || undefined,
|
||||||
|
authorName: model.author?.nickname || undefined,
|
||||||
|
taskCount: model.task_count,
|
||||||
|
likeCount: model.like_count,
|
||||||
|
markCount: model.mark_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublicAssetUrl(value?: string): string | undefined {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
if (/^https?:\/\//i.test(normalized)) return normalized
|
||||||
|
if (normalized.startsWith('//')) return `https:${normalized}`
|
||||||
|
return new URL(normalized.replace(/^\/+/, ''), FISH_AUDIO_PUBLIC_ASSET_URL).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedVoiceId(value: string): string {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return normalized.startsWith('demo-') ? '' : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModel(value: unknown): TextToSpeechModel {
|
||||||
|
return value === 's2.1-pro' ? 's2.1-pro' : 's2.1-pro-free'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return fallback
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(parsed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fishAudioError(response: Response): Promise<Error> {
|
||||||
|
let detail = ''
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as
|
||||||
|
| { status?: number; message?: string }
|
||||||
|
| Array<{ msg?: string }>
|
||||||
|
detail = Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
.map((item) => item.msg)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';')
|
||||||
|
: payload.message || ''
|
||||||
|
} catch {
|
||||||
|
detail = await response.text().catch(() => '')
|
||||||
|
}
|
||||||
|
if (response.status === 401) return new Error('API Key 无效或已失效')
|
||||||
|
if (response.status === 402) return new Error('语音服务余额不足')
|
||||||
|
if (response.status === 404) return new Error('所选音色不存在或不可访问')
|
||||||
|
if (response.status === 422) return new Error(detail || '语音生成参数不正确')
|
||||||
|
if (response.status === 503) return new Error('语音服务暂时不可用,请稍后重试')
|
||||||
|
return new Error(detail || `语音服务请求失败(HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFishAudioError(error: unknown): string {
|
||||||
|
if (error instanceof DOMException && error.name === 'TimeoutError') return '语音服务请求超时'
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return '语音服务请求失败'
|
||||||
|
}
|
||||||
@@ -74,12 +74,12 @@ export class SilkAudioDecoder implements VoiceAudioDecoder {
|
|||||||
decode?: (data: Buffer, sampleRate: number) => Promise<{ data: Uint8Array }>
|
decode?: (data: Buffer, sampleRate: number) => Promise<{ data: Uint8Array }>
|
||||||
}
|
}
|
||||||
if (!silkWasm.decode) throw new Error('silk-wasm 运行时无效')
|
if (!silkWasm.decode) throw new Error('silk-wasm 运行时无效')
|
||||||
const result = await silkWasm.decode(source.data, 24000)
|
const result = await silkWasm.decode(source.data, 16000)
|
||||||
const pcm = Buffer.from(result.data)
|
const pcm = Buffer.from(result.data)
|
||||||
if (!pcm.length) throw new Error('Silk 解码结果为空')
|
if (!pcm.length) throw new Error('Silk 解码结果为空')
|
||||||
return {
|
return {
|
||||||
pcm,
|
pcm,
|
||||||
sampleRate: 24000,
|
sampleRate: 16000,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
sourceHash: source.sourceHash
|
sourceHash: source.sourceHash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ export class VoiceService {
|
|||||||
if (!pcmResult.success) return pcmResult
|
if (!pcmResult.success) return pcmResult
|
||||||
const pcmData = pcmResult.audio.pcm
|
const pcmData = pcmResult.audio.pcm
|
||||||
|
|
||||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
const wavData = this.createWavBuffer(
|
||||||
|
pcmData,
|
||||||
|
pcmResult.audio.sampleRate,
|
||||||
|
pcmResult.audio.channels
|
||||||
|
)
|
||||||
console.log(
|
console.log(
|
||||||
'[VoiceService] wavData length:',
|
'[VoiceService] wavData length:',
|
||||||
wavData.length,
|
wavData.length,
|
||||||
|
|||||||
Vendored
+55
@@ -53,6 +53,18 @@ import type {
|
|||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type {
|
||||||
|
PersonalWechatImageSelectionResult,
|
||||||
|
PersonalWechatVoiceSelectionResult,
|
||||||
|
PersonalWechatSendRequest,
|
||||||
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
|
} from '../shared/personal-wechat'
|
||||||
|
import type {
|
||||||
|
PersonalWechatRuntimeDownloadResult,
|
||||||
|
PersonalWechatRuntimeProgressEvent,
|
||||||
|
PersonalWechatRuntimeStatus
|
||||||
|
} from '../shared/personal-wechat-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
||||||
import type { CacheSummary } from '../shared/cache'
|
import type { CacheSummary } from '../shared/cache'
|
||||||
@@ -86,6 +98,14 @@ import type {
|
|||||||
WechatShareServiceConfig,
|
WechatShareServiceConfig,
|
||||||
WechatShareServiceConfigResult
|
WechatShareServiceConfigResult
|
||||||
} from '../shared/wechat-share-card'
|
} from '../shared/wechat-share-card'
|
||||||
|
import type {
|
||||||
|
ListTextToSpeechVoicesRequest,
|
||||||
|
ListTextToSpeechVoicesResult,
|
||||||
|
SaveTextToSpeechSettingsRequest,
|
||||||
|
SynthesizeTextToSpeechRequest,
|
||||||
|
SynthesizeTextToSpeechResult,
|
||||||
|
TextToSpeechSettingsResult
|
||||||
|
} from '../shared/text-to-speech'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -250,6 +270,20 @@ declare global {
|
|||||||
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
|
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
|
||||||
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
|
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
|
||||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
|
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
|
||||||
|
getTextToSpeechSettings: () => Promise<TextToSpeechSettingsResult>
|
||||||
|
saveTextToSpeechSettings: (
|
||||||
|
request: SaveTextToSpeechSettingsRequest
|
||||||
|
) => Promise<TextToSpeechSettingsResult>
|
||||||
|
listTextToSpeechVoices: (
|
||||||
|
request?: ListTextToSpeechVoicesRequest
|
||||||
|
) => Promise<ListTextToSpeechVoicesResult>
|
||||||
|
synthesizeTextToSpeech: (
|
||||||
|
request: SynthesizeTextToSpeechRequest
|
||||||
|
) => Promise<SynthesizeTextToSpeechResult>
|
||||||
|
removeGeneratedTextToSpeechAudio: (
|
||||||
|
filePath: string
|
||||||
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
|
openFishAudioApiKeys: () => Promise<{ success: boolean; error?: string }>
|
||||||
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
||||||
getVoiceData: (
|
getVoiceData: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -383,6 +417,8 @@ declare global {
|
|||||||
appearanceTheme: 'system' | 'light' | 'dark'
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
|
ttsSelectedVoiceId: string
|
||||||
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -420,6 +456,8 @@ declare global {
|
|||||||
appearanceTheme: 'system' | 'light' | 'dark'
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
|
ttsSelectedVoiceId: string
|
||||||
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -440,6 +478,8 @@ declare global {
|
|||||||
appearanceTheme: 'system' | 'light' | 'dark'
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
|
ttsSelectedVoiceId: string
|
||||||
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -533,6 +573,21 @@ declare global {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit?: number
|
limit?: number
|
||||||
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||||
|
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||||
|
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||||
|
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
||||||
|
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
||||||
|
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
|
||||||
|
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
onPersonalWechatRuntimeProgress: (
|
||||||
|
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
||||||
|
) => () => void
|
||||||
|
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||||
|
selectPersonalWechatImage: () => Promise<PersonalWechatImageSelectionResult>
|
||||||
|
selectPersonalWechatVoice: () => Promise<PersonalWechatVoiceSelectionResult>
|
||||||
|
sendPersonalWechatMessage: (
|
||||||
|
request: PersonalWechatSendRequest
|
||||||
|
) => Promise<PersonalWechatSendResult>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
clearAgentHubLogs: () => Promise<void>
|
clearAgentHubLogs: () => Promise<void>
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ import type {
|
|||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type {
|
||||||
|
PersonalWechatImageSelectionResult,
|
||||||
|
PersonalWechatVoiceSelectionResult,
|
||||||
|
PersonalWechatSendRequest,
|
||||||
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
|
} from '../shared/personal-wechat'
|
||||||
|
import type {
|
||||||
|
PersonalWechatRuntimeDownloadResult,
|
||||||
|
PersonalWechatRuntimeProgressEvent,
|
||||||
|
PersonalWechatRuntimeStatus
|
||||||
|
} from '../shared/personal-wechat-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
import type { CacheSummary } from '../shared/cache'
|
import type { CacheSummary } from '../shared/cache'
|
||||||
@@ -58,6 +70,11 @@ import type {
|
|||||||
PublishWechatShareCardRequest,
|
PublishWechatShareCardRequest,
|
||||||
WechatShareServiceConfig
|
WechatShareServiceConfig
|
||||||
} from '../shared/wechat-share-card'
|
} from '../shared/wechat-share-card'
|
||||||
|
import type {
|
||||||
|
ListTextToSpeechVoicesRequest,
|
||||||
|
SaveTextToSpeechSettingsRequest,
|
||||||
|
SynthesizeTextToSpeechRequest
|
||||||
|
} from '../shared/text-to-speech'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -138,6 +155,16 @@ const api = {
|
|||||||
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
|
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
|
||||||
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
|
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
|
||||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
|
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
|
||||||
|
getTextToSpeechSettings: () => ipcRenderer.invoke('tts:getSettings'),
|
||||||
|
saveTextToSpeechSettings: (request: SaveTextToSpeechSettingsRequest) =>
|
||||||
|
ipcRenderer.invoke('tts:saveSettings', request),
|
||||||
|
listTextToSpeechVoices: (request?: ListTextToSpeechVoicesRequest) =>
|
||||||
|
ipcRenderer.invoke('tts:listVoices', request),
|
||||||
|
synthesizeTextToSpeech: (request: SynthesizeTextToSpeechRequest) =>
|
||||||
|
ipcRenderer.invoke('tts:synthesize', request),
|
||||||
|
removeGeneratedTextToSpeechAudio: (filePath: string) =>
|
||||||
|
ipcRenderer.invoke('tts:removeGeneratedAudio', filePath),
|
||||||
|
openFishAudioApiKeys: () => ipcRenderer.invoke('tts:openApiKeys'),
|
||||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
||||||
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
||||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||||
@@ -322,6 +349,37 @@ const api = {
|
|||||||
limit?: number
|
limit?: number
|
||||||
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
||||||
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||||
|
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||||
|
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
||||||
|
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
|
||||||
|
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
|
||||||
|
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:removeRuntime'),
|
||||||
|
openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'),
|
||||||
|
onPersonalWechatRuntimeProgress: (
|
||||||
|
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
status: PersonalWechatRuntimeProgressEvent
|
||||||
|
): void => callback(status)
|
||||||
|
ipcRenderer.on('wechat-personal:runtimeProgress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener)
|
||||||
|
},
|
||||||
|
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||||
|
selectPersonalWechatImage: (): Promise<PersonalWechatImageSelectionResult> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:selectImage'),
|
||||||
|
selectPersonalWechatVoice: (): Promise<PersonalWechatVoiceSelectionResult> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:selectVoice'),
|
||||||
|
sendPersonalWechatMessage: (
|
||||||
|
request: PersonalWechatSendRequest
|
||||||
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||||
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||||
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' https: blob: data: wxe-media:;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -1470,6 +1471,11 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTextToSpeechSettings = (): void => {
|
||||||
|
setSettingsCategory('text-to-speech')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const dismissFirstUseWelcome = (): void => {
|
const dismissFirstUseWelcome = (): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||||
@@ -1619,6 +1625,9 @@ function App(): React.ReactElement {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
|
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
|
||||||
|
const selectedReportContact = selectedReport
|
||||||
|
? contacts.find((contact) => contact.md5 === selectedReport.contactId) || null
|
||||||
|
: null
|
||||||
|
|
||||||
const openReportResult = (): void => {
|
const openReportResult = (): void => {
|
||||||
if (isSavingGeneratedReport) {
|
if (isSavingGeneratedReport) {
|
||||||
@@ -1727,6 +1736,7 @@ function App(): React.ReactElement {
|
|||||||
onReloadAvatars={handleReloadCurrentAvatars}
|
onReloadAvatars={handleReloadCurrentAvatars}
|
||||||
onLoadOlderMessages={handleLoadOlderMessages}
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
jumpToTime={archiveJumpTime}
|
jumpToTime={archiveJumpTime}
|
||||||
/>
|
/>
|
||||||
@@ -1755,6 +1765,7 @@ function App(): React.ReactElement {
|
|||||||
onCopyImage={handleCopyReportImage}
|
onCopyImage={handleCopyReportImage}
|
||||||
onReveal={handleRevealReport}
|
onReveal={handleRevealReport}
|
||||||
onSwitchTemplate={handleSwitchReportTemplate}
|
onSwitchTemplate={handleSwitchReportTemplate}
|
||||||
|
sendTarget={selectedReportContact}
|
||||||
/>
|
/>
|
||||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2001,7 +2012,7 @@ function App(): React.ReactElement {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<DatabaseConnectionPage
|
<DatabaseConnectionPage
|
||||||
platform={window.electron.process.platform}
|
platform={runtimePlatform}
|
||||||
mode={databaseConnectionMode}
|
mode={databaseConnectionMode}
|
||||||
dbKey={dbKey}
|
dbKey={dbKey}
|
||||||
dbRoot={dbRootInput}
|
dbRoot={dbRootInput}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ChatStatusBar } from './chat/ChatStatusBar'
|
|||||||
import { DataTrustBar } from './chat/DataTrustBar'
|
import { DataTrustBar } from './chat/DataTrustBar'
|
||||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
import { MessageList } from './chat/MessageList'
|
import { MessageList } from './chat/MessageList'
|
||||||
|
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
@@ -18,6 +19,7 @@ interface ChatWindowProps {
|
|||||||
onReloadAvatars?: () => Promise<void>
|
onReloadAvatars?: () => Promise<void>
|
||||||
onLoadOlderMessages?: () => Promise<void>
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
|
onOpenTextToSpeechSettings?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
jumpToTime?: number | null
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onReloadAvatars,
|
onReloadAvatars,
|
||||||
onLoadOlderMessages,
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
|
onOpenTextToSpeechSettings,
|
||||||
isAiLoading = false,
|
isAiLoading = false,
|
||||||
jumpToTime
|
jumpToTime
|
||||||
}) => {
|
}) => {
|
||||||
@@ -53,6 +56,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const [showAvatar, setShowAvatar] = useState(true)
|
const [showAvatar, setShowAvatar] = useState(true)
|
||||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||||
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||||
|
const [sendDialogOpen, setSendDialogOpen] = useState(false)
|
||||||
const previousScrollTopRef = useRef(0)
|
const previousScrollTopRef = useRef(0)
|
||||||
|
|
||||||
const scrollToBottom = useCallback((): void => {
|
const scrollToBottom = useCallback((): void => {
|
||||||
@@ -199,6 +203,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onRefreshData={onRefreshData}
|
onRefreshData={onRefreshData}
|
||||||
|
onTestSend={() => setSendDialogOpen(true)}
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
@@ -227,6 +232,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onJumpToLatest={scrollToBottom}
|
onJumpToLatest={scrollToBottom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{sendDialogOpen && (
|
||||||
|
<PersonalWechatSendDialog
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat={isGroupChat}
|
||||||
|
onClose={() => setSendDialogOpen(false)}
|
||||||
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||||
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
|
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
|
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
|
||||||
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
|
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { isWindows } from '../utils/runtime-environment'
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -66,7 +67,6 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|||||||
onSaveAiModelConfig,
|
onSaveAiModelConfig,
|
||||||
onDbRootChanged
|
onDbRootChanged
|
||||||
}) => {
|
}) => {
|
||||||
const isWindows = window.electron.process.platform === 'win32'
|
|
||||||
const dbRootPlaceholder = isWindows
|
const dbRootPlaceholder = isWindows
|
||||||
? 'C:\\Users\\你\\Documents\\WeChat Files'
|
? 'C:\\Users\\你\\Documents\\WeChat Files'
|
||||||
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
||||||
|
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
@@ -13,6 +14,7 @@ interface ChatHeaderProps {
|
|||||||
onContentFilterChange: (value: string) => void
|
onContentFilterChange: (value: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
|
onTestSend: () => void
|
||||||
onOpenAiSettings: () => void
|
onOpenAiSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export function ChatHeader({
|
|||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
|
onTestSend,
|
||||||
onOpenAiSettings
|
onOpenAiSettings
|
||||||
}: ChatHeaderProps): React.ReactElement {
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
@@ -111,6 +114,22 @@ export function ChatHeader({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className="chat-tool-button-wrapper"
|
||||||
|
title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
|
||||||
|
aria-label={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
|
||||||
|
tabIndex={supportsPersonalWechatSend ? -1 : 0}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-tool-button"
|
||||||
|
onClick={onTestSend}
|
||||||
|
disabled={!supportsPersonalWechatSend}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
<span>发送消息</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="chat-ai-button"
|
className="chat-ai-button"
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { Contact } from '../../../../shared/types'
|
||||||
|
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||||
|
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
||||||
|
|
||||||
|
type SendMode = 'image' | 'voice'
|
||||||
|
type VoiceSource = 'generated' | 'file'
|
||||||
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
|
interface PersonalWechatSendDialogProps {
|
||||||
|
contact: Contact
|
||||||
|
isGroupChat: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onOpenTextToSpeechSettings?: () => void
|
||||||
|
initialMode?: SendMode
|
||||||
|
initialImage?: SelectedLocalFile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
||||||
|
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||||
|
|
||||||
|
function formatAudioTime(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value < 0) return '0:00'
|
||||||
|
const seconds = Math.floor(value)
|
||||||
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
||||||
|
if (!status) return '正在检测'
|
||||||
|
if (status.state === 'online') return '个人微信已绑定'
|
||||||
|
if (status.state === 'stopped') return '尚未绑定'
|
||||||
|
if (status.state === 'runtime_missing') return '运行时未安装'
|
||||||
|
if (status.state === 'hook_not_ready') return '已绑定,等待消息初始化'
|
||||||
|
if (status.state === 'unsupported_version') return '微信版本不匹配'
|
||||||
|
if (status.state === 'wechat_not_running') return '微信未运行'
|
||||||
|
if (status.state === 'sip_enabled') return 'SIP 未关闭'
|
||||||
|
if (status.state === 'unsupported_platform') return '当前平台不支持'
|
||||||
|
if (status.state === 'starting') return '正在连接'
|
||||||
|
if (status.state === 'rebinding') return '正在重新绑定'
|
||||||
|
if (status.state === 'error') return '绑定异常'
|
||||||
|
return '正在检测'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyText(ready: boolean, readyLabel = '正常', waitingLabel = '等待初始化'): string {
|
||||||
|
return ready ? readyLabel : waitingLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||||
|
if (!status) return '正在检查微信版本、进程和 OneBot Hook…'
|
||||||
|
if (status.state === 'unsupported_version') {
|
||||||
|
return `当前微信版本 ${status.wechatVersion || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
|
||||||
|
}
|
||||||
|
if (status.state === 'runtime_missing') {
|
||||||
|
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
|
||||||
|
}
|
||||||
|
return status.message
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalWechatSendDialog({
|
||||||
|
contact,
|
||||||
|
isGroupChat,
|
||||||
|
onClose,
|
||||||
|
onOpenTextToSpeechSettings,
|
||||||
|
initialMode = 'image',
|
||||||
|
initialImage = null
|
||||||
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
|
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
|
const [mode, setMode] = useState<SendMode>(initialMode)
|
||||||
|
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
|
||||||
|
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
|
||||||
|
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
|
||||||
|
const [voiceText, setVoiceText] = useState('1')
|
||||||
|
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
||||||
|
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
||||||
|
const [generatedVoice, setGeneratedVoice] = useState<GeneratedVoice | null>(null)
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||||
|
const [previewCurrentTime, setPreviewCurrentTime] = useState(0)
|
||||||
|
const [previewDuration, setPreviewDuration] = useState(0)
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [isRebinding, setIsRebinding] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
||||||
|
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
|
const targetId = contact.m_nsUsrName
|
||||||
|
const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage
|
||||||
|
const hasContent =
|
||||||
|
mode === 'voice'
|
||||||
|
? voiceSource === 'file'
|
||||||
|
? Boolean(voice?.path)
|
||||||
|
: Boolean(voiceText.trim())
|
||||||
|
: Boolean(image?.path)
|
||||||
|
const isBusy = isSending || isGenerating || isRebinding
|
||||||
|
const generatedVoiceReady = Boolean(
|
||||||
|
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
||||||
|
)
|
||||||
|
const canSubmit = Boolean(
|
||||||
|
selectedTypeReady && hasContent && !isBusy && (mode !== 'voice' || voiceSource !== 'generated')
|
||||||
|
)
|
||||||
|
const canGenerate = Boolean(generatedVoiceReady && !isBusy)
|
||||||
|
const canSendGenerated = Boolean(status?.canSendVoice && generatedVoice && !isBusy)
|
||||||
|
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||||
|
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
||||||
|
const statusTone = useMemo(() => {
|
||||||
|
if (status?.attachReady && status.baseAddressReady) return 'ready'
|
||||||
|
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
|
||||||
|
return 'blocked'
|
||||||
|
}, [status])
|
||||||
|
|
||||||
|
const clearGeneratedVoice = useCallback((removeFile = true): void => {
|
||||||
|
const current = generatedVoiceRef.current
|
||||||
|
generatedVoiceRef.current = null
|
||||||
|
generatedAudioRef.current?.pause()
|
||||||
|
setGeneratedVoice(null)
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
setPreviewCurrentTime(0)
|
||||||
|
setPreviewDuration(0)
|
||||||
|
if (removeFile && current?.filePath) {
|
||||||
|
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = useCallback((): void => {
|
||||||
|
if (isBusy) return
|
||||||
|
clearGeneratedVoice()
|
||||||
|
onClose()
|
||||||
|
}, [clearGeneratedVoice, isBusy, onClose])
|
||||||
|
|
||||||
|
const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => {
|
||||||
|
if (!onOpenTextToSpeechSettings || isBusy) return
|
||||||
|
if (showSupportedVersions) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY, '1')
|
||||||
|
} catch {
|
||||||
|
// The settings page can still open if session storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearGeneratedVoice()
|
||||||
|
onClose()
|
||||||
|
onOpenTextToSpeechSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
setStatus(await window.api.getPersonalWechatSenderStatus())
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
state: 'error',
|
||||||
|
platform: 'unknown',
|
||||||
|
arch: 'unknown',
|
||||||
|
sipDisabled: false,
|
||||||
|
wechatRunning: false,
|
||||||
|
runtimeReady: false,
|
||||||
|
endpoint: '127.0.0.1:58080',
|
||||||
|
endpointReady: false,
|
||||||
|
attachReady: false,
|
||||||
|
baseAddressReady: false,
|
||||||
|
textHookInstalled: false,
|
||||||
|
textHookReady: false,
|
||||||
|
imageHookInstalled: false,
|
||||||
|
imageHookReady: false,
|
||||||
|
messageListenerReady: false,
|
||||||
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
|
canSendImage: false,
|
||||||
|
canSendVoice: false,
|
||||||
|
message: '无法检测个人微信发送服务',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshStatus()
|
||||||
|
}, [refreshStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api
|
||||||
|
.getTextToSpeechSettings()
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!active) return
|
||||||
|
setTtsSettings(response.settings)
|
||||||
|
if (!response.settings.hasApiKey) return
|
||||||
|
const voicesResponse = await window.api.listTextToSpeechVoices({
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 24
|
||||||
|
})
|
||||||
|
if (!active || !voicesResponse.success) return
|
||||||
|
setTtsVoices(voicesResponse.items)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const current = generatedVoiceRef.current
|
||||||
|
generatedVoiceRef.current = null
|
||||||
|
if (current?.filePath) {
|
||||||
|
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = generatedAudioRef.current
|
||||||
|
return () => audio?.pause()
|
||||||
|
}, [generatedVoice])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape' && !isBusy) handleClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleClose, isBusy])
|
||||||
|
|
||||||
|
const handleRebind = async (): Promise<void> => {
|
||||||
|
if (isBusy) return
|
||||||
|
setIsRebinding(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const nextStatus = await window.api.rebindPersonalWechatSender()
|
||||||
|
setStatus(nextStatus)
|
||||||
|
setResult({
|
||||||
|
success: nextStatus.attachReady && nextStatus.baseAddressReady,
|
||||||
|
message: nextStatus.message
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||||
|
} finally {
|
||||||
|
setIsRebinding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectImage = async (): Promise<void> => {
|
||||||
|
if (isBusy) return
|
||||||
|
const selection = await window.api.selectPersonalWechatImage()
|
||||||
|
if (!selection.canceled && selection.path) {
|
||||||
|
setImage({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectVoice = async (): Promise<void> => {
|
||||||
|
if (isBusy) return
|
||||||
|
const selection = await window.api.selectPersonalWechatVoice()
|
||||||
|
if (!selection.canceled && selection.path) {
|
||||||
|
setVoice({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeMode = (nextMode: SendMode): void => {
|
||||||
|
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
|
||||||
|
clearGeneratedVoice()
|
||||||
|
}
|
||||||
|
setMode(nextMode)
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeVoiceSource = (nextSource: VoiceSource): void => {
|
||||||
|
if (voiceSource === 'generated' && nextSource !== 'generated') clearGeneratedVoice()
|
||||||
|
setVoiceSource(nextSource)
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeVoiceText = (nextText: string): void => {
|
||||||
|
if (generatedVoiceRef.current) clearGeneratedVoice()
|
||||||
|
setVoiceText(nextText)
|
||||||
|
setResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateVoice = async (): Promise<void> => {
|
||||||
|
if (!canGenerate) return
|
||||||
|
clearGeneratedVoice()
|
||||||
|
setIsGenerating(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const generated = await window.api.synthesizeTextToSpeech({
|
||||||
|
text: voiceText.trim(),
|
||||||
|
referenceId: ttsSettings!.selectedVoiceId
|
||||||
|
})
|
||||||
|
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
|
||||||
|
setResult({ success: false, message: generated.error || '语音生成失败' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextVoice = {
|
||||||
|
filePath: generated.filePath,
|
||||||
|
audioDataUrl: generated.audioDataUrl
|
||||||
|
}
|
||||||
|
generatedVoiceRef.current = nextVoice
|
||||||
|
setGeneratedVoice(nextVoice)
|
||||||
|
setResult({ success: true, message: '语音已生成,可以先试听,确认后再发送' })
|
||||||
|
} catch (error) {
|
||||||
|
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleGeneratedPreview = async (): Promise<void> => {
|
||||||
|
const audio = generatedAudioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
if (!audio.paused) {
|
||||||
|
audio.pause()
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await audio.play()
|
||||||
|
setIsPreviewPlaying(true)
|
||||||
|
} catch {
|
||||||
|
setResult({ success: false, message: '语音试听播放失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendGeneratedVoice = async (): Promise<void> => {
|
||||||
|
const current = generatedVoiceRef.current
|
||||||
|
if (!canSendGenerated || !current) return
|
||||||
|
setIsSending(true)
|
||||||
|
setResult(null)
|
||||||
|
generatedAudioRef.current?.pause()
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
try {
|
||||||
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
|
type: 'voice',
|
||||||
|
to: targetId,
|
||||||
|
filePath: current.filePath,
|
||||||
|
isGroup: isGroupChat
|
||||||
|
})
|
||||||
|
setStatus(response.status)
|
||||||
|
setResult({
|
||||||
|
success: response.success,
|
||||||
|
message: response.success
|
||||||
|
? '语音已提交给微信;请从另一设备或群成员处确认实际送达'
|
||||||
|
: response.error || '发送失败'
|
||||||
|
})
|
||||||
|
if (response.success) clearGeneratedVoice()
|
||||||
|
} catch (error) {
|
||||||
|
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async (): Promise<void> => {
|
||||||
|
if (!canSubmit || (mode === 'voice' && voiceSource === 'generated')) return
|
||||||
|
setIsSending(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const response = await window.api.sendPersonalWechatMessage(
|
||||||
|
mode === 'voice'
|
||||||
|
? { type: 'voice', to: targetId, filePath: voice!.path, isGroup: isGroupChat }
|
||||||
|
: { type: 'image', to: targetId, filePath: image!.path, isGroup: isGroupChat }
|
||||||
|
)
|
||||||
|
setStatus(response.status)
|
||||||
|
setResult({
|
||||||
|
success: response.success,
|
||||||
|
message: response.success
|
||||||
|
? `${mode === 'voice' ? '语音' : '图片'}已提交给微信;请从另一设备或群成员处确认实际送达`
|
||||||
|
: response.error || '发送失败'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusItems = [
|
||||||
|
['微信进程', status?.wechatPid ? `PID ${status.wechatPid}` : '未检测到'],
|
||||||
|
[
|
||||||
|
'OneBot',
|
||||||
|
status?.oneBotPid
|
||||||
|
? `PID ${status.oneBotPid}${status.boundWechatPid ? ` · 绑定 ${status.boundWechatPid}` : ''}`
|
||||||
|
: '未启动'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'接口',
|
||||||
|
`${status?.endpoint || '127.0.0.1:58080'} · ${readyText(Boolean(status?.endpointReady), '监听中', '未监听')}`
|
||||||
|
],
|
||||||
|
['基址扫描', status?.baseAddress || readyText(Boolean(status?.baseAddressReady))],
|
||||||
|
['图片 Hook', readyText(Boolean(status?.imageHookReady), '已捕获,可发送', '等待手动发图片')],
|
||||||
|
['语音能力', readyText(Boolean(status?.canSendVoice), '可发送', '等待媒体 Hook 初始化')],
|
||||||
|
['消息监听', readyText(Boolean(status?.messageListenerReady), '正常', '等待收到微信消息')]
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="personal-wechat-send-backdrop" role="presentation" onMouseDown={handleClose}>
|
||||||
|
<section
|
||||||
|
className="personal-wechat-send-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="personal-wechat-send-title"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="personal-wechat-send-kicker">实验性功能</span>
|
||||||
|
<h2 id="personal-wechat-send-title">个人微信测试发送</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="personal-wechat-send-close" onClick={handleClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="personal-wechat-send-device-note" role="note">
|
||||||
|
<span aria-hidden>i</span>
|
||||||
|
<p>
|
||||||
|
<strong>请在其他设备确认发送结果</strong>
|
||||||
|
通过测试功能发送的消息不会显示在本机微信会话中,请切换到手机、平板等其他设备确认是否送达。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="personal-wechat-send-target">
|
||||||
|
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||||
|
<strong>{displayName}</strong>
|
||||||
|
<code>{targetId}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`personal-wechat-send-status ${statusTone}`}>
|
||||||
|
<span className="personal-wechat-send-status-dot" />
|
||||||
|
<div>
|
||||||
|
<strong>{statusLabel(status)}</strong>
|
||||||
|
<p>{statusDescription(status)}</p>
|
||||||
|
{status?.wechatVersion && <small>微信版本:{status.wechatVersion}</small>}
|
||||||
|
{status?.error &&
|
||||||
|
status.state !== 'unsupported_version' &&
|
||||||
|
status.state !== 'runtime_missing' && <small className="error">{status.error}</small>}
|
||||||
|
</div>
|
||||||
|
<div className="personal-wechat-send-status-actions">
|
||||||
|
{(status?.state === 'unsupported_version' || status?.state === 'runtime_missing') &&
|
||||||
|
onOpenTextToSpeechSettings ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
|
||||||
|
}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" onClick={() => void refreshStatus()} disabled={isBusy}>
|
||||||
|
重新检测
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleRebind()} disabled={isBusy}>
|
||||||
|
{isRebinding ? '绑定中…' : '尝试重新绑定'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="personal-wechat-send-diagnostics">
|
||||||
|
{statusItems.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="personal-wechat-send-mode" role="radiogroup" aria-label="测试消息类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === 'image'}
|
||||||
|
className={mode === 'image' ? 'active' : ''}
|
||||||
|
onClick={() => changeMode('image')}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === 'voice'}
|
||||||
|
className={mode === 'voice' ? 'active' : ''}
|
||||||
|
onClick={() => changeMode('voice')}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
语音
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'image' ? (
|
||||||
|
<div className="personal-wechat-send-image-picker">
|
||||||
|
<span>图片</span>
|
||||||
|
<button type="button" onClick={() => void handleSelectImage()} disabled={isBusy}>
|
||||||
|
{image ? '重新选择图片' : '选择图片'}
|
||||||
|
</button>
|
||||||
|
{image ? (
|
||||||
|
<div>
|
||||||
|
<strong>{image.name}</strong>
|
||||||
|
<small>{image.path}</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<small>支持 PNG、JPG、GIF 和 WebP,最大 20 MB</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="personal-wechat-voice-composer">
|
||||||
|
<div className="personal-wechat-voice-source" role="radiogroup" aria-label="语音来源">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={voiceSource === 'generated'}
|
||||||
|
className={voiceSource === 'generated' ? 'active' : ''}
|
||||||
|
onClick={() => changeVoiceSource('generated')}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
输入文字生成
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={voiceSource === 'file'}
|
||||||
|
className={voiceSource === 'file' ? 'active' : ''}
|
||||||
|
onClick={() => changeVoiceSource('file')}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
选择本地文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{voiceSource === 'generated' ? (
|
||||||
|
<div className="personal-wechat-generated-voice">
|
||||||
|
<div className="personal-wechat-generated-voice-heading">
|
||||||
|
<div>
|
||||||
|
<span>文字生成语音</span>
|
||||||
|
<strong>{selectedTtsVoice?.name || '尚未选择音色'}</strong>
|
||||||
|
</div>
|
||||||
|
{onOpenTextToSpeechSettings ? (
|
||||||
|
<button type="button" onClick={() => handleOpenTextToSpeechSettings()}>
|
||||||
|
前往文字转语音设置
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="personal-wechat-send-editor">
|
||||||
|
<span>要生成的文字</span>
|
||||||
|
<textarea
|
||||||
|
aria-label="要生成的文字"
|
||||||
|
value={voiceText}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={3}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(event) => changeVoiceText(event.target.value)}
|
||||||
|
/>
|
||||||
|
<small>{voiceText.length} / 1000</small>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className={`personal-wechat-tts-readiness ${ttsSettings?.hasApiKey ? 'ready' : ''}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{generatedVoiceReady
|
||||||
|
? 'API Key 与音色已准备'
|
||||||
|
: ttsSettings?.hasApiKey
|
||||||
|
? '还需要在设置中选择音色'
|
||||||
|
: '还需要配置语音服务 API Key'}
|
||||||
|
</strong>
|
||||||
|
<span>点击右下角“生成语音”,生成完成后可以试听,再决定是否发送。</span>
|
||||||
|
</div>
|
||||||
|
{isGenerating ? (
|
||||||
|
<div className="personal-wechat-generation-progress" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<strong>正在生成语音…</strong>
|
||||||
|
<span>通常需要几秒,请稍候</span>
|
||||||
|
</div>
|
||||||
|
<div className="personal-wechat-generation-track">
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : generatedVoice ? (
|
||||||
|
<div className="personal-wechat-generated-result">
|
||||||
|
<audio
|
||||||
|
ref={generatedAudioRef}
|
||||||
|
src={generatedVoice.audioDataUrl}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||||
|
onTimeUpdate={(event) =>
|
||||||
|
setPreviewCurrentTime(event.currentTarget.currentTime)
|
||||||
|
}
|
||||||
|
onPause={() => setIsPreviewPlaying(false)}
|
||||||
|
onPlay={() => setIsPreviewPlaying(true)}
|
||||||
|
onEnded={() => {
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
setPreviewCurrentTime(0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="personal-wechat-generated-result-copy">
|
||||||
|
<strong>语音生成完成</strong>
|
||||||
|
<span>
|
||||||
|
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
|
||||||
|
</span>
|
||||||
|
<div className="personal-wechat-preview-track">
|
||||||
|
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="personal-wechat-generated-result-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleToggleGeneratedPreview()}
|
||||||
|
disabled={isSending}
|
||||||
|
>
|
||||||
|
{isPreviewPlaying ? '暂停' : '播放'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => void handleSendGeneratedVoice()}
|
||||||
|
disabled={!canSendGenerated}
|
||||||
|
>
|
||||||
|
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="personal-wechat-send-image-picker">
|
||||||
|
<span>本地语音文件</span>
|
||||||
|
<button type="button" onClick={() => void handleSelectVoice()} disabled={isBusy}>
|
||||||
|
{voice ? '重新选择语音' : '选择语音'}
|
||||||
|
</button>
|
||||||
|
{voice ? (
|
||||||
|
<div>
|
||||||
|
<strong>{voice.name}</strong>
|
||||||
|
<small>{voice.path}</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<small>支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC,最大 20 MB</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="personal-wechat-send-note">
|
||||||
|
{mode === 'voice' && voiceSource === 'generated'
|
||||||
|
? '文字生成语音会复用现有媒体上传能力;请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||||
|
: mode === 'voice'
|
||||||
|
? '本地语音复用媒体上传 Hook,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||||
|
: '如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'}
|
||||||
|
微信重新登录导致 PID 改变时,请点击“尝试重新绑定”。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className={`personal-wechat-send-result ${result.success ? 'success' : 'error'}`}>
|
||||||
|
{result.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button type="button" className="secondary" onClick={handleClose} disabled={isBusy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
{mode === 'voice' && voiceSource === 'generated' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
disabled={!canGenerate}
|
||||||
|
onClick={() => void handleGenerateVoice()}
|
||||||
|
>
|
||||||
|
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="primary" disabled={!canSubmit} onClick={handleSend}>
|
||||||
|
{isSending
|
||||||
|
? '正在发送…'
|
||||||
|
: `测试发送${mode === 'voice' ? '语音' : '图片'}到${isGroupChat ? '群聊' : '联系人'}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,6 +53,15 @@ export function AiIcon({ className }: IconProps): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m4 5 16 7-16 7 3-7-3-7Z" />
|
||||||
|
<path d="M7 12h13" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function CloseIcon({ className }: IconProps): React.ReactElement {
|
export function CloseIcon({ className }: IconProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface ReportToolbarProps {
|
|||||||
canReveal: boolean
|
canReveal: boolean
|
||||||
canShare: boolean
|
canShare: boolean
|
||||||
canSwitchTemplate: boolean
|
canSwitchTemplate: boolean
|
||||||
|
canSendToGroup?: boolean
|
||||||
|
sendToGroupHint?: string
|
||||||
currentTemplateId?: SelectableReportTemplateId
|
currentTemplateId?: SelectableReportTemplateId
|
||||||
isSwitchingTemplate: boolean
|
isSwitchingTemplate: boolean
|
||||||
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
|
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
|
||||||
@@ -16,6 +18,7 @@ interface ReportToolbarProps {
|
|||||||
onCopyImage: () => void
|
onCopyImage: () => void
|
||||||
onReveal: () => void
|
onReveal: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
|
onSendToGroup?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportToolbar({
|
export function ReportToolbar({
|
||||||
@@ -23,13 +26,16 @@ export function ReportToolbar({
|
|||||||
canReveal,
|
canReveal,
|
||||||
canShare,
|
canShare,
|
||||||
canSwitchTemplate,
|
canSwitchTemplate,
|
||||||
|
canSendToGroup = false,
|
||||||
|
sendToGroupHint = '当前报告暂时无法发送',
|
||||||
currentTemplateId,
|
currentTemplateId,
|
||||||
isSwitchingTemplate,
|
isSwitchingTemplate,
|
||||||
onSwitchTemplate,
|
onSwitchTemplate,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
onReveal,
|
onReveal,
|
||||||
onShare
|
onShare,
|
||||||
|
onSendToGroup
|
||||||
}: ReportToolbarProps): React.ReactElement {
|
}: ReportToolbarProps): React.ReactElement {
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
const [templateOpen, setTemplateOpen] = useState(false)
|
const [templateOpen, setTemplateOpen] = useState(false)
|
||||||
@@ -89,6 +95,16 @@ export function ReportToolbar({
|
|||||||
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}>
|
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}>
|
||||||
复制图片
|
复制图片
|
||||||
</button>
|
</button>
|
||||||
|
<span
|
||||||
|
className="report-toolbar-button-hint"
|
||||||
|
title={sendToGroupHint}
|
||||||
|
aria-label={sendToGroupHint}
|
||||||
|
tabIndex={canSendToGroup ? -1 : 0}
|
||||||
|
>
|
||||||
|
<button type="button" disabled={!canSendToGroup} onClick={() => onSendToGroup?.()}>
|
||||||
|
发送到当前群聊
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
||||||
打开报告
|
打开报告
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { ReportEmptyState } from './ReportEmptyState'
|
|||||||
import { ReportToolbar } from './ReportToolbar'
|
import { ReportToolbar } from './ReportToolbar'
|
||||||
import { ReportZoomBar } from './ReportZoomBar'
|
import { ReportZoomBar } from './ReportZoomBar'
|
||||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||||
|
import type { Contact } from '../../../../shared/types'
|
||||||
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||||
|
import { PersonalWechatSendDialog } from '../chat/PersonalWechatSendDialog'
|
||||||
|
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||||
|
|
||||||
interface ReportViewerProps {
|
interface ReportViewerProps {
|
||||||
report: GeneratedReportRecord | null
|
report: GeneratedReportRecord | null
|
||||||
@@ -17,6 +20,8 @@ interface ReportViewerProps {
|
|||||||
report: GeneratedReportRecord,
|
report: GeneratedReportRecord,
|
||||||
templateId: SelectableReportTemplateId
|
templateId: SelectableReportTemplateId
|
||||||
) => Promise<{ success: boolean; error?: string }>
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
|
sendTarget?: Contact | null
|
||||||
|
personalWechatSendSupported?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateFitZoom = (
|
const calculateFitZoom = (
|
||||||
@@ -43,7 +48,9 @@ export function ReportViewer({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
onReveal,
|
onReveal,
|
||||||
onSwitchTemplate
|
onSwitchTemplate,
|
||||||
|
sendTarget = null,
|
||||||
|
personalWechatSendSupported = supportsPersonalWechatSend
|
||||||
}: ReportViewerProps): React.ReactElement {
|
}: ReportViewerProps): React.ReactElement {
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [fitZoom, setFitZoom] = useState(1)
|
const [fitZoom, setFitZoom] = useState(1)
|
||||||
@@ -51,6 +58,7 @@ export function ReportViewer({
|
|||||||
const [imageError, setImageError] = useState('')
|
const [imageError, setImageError] = useState('')
|
||||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
||||||
const [shareDialogOpen, setShareDialogOpen] = useState(false)
|
const [shareDialogOpen, setShareDialogOpen] = useState(false)
|
||||||
|
const [sendDialogOpen, setSendDialogOpen] = useState(false)
|
||||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -60,6 +68,7 @@ export function ReportViewer({
|
|||||||
setImageError('')
|
setImageError('')
|
||||||
setIsSwitchingTemplate(false)
|
setIsSwitchingTemplate(false)
|
||||||
setShareDialogOpen(false)
|
setShareDialogOpen(false)
|
||||||
|
setSendDialogOpen(false)
|
||||||
setZoom(1)
|
setZoom(1)
|
||||||
setFitZoom(1)
|
setFitZoom(1)
|
||||||
setNaturalSize(null)
|
setNaturalSize(null)
|
||||||
@@ -68,6 +77,14 @@ export function ReportViewer({
|
|||||||
}, [report?.id])
|
}, [report?.id])
|
||||||
|
|
||||||
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||||
|
const sendToGroupHint = !personalWechatSendSupported
|
||||||
|
? '仅支持 macOS'
|
||||||
|
: !sendTarget
|
||||||
|
? '未找到这份日报对应的群聊'
|
||||||
|
: !report?.pngPath
|
||||||
|
? '当前报告缺少可发送的 PNG 文件'
|
||||||
|
: '打开确认窗口,将日报图片发送到当前群聊'
|
||||||
|
const canSendToGroup = Boolean(personalWechatSendSupported && sendTarget && report?.pngPath)
|
||||||
|
|
||||||
const measureFitZoom = (): number | null => {
|
const measureFitZoom = (): number | null => {
|
||||||
const viewport = viewportRef.current
|
const viewport = viewportRef.current
|
||||||
@@ -169,6 +186,8 @@ export function ReportViewer({
|
|||||||
canCopyImage={Boolean(report.generatedImage)}
|
canCopyImage={Boolean(report.generatedImage)}
|
||||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||||
canShare={Boolean(report.pngPath)}
|
canShare={Boolean(report.pngPath)}
|
||||||
|
canSendToGroup={canSendToGroup}
|
||||||
|
sendToGroupHint={sendToGroupHint}
|
||||||
canSwitchTemplate={Boolean(
|
canSwitchTemplate={Boolean(
|
||||||
(report.reportSnapshot && report.reportMetadata) ||
|
(report.reportSnapshot && report.reportMetadata) ||
|
||||||
report.reportRenderSnapshot ||
|
report.reportRenderSnapshot ||
|
||||||
@@ -181,6 +200,7 @@ export function ReportViewer({
|
|||||||
onCopyImage={() => void handleCopy()}
|
onCopyImage={() => void handleCopy()}
|
||||||
onReveal={() => void handleReveal()}
|
onReveal={() => void handleReveal()}
|
||||||
onShare={() => setShareDialogOpen(true)}
|
onShare={() => setShareDialogOpen(true)}
|
||||||
|
onSendToGroup={() => setSendDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
{status && <div className="report-viewer-status">{status}</div>}
|
{status && <div className="report-viewer-status">{status}</div>}
|
||||||
@@ -246,6 +266,18 @@ export function ReportViewer({
|
|||||||
onClose={() => setShareDialogOpen(false)}
|
onClose={() => setShareDialogOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sendDialogOpen && report.pngPath && sendTarget && (
|
||||||
|
<PersonalWechatSendDialog
|
||||||
|
contact={sendTarget}
|
||||||
|
isGroupChat
|
||||||
|
initialMode="image"
|
||||||
|
initialImage={{
|
||||||
|
path: report.pngPath,
|
||||||
|
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
|
||||||
|
}}
|
||||||
|
onClose={() => setSendDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
|||||||
import { AppearancePage } from './pages/AppearancePage'
|
import { AppearancePage } from './pages/AppearancePage'
|
||||||
import { AboutPage } from './pages/AboutPage'
|
import { AboutPage } from './pages/AboutPage'
|
||||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||||
|
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ export function SettingsWorkspace({
|
|||||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
case 'voice-recognition':
|
case 'voice-recognition':
|
||||||
return <VoiceRecognitionPage onNotice={onNotice} />
|
return <VoiceRecognitionPage onNotice={onNotice} />
|
||||||
|
case 'text-to-speech':
|
||||||
|
return <TextToSpeechPage onNotice={onNotice} />
|
||||||
case 'recall-protection':
|
case 'recall-protection':
|
||||||
return <RecallProtectionPage onNotice={onNotice} />
|
return <RecallProtectionPage onNotice={onNotice} />
|
||||||
case 'advanced':
|
case 'advanced':
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DatabaseKeyState } from './types'
|
import type { DatabaseKeyState } from './types'
|
||||||
|
import { runtimePlatform } from '../../../utils/runtime-environment'
|
||||||
|
|
||||||
const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成']
|
const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成']
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export function DatabaseKeyAutoDetect({
|
|||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const environment = state.environment
|
const environment = state.environment
|
||||||
const platform = environment?.platform || window.electron.process.platform
|
const platform = environment?.platform || runtimePlatform
|
||||||
if (platform !== 'win32') {
|
if (platform !== 'win32') {
|
||||||
return (
|
return (
|
||||||
<section className="settings-card database-key-auto database-key-auto-manual">
|
<section className="settings-card database-key-auto database-key-auto-manual">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
label: '智能能力',
|
label: '智能能力',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'voice-recognition', label: '语音转文字' },
|
{ id: 'voice-recognition', label: '语音转文字' },
|
||||||
|
{ id: 'text-to-speech', label: '文字转语音' },
|
||||||
{ id: 'ai-model', label: 'AI 模型' }
|
{ id: 'ai-model', label: 'AI 模型' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
|||||||
| 'database-key'
|
| 'database-key'
|
||||||
| 'image-key'
|
| 'image-key'
|
||||||
| 'voice-recognition'
|
| 'voice-recognition'
|
||||||
|
| 'text-to-speech'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
| 'recall-protection'
|
| 'recall-protection'
|
||||||
| 'local-api'
|
| 'local-api'
|
||||||
|
|||||||
@@ -0,0 +1,931 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type {
|
||||||
|
TextToSpeechModel,
|
||||||
|
TextToSpeechSettings,
|
||||||
|
TextToSpeechVoice
|
||||||
|
} from '../../../../../shared/text-to-speech'
|
||||||
|
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
|
||||||
|
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
||||||
|
|
||||||
|
const VOICE_PAGE_SIZE = 24
|
||||||
|
const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
|
||||||
|
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||||
|
const BUNDLED_WECHAT_VERSIONS = [
|
||||||
|
'4.1.6.12',
|
||||||
|
'4.1.6.46',
|
||||||
|
'4.1.6.47',
|
||||||
|
'4.1.7.31',
|
||||||
|
'4.1.7.55',
|
||||||
|
'4.1.7.57',
|
||||||
|
'4.1.8.28',
|
||||||
|
'4.1.8.29',
|
||||||
|
'4.1.8.104',
|
||||||
|
'4.1.8.107',
|
||||||
|
'4.1.9.52',
|
||||||
|
'4.1.9.55',
|
||||||
|
'4.1.9.58',
|
||||||
|
'4.1.10.53',
|
||||||
|
'4.1.11.53'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
|
||||||
|
missing: '未下载',
|
||||||
|
downloading: '下载中',
|
||||||
|
ready: '已就绪',
|
||||||
|
invalid: '需要修复',
|
||||||
|
error: '下载失败',
|
||||||
|
unsupported: '暂不支持'
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOICE_FILTERS = [
|
||||||
|
{ value: 'male', label: '男性' },
|
||||||
|
{ value: 'female', label: '女性' },
|
||||||
|
{ value: 'neutral', label: '中性' },
|
||||||
|
{ value: 'young', label: '年轻' },
|
||||||
|
{ value: 'middle-aged', label: '中年' },
|
||||||
|
{ value: 'narration', label: '旁白' },
|
||||||
|
{ value: 'social-media', label: '社交媒体' },
|
||||||
|
{ value: 'sexy', label: '性感' },
|
||||||
|
{ value: 'documentary', label: '纪录片' },
|
||||||
|
{ value: 'deep', label: '深沉' },
|
||||||
|
{ value: 'soft', label: '柔和' },
|
||||||
|
{ value: 'dramatic', label: '戏剧感' },
|
||||||
|
{ value: 'mysterious', label: '神秘' },
|
||||||
|
{ value: 'anime', label: '动漫' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const VOICE_FILTER_GROUPS = [
|
||||||
|
['male', 'female', 'neutral'],
|
||||||
|
['young', 'middle-aged']
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const VOICE_TAG_LABELS: Record<string, string> = {
|
||||||
|
zh: '中文',
|
||||||
|
Chinese: '中文',
|
||||||
|
en: '英语',
|
||||||
|
English: '英语',
|
||||||
|
male: '男性',
|
||||||
|
female: '女性',
|
||||||
|
neutral: '中性',
|
||||||
|
young: '年轻',
|
||||||
|
'middle-aged': '中年',
|
||||||
|
old: '年长',
|
||||||
|
conversational: '对话',
|
||||||
|
narration: '旁白',
|
||||||
|
'character-voice': '角色声音',
|
||||||
|
'social-media': '社交媒体',
|
||||||
|
educational: '教育',
|
||||||
|
advertisement: '广告',
|
||||||
|
entertainment: '娱乐',
|
||||||
|
deep: '深沉',
|
||||||
|
low: '低沉',
|
||||||
|
medium: '中等',
|
||||||
|
high: '高亢',
|
||||||
|
soft: '柔和',
|
||||||
|
bright: '明亮',
|
||||||
|
warm: '温暖',
|
||||||
|
dark: '暗沉',
|
||||||
|
raspy: '沙哑',
|
||||||
|
smooth: '顺滑',
|
||||||
|
breathy: '气声',
|
||||||
|
husky: '烟嗓',
|
||||||
|
energetic: '有活力',
|
||||||
|
calm: '沉稳',
|
||||||
|
relaxed: '放松',
|
||||||
|
fast: '快速',
|
||||||
|
slow: '缓慢',
|
||||||
|
measured: '从容',
|
||||||
|
dynamic: '动态',
|
||||||
|
sexy: '性感',
|
||||||
|
friendly: '亲切',
|
||||||
|
professional: '专业',
|
||||||
|
serious: '严肃',
|
||||||
|
cheerful: '欢快',
|
||||||
|
enthusiastic: '热情',
|
||||||
|
confident: '自信',
|
||||||
|
authoritative: '权威',
|
||||||
|
gentle: '温柔',
|
||||||
|
empathetic: '共情',
|
||||||
|
playful: '活泼',
|
||||||
|
dramatic: '戏剧感',
|
||||||
|
intimate: '亲密',
|
||||||
|
mysterious: '神秘',
|
||||||
|
sad: '悲伤',
|
||||||
|
angry: '愤怒',
|
||||||
|
clear: '清晰',
|
||||||
|
crisp: '清脆',
|
||||||
|
'neutral-tone': '中性语气',
|
||||||
|
expressive: '有表现力',
|
||||||
|
monotone: '平稳',
|
||||||
|
animated: '生动',
|
||||||
|
storytelling: '故事感',
|
||||||
|
narrative: '叙事',
|
||||||
|
character: '角色',
|
||||||
|
announcer: '播音',
|
||||||
|
host: '主持',
|
||||||
|
teacher: '教师',
|
||||||
|
coach: '教练',
|
||||||
|
anime: '动漫',
|
||||||
|
gaming: '游戏',
|
||||||
|
cinematic: '电影感',
|
||||||
|
documentary: '纪录片',
|
||||||
|
radio: '电台',
|
||||||
|
podcast: '播客'
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCount(value?: number): string {
|
||||||
|
if (!value) return '0'
|
||||||
|
return new Intl.NumberFormat('zh-CN', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function voiceMetaTags(voice: TextToSpeechVoice): string[] {
|
||||||
|
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function voiceTagLabel(tag: string): string {
|
||||||
|
return VOICE_TAG_LABELS[tag] || tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceAvatar({ voice }: { voice: TextToSpeechVoice }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className="tts-voice-media" aria-hidden>
|
||||||
|
<span className="tts-voice-avatar">{voice.name.slice(0, 1)}</span>
|
||||||
|
{voice.coverImage ? (
|
||||||
|
<img
|
||||||
|
src={voice.coverImage}
|
||||||
|
alt=""
|
||||||
|
className="tts-voice-cover"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.hidden = true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextToSpeechPage({
|
||||||
|
onNotice
|
||||||
|
}: {
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [settings, setSettings] = useState<TextToSpeechSettings | null>(null)
|
||||||
|
const [voices, setVoices] = useState<TextToSpeechVoice[]>([])
|
||||||
|
const [selectedVoiceId, setSelectedVoiceId] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [appliedQuery, setAppliedQuery] = useState('')
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||||
|
const [pageNumber, setPageNumber] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingVoices, setLoadingVoices] = useState(false)
|
||||||
|
const [savingKey, setSavingKey] = useState(false)
|
||||||
|
const [savingVoiceId, setSavingVoiceId] = useState('')
|
||||||
|
const [playingVoiceId, setPlayingVoiceId] = useState('')
|
||||||
|
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
|
||||||
|
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
||||||
|
const [showWechatVersions, setShowWechatVersions] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
|
||||||
|
|
||||||
|
const loadVoices = useCallback(
|
||||||
|
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
|
||||||
|
setLoadingVoices(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await window.api.listTextToSpeechVoices({
|
||||||
|
pageNumber: nextPage,
|
||||||
|
pageSize: VOICE_PAGE_SIZE,
|
||||||
|
title: title || undefined,
|
||||||
|
tags
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || '音色加载失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVoices((current) => {
|
||||||
|
const merged = append ? [...current, ...result.items] : result.items
|
||||||
|
return Array.from(new Map(merged.map((voice) => [voice.id, voice])).values())
|
||||||
|
})
|
||||||
|
setPageNumber(result.pageNumber)
|
||||||
|
setTotal(result.total)
|
||||||
|
setHasMore(result.hasMore)
|
||||||
|
setAppliedQuery(title)
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '音色加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoadingVoices(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api
|
||||||
|
.getTextToSpeechSettings()
|
||||||
|
.then(async (result) => {
|
||||||
|
if (!active) return
|
||||||
|
setSettings(result.settings)
|
||||||
|
setSelectedVoiceId(result.settings.selectedVoiceId)
|
||||||
|
setError(result.success ? '' : result.error || '文字转语音配置读取失败')
|
||||||
|
setLoading(false)
|
||||||
|
if (result.settings.hasApiKey) await loadVoices(1, '')
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!active) return
|
||||||
|
setError(reason instanceof Error ? reason.message : '文字转语音配置读取失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
audioRef.current?.pause()
|
||||||
|
}
|
||||||
|
}, [loadVoices])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY) !== '1') return
|
||||||
|
sessionStorage.removeItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY)
|
||||||
|
setShowWechatVersions(true)
|
||||||
|
} catch {
|
||||||
|
// The page remains usable if session storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api
|
||||||
|
.getPersonalWechatRuntimeStatus()
|
||||||
|
.then((status) => active && setRuntimeStatus(status))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!active) return
|
||||||
|
setRuntimeStatus(null)
|
||||||
|
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
|
||||||
|
})
|
||||||
|
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
|
||||||
|
if (active) setRuntimeStatus(status)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedVoice = voices.find((voice) => voice.id === selectedVoiceId)
|
||||||
|
const visibleVoices = useMemo(() => voices, [voices])
|
||||||
|
|
||||||
|
const saveApiKey = async (): Promise<void> => {
|
||||||
|
if (!apiKey.trim() || savingKey) return
|
||||||
|
setSavingKey(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await window.api.saveTextToSpeechSettings({ apiKey: apiKey.trim() })
|
||||||
|
setSettings(result.settings)
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || 'API Key 保存失败')
|
||||||
|
onNotice(result.error || 'API Key 保存失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setApiKey('')
|
||||||
|
onNotice('API Key 已安全保存')
|
||||||
|
await loadVoices(1, '')
|
||||||
|
} catch (reason) {
|
||||||
|
const message = reason instanceof Error ? reason.message : 'API Key 保存失败'
|
||||||
|
setError(message)
|
||||||
|
onNotice(message)
|
||||||
|
} finally {
|
||||||
|
setSavingKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearApiKey = async (): Promise<void> => {
|
||||||
|
if (!settings?.hasStoredApiKey || savingKey) return
|
||||||
|
setSavingKey(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await window.api.saveTextToSpeechSettings({ clearApiKey: true })
|
||||||
|
setSettings(result.settings)
|
||||||
|
setApiKey('')
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || 'API Key 清除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onNotice(
|
||||||
|
result.settings.hasEnvironmentApiKey
|
||||||
|
? '已清除应用内 Key,将继续使用应用环境中的 Key'
|
||||||
|
: 'API Key 已清除'
|
||||||
|
)
|
||||||
|
if (!result.settings.hasApiKey) {
|
||||||
|
setVoices([])
|
||||||
|
setTotal(0)
|
||||||
|
setHasMore(false)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSavingKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectVoice = async (voice: TextToSpeechVoice): Promise<void> => {
|
||||||
|
if (savingVoiceId) return
|
||||||
|
const previous = selectedVoiceId
|
||||||
|
setSelectedVoiceId(voice.id)
|
||||||
|
setSavingVoiceId(voice.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await window.api.saveTextToSpeechSettings({ selectedVoiceId: voice.id })
|
||||||
|
setSettings(result.settings)
|
||||||
|
if (!result.success) {
|
||||||
|
setSelectedVoiceId(previous)
|
||||||
|
setError(result.error || '音色保存失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onNotice(`已选择音色:${voice.name}`)
|
||||||
|
} catch (reason) {
|
||||||
|
setSelectedVoiceId(previous)
|
||||||
|
setError(reason instanceof Error ? reason.message : '音色保存失败')
|
||||||
|
} finally {
|
||||||
|
setSavingVoiceId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeModel = async (model: TextToSpeechModel): Promise<void> => {
|
||||||
|
if (!settings) return
|
||||||
|
const previous = settings.model
|
||||||
|
setSettings({ ...settings, model })
|
||||||
|
const result = await window.api.saveTextToSpeechSettings({ model })
|
||||||
|
if (!result.success) {
|
||||||
|
setSettings({ ...settings, model: previous })
|
||||||
|
setError(result.error || '合成模型保存失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSettings(result.settings)
|
||||||
|
onNotice(model === 's2.1-pro-free' ? '已切换到标准模型' : '已切换到高质量模型')
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchVoices = async (): Promise<void> => {
|
||||||
|
await loadVoices(1, query.trim(), false, selectedTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVoiceFilter = async (tag: string): Promise<void> => {
|
||||||
|
const isSelected = selectedTags.includes(tag)
|
||||||
|
const exclusiveGroup = VOICE_FILTER_GROUPS.find((group) => group.includes(tag as never))
|
||||||
|
const nextTags = isSelected
|
||||||
|
? selectedTags.filter((item) => item !== tag)
|
||||||
|
: [
|
||||||
|
...(exclusiveGroup
|
||||||
|
? selectedTags.filter((item) => !exclusiveGroup.includes(item as never))
|
||||||
|
: selectedTags),
|
||||||
|
tag
|
||||||
|
]
|
||||||
|
setSelectedTags(nextTags)
|
||||||
|
await loadVoices(1, appliedQuery, false, nextTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearVoiceFilters = async (): Promise<void> => {
|
||||||
|
setSelectedTags([])
|
||||||
|
await loadVoices(1, appliedQuery, false, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const playPreview = (voice: TextToSpeechVoice): void => {
|
||||||
|
if (!voice.previewUrl) {
|
||||||
|
onNotice('这个音色暂时没有公开试听片段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioRef.current?.pause()
|
||||||
|
if (playingVoiceId === voice.id) {
|
||||||
|
setPlayingVoiceId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const audio = new Audio(voice.previewUrl)
|
||||||
|
audioRef.current = audio
|
||||||
|
setPlayingVoiceId(voice.id)
|
||||||
|
audio.addEventListener('ended', () => setPlayingVoiceId(''), { once: true })
|
||||||
|
audio.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
setPlayingVoiceId('')
|
||||||
|
onNotice('音色试听加载失败')
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
void audio.play().catch(() => {
|
||||||
|
setPlayingVoiceId('')
|
||||||
|
onNotice('音色试听播放失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openApiKeys = async (): Promise<void> => {
|
||||||
|
const result = await window.api.openFishAudioApiKeys()
|
||||||
|
if (!result.success) onNotice(result.error || '无法打开 API Key 页面')
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshRuntime = async (): Promise<void> => {
|
||||||
|
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadRuntime = async (): Promise<void> => {
|
||||||
|
if (runtimeBusy || !isMac || !runtimeStatus?.supported) return
|
||||||
|
setRuntimeBusy(true)
|
||||||
|
setRuntimeStatus((current) =>
|
||||||
|
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const result = await window.api.downloadPersonalWechatRuntime()
|
||||||
|
setRuntimeStatus(result.status)
|
||||||
|
onNotice(result.success ? '微信发送组件已准备好' : result.error || '发送组件下载失败')
|
||||||
|
} finally {
|
||||||
|
setRuntimeBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRuntimeDownload = async (): Promise<void> => {
|
||||||
|
if (!personalWechatRuntimeSupported) return
|
||||||
|
await window.api.cancelPersonalWechatRuntimeDownload()
|
||||||
|
onNotice('正在取消发送组件下载')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRuntime = async (): Promise<void> => {
|
||||||
|
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
|
||||||
|
if (!window.confirm('卸载微信发送组件?以后需要向个人微信发送语音时可以重新下载。')) return
|
||||||
|
setRuntimeBusy(true)
|
||||||
|
try {
|
||||||
|
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
|
||||||
|
onNotice('微信发送组件已卸载')
|
||||||
|
} catch (reason) {
|
||||||
|
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
|
||||||
|
} finally {
|
||||||
|
setRuntimeBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRuntimeDirectory = async (): Promise<void> => {
|
||||||
|
if (!personalWechatRuntimeSupported) return
|
||||||
|
const result = await window.api.openPersonalWechatRuntimeDirectory()
|
||||||
|
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyStatusText = !settings
|
||||||
|
? '正在读取 API Key 状态'
|
||||||
|
: settings.keySource === 'secure-storage'
|
||||||
|
? 'Key 已保存在系统安全存储中,页面不会回显完整内容'
|
||||||
|
: settings.keySource === 'environment'
|
||||||
|
? '已从应用环境中读取 API Key'
|
||||||
|
: settings.encryptionAvailable
|
||||||
|
? '还没有配置 API Key,可在这里直接保存'
|
||||||
|
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page text-to-speech-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>文字转语音</h1>
|
||||||
|
<p>选择喜欢的音色,把文字生成为可发送的微信语音</p>
|
||||||
|
</div>
|
||||||
|
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
|
||||||
|
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content text-to-speech-content">
|
||||||
|
<section className="tts-usage-guide">
|
||||||
|
<div className="tts-usage-guide-heading">
|
||||||
|
<div>
|
||||||
|
<span className="tts-experimental-badge">实验性功能</span>
|
||||||
|
<h2>使用说明</h2>
|
||||||
|
</div>
|
||||||
|
<p>语音生成与微信发送是两个独立步骤。建议先生成并试听,确认无误后再发送。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="tts-usage-steps">
|
||||||
|
<li>
|
||||||
|
<span>1</span>
|
||||||
|
<div>
|
||||||
|
<strong>配置并选择音色</strong>
|
||||||
|
<p>保存 API Key,在下方音色库中选择一个音色。</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>2</span>
|
||||||
|
<div>
|
||||||
|
<strong>在档案中生成语音</strong>
|
||||||
|
<p>打开联系人或群聊的测试发送,选择“语音 → 输入文字生成”。</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>3</span>
|
||||||
|
<div>
|
||||||
|
<strong>试听后手动发送</strong>
|
||||||
|
<p>点击“生成语音”,播放检查内容,再点击发送到当前会话。</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="tts-hook-warning">
|
||||||
|
<div className="tts-hook-warning-icon" aria-hidden>
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>微信发送能力注意事项</strong>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
仅支持与当前 OneBot 运行时配置匹配的 macOS
|
||||||
|
微信版本;微信自动更新后需要重新确认兼容性。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
|
||||||
|
会降低系统安全性,请确认风险后再使用。
|
||||||
|
</li>
|
||||||
|
<li>微信重新登录或 PID 改变后,需要在测试发送弹窗中点击“尝试重新绑定”。</li>
|
||||||
|
</ul>
|
||||||
|
{personalWechatRuntimeSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tts-supported-versions-button"
|
||||||
|
onClick={() => setShowWechatVersions(true)}
|
||||||
|
>
|
||||||
|
查看支持版本
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">微信发送组件</h2>
|
||||||
|
<section className="settings-card tts-runtime-card">
|
||||||
|
<div className="tts-runtime-summary">
|
||||||
|
<span className="settings-card-kicker">
|
||||||
|
OneBot {runtimeStatus?.version || 'v0.0.18'}
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{!isMac
|
||||||
|
? '暂不支持'
|
||||||
|
: runtimeStatus?.state === 'downloading'
|
||||||
|
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
|
||||||
|
: runtimeStatus
|
||||||
|
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
|
||||||
|
: '正在检测'}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{isWindows
|
||||||
|
? 'Windows 暂不支持个人微信发送;仍可生成和试听语音'
|
||||||
|
: !isMac
|
||||||
|
? '当前平台暂不支持个人微信发送'
|
||||||
|
: !runtimeStatus
|
||||||
|
? '正在检测当前平台与组件状态'
|
||||||
|
: personalWechatRuntimeSupported
|
||||||
|
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
|
||||||
|
: '当前 Mac 环境不满足个人微信发送组件要求'}
|
||||||
|
</small>
|
||||||
|
{runtimeStatus?.error ? (
|
||||||
|
<p className="tts-runtime-error">{runtimeStatus.error}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tts-runtime-actions">
|
||||||
|
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
|
||||||
|
<button type="button" onClick={() => void cancelRuntimeDownload()}>
|
||||||
|
取消下载
|
||||||
|
</button>
|
||||||
|
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
|
||||||
|
<>
|
||||||
|
{runtimeStatus.directory ? (
|
||||||
|
<button type="button" onClick={() => void openRuntimeDirectory()}>
|
||||||
|
打开目录
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{runtimeStatus.removable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-danger-button"
|
||||||
|
disabled={runtimeBusy}
|
||||||
|
onClick={() => void removeRuntime()}
|
||||||
|
>
|
||||||
|
卸载组件
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : personalWechatRuntimeSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-primary-button"
|
||||||
|
disabled={runtimeBusy}
|
||||||
|
onClick={() => void downloadRuntime()}
|
||||||
|
>
|
||||||
|
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
|
||||||
|
? '重新下载'
|
||||||
|
: '下载组件'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" disabled={runtimeBusy} onClick={() => void refreshRuntime()}>
|
||||||
|
重新检测
|
||||||
|
</button>
|
||||||
|
{personalWechatRuntimeSupported ? (
|
||||||
|
<button type="button" onClick={() => setShowWechatVersions(true)}>
|
||||||
|
支持版本
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
|
||||||
|
<div className="tts-runtime-progress">
|
||||||
|
<div>
|
||||||
|
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
|
||||||
|
<small>
|
||||||
|
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
|
||||||
|
{formatBytes(runtimeStatus.totalBytes)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
value={runtimeStatus.progress}
|
||||||
|
max={1}
|
||||||
|
aria-label="微信发送组件下载进度"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">API 设置</h2>
|
||||||
|
<section className="settings-card tts-api-card">
|
||||||
|
<div className="tts-api-heading">
|
||||||
|
<div>
|
||||||
|
<span className="settings-card-kicker">语音服务</span>
|
||||||
|
<strong>API Key</strong>
|
||||||
|
<p>保存后即可加载音色并生成语音。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => void openApiKeys()}>
|
||||||
|
前往 api.fish.audio 获取 Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="tts-api-input">
|
||||||
|
<span>API Key</span>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
disabled={savingKey || !settings?.encryptionAvailable}
|
||||||
|
placeholder={
|
||||||
|
settings?.hasStoredApiKey
|
||||||
|
? '已安全保存;输入新 Key 可替换'
|
||||||
|
: settings?.hasEnvironmentApiKey
|
||||||
|
? '当前使用环境变量;也可保存一个应用专用 Key'
|
||||||
|
: '粘贴 API Key'
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowApiKey((current) => !current)}>
|
||||||
|
{showApiKey ? '隐藏' : '显示'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tts-save-key-button"
|
||||||
|
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
|
||||||
|
onClick={() => void saveApiKey()}
|
||||||
|
>
|
||||||
|
{savingKey ? '保存中…' : '保存 Key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="tts-api-footer">
|
||||||
|
<span>{keyStatusText}</span>
|
||||||
|
{settings?.hasStoredApiKey ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-danger-button"
|
||||||
|
onClick={() => void clearApiKey()}
|
||||||
|
>
|
||||||
|
清除应用内 Key
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="tts-model-select">
|
||||||
|
<span>合成模型</span>
|
||||||
|
<select
|
||||||
|
value={settings?.model || 's2.1-pro-free'}
|
||||||
|
disabled={!settings}
|
||||||
|
onChange={(event) => void changeModel(event.target.value as TextToSpeechModel)}
|
||||||
|
>
|
||||||
|
<option value="s2.1-pro-free">s2.1-pro-free</option>
|
||||||
|
<option value="s2.1-pro">s2.1-pro</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="tts-voice-heading">
|
||||||
|
<div>
|
||||||
|
<h2 className="settings-section-heading">选择音色</h2>
|
||||||
|
<p>
|
||||||
|
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="tts-voice-search"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void searchVoices()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden>⌕</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder="按音色名称搜索"
|
||||||
|
disabled={!settings?.hasApiKey || loadingVoices}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!settings?.hasApiKey || loadingVoices}>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tts-voice-filters" aria-label="音色筛选">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={!selectedTags.length ? 'active' : ''}
|
||||||
|
disabled={!settings?.hasApiKey || loadingVoices}
|
||||||
|
onClick={() => void clearVoiceFilters()}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
{VOICE_FILTERS.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
type="button"
|
||||||
|
className={selectedTags.includes(filter.value) ? 'active' : ''}
|
||||||
|
disabled={!settings?.hasApiKey || loadingVoices}
|
||||||
|
onClick={() => void toggleVoiceFilter(filter.value)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVoice ? (
|
||||||
|
<section className="tts-current-voice">
|
||||||
|
<VoiceAvatar voice={selectedVoice} />
|
||||||
|
<div>
|
||||||
|
<span className="settings-card-kicker">当前音色</span>
|
||||||
|
<strong>{selectedVoice.name}</strong>
|
||||||
|
<p>
|
||||||
|
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
|
||||||
|
{selectedVoice.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="tts-current-tags">
|
||||||
|
{voiceMetaTags(selectedVoice)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((tag) => (
|
||||||
|
<span key={tag}>{voiceTagLabel(tag)}</span>
|
||||||
|
))}
|
||||||
|
{voiceMetaTags(selectedVoice).length > 5 ? (
|
||||||
|
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!settings?.hasApiKey && !loading ? (
|
||||||
|
<div className="settings-card tts-voice-empty">
|
||||||
|
<strong>先配置 API Key</strong>
|
||||||
|
<span>保存后即可加载并选择音色。</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
|
||||||
|
{visibleVoices.map((voice) => {
|
||||||
|
const selected = voice.id === selectedVoiceId
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={voice.id}
|
||||||
|
className={`tts-voice-card ${selected ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
className="tts-voice-select"
|
||||||
|
disabled={Boolean(savingVoiceId)}
|
||||||
|
onClick={() => void selectVoice(voice)}
|
||||||
|
>
|
||||||
|
<VoiceAvatar voice={voice} />
|
||||||
|
<span className="tts-voice-copy">
|
||||||
|
<span className="tts-voice-title-row">
|
||||||
|
<strong>{voice.name}</strong>
|
||||||
|
{voice.authorName ? <em>{voice.authorName}</em> : null}
|
||||||
|
</span>
|
||||||
|
<small>{voice.description || '公开音色'}</small>
|
||||||
|
<span className="tts-voice-tags">
|
||||||
|
{voiceMetaTags(voice)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((tag) => (
|
||||||
|
<span key={tag}>{voiceTagLabel(tag)}</span>
|
||||||
|
))}
|
||||||
|
{voiceMetaTags(voice).length > 4 ? (
|
||||||
|
<span>+{voiceMetaTags(voice).length - 4}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="tts-voice-stats">
|
||||||
|
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
|
||||||
|
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
|
||||||
|
{voice.markCount ? (
|
||||||
|
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="tts-voice-check">
|
||||||
|
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tts-voice-preview"
|
||||||
|
disabled={!voice.previewUrl}
|
||||||
|
onClick={() => playPreview(voice)}
|
||||||
|
>
|
||||||
|
{playingVoiceId === voice.id ? '停止' : '试听'}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
|
||||||
|
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
|
||||||
|
<div className="settings-card tts-voice-empty">
|
||||||
|
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasMore ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tts-load-more"
|
||||||
|
disabled={loadingVoices}
|
||||||
|
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
|
||||||
|
>
|
||||||
|
加载更多音色
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="tts-settings-error">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showWechatVersions ? (
|
||||||
|
<div
|
||||||
|
className="tts-version-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={() => setShowWechatVersions(false)}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className="tts-version-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="tts-version-modal-title"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2 id="tts-version-modal-title">支持的微信版本</h2>
|
||||||
|
</div>
|
||||||
|
<div className="tts-version-modal-actions">
|
||||||
|
<a href={WECHAT_VERSION_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
|
下载微信历史版本 ↗
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tts-version-modal-close"
|
||||||
|
onClick={() => setShowWechatVersions(false)}
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="tts-version-modal-intro">请安装下列完整版本之一。</p>
|
||||||
|
|
||||||
|
<div className="tts-version-grid">
|
||||||
|
{BUNDLED_WECHAT_VERSIONS.map((version) => (
|
||||||
|
<span key={version}>{version}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -129,6 +129,46 @@
|
|||||||
background: var(--wxex-bg-elevated);
|
background: var(--wxex-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-tool-button-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-button-wrapper .chat-tool-button:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-button-wrapper::after {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(240px, calc(100vw - 24px));
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wxex-text-primary);
|
||||||
|
color: var(--wxex-bg-elevated);
|
||||||
|
content: attr(title);
|
||||||
|
font: 12px/17px var(--wxex-font);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: left;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
transition:
|
||||||
|
opacity 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-button-wrapper:hover::after,
|
||||||
|
.chat-tool-button-wrapper:focus-visible::after,
|
||||||
|
.chat-tool-button-wrapper:focus-within::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-icon-button:hover,
|
.chat-icon-button:hover,
|
||||||
.chat-tool-button:hover {
|
.chat-tool-button:hover {
|
||||||
background: var(--wxex-brand-soft);
|
background: var(--wxex-brand-soft);
|
||||||
@@ -153,6 +193,659 @@
|
|||||||
background: #5a60c6;
|
background: #5a60c6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(18, 29, 25, 0.42);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-dialog {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
|
||||||
|
padding: 22px;
|
||||||
|
|
||||||
|
> header,
|
||||||
|
> footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
padding: 0 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-kicker {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-device-note {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 9px;
|
||||||
|
border: 1px solid rgba(36, 122, 99, 0.2);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(36, 122, 99, 0.065);
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-target {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 10px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: #f3f6f4;
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
align-self: center;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-status {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
border-color: rgba(36, 122, 99, 0.28);
|
||||||
|
background: rgba(36, 122, 99, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blocked {
|
||||||
|
border-color: rgba(190, 96, 60, 0.24);
|
||||||
|
background: rgba(190, 96, 60, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
p,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small.error {
|
||||||
|
color: #b15338;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-status-actions {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-diagnostics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-border);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 11px/1.45 var(--wxex-font);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-mode {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
.ready & {
|
||||||
|
background: var(--wxex-success);
|
||||||
|
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked & {
|
||||||
|
background: #be603c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
outline: 0;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: 14px/1.6 var(--wxex-font);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> small {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-image-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
border: 1px solid var(--wxex-brand);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
> div,
|
||||||
|
> small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> small {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-voice-composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-voice-source {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-voice {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-voice-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
span,
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-tts-readiness {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(190, 96, 60, 0.08);
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
background: rgba(36, 122, 99, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generation-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
border: 1px solid rgba(36, 122, 99, 0.2);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(36, 122, 99, 0.045);
|
||||||
|
padding: 11px 12px;
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generation-track,
|
||||||
|
.personal-wechat-preview-track {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(36, 122, 99, 0.12);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generation-track > span {
|
||||||
|
width: 38%;
|
||||||
|
animation: personal-wechat-generation-slide 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes personal-wechat-generation-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(285%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid rgba(36, 122, 99, 0.3);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(36, 122, 99, 0.055);
|
||||||
|
padding: 11px 12px;
|
||||||
|
|
||||||
|
audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-result-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 10px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 10px/16px var(--wxex-font);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-preview-track {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-result-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 11px;
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.personal-wechat-generated-result {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-generated-result-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-wechat-send-result {
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
background: rgba(36, 122, 99, 0.1);
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: rgba(190, 96, 60, 0.1);
|
||||||
|
color: #a84e32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-icon-button svg,
|
.chat-icon-button svg,
|
||||||
.chat-tool-button svg,
|
.chat-tool-button svg,
|
||||||
.chat-ai-button svg,
|
.chat-ai-button svg,
|
||||||
|
|||||||
@@ -15,4 +15,5 @@
|
|||||||
@use './archive';
|
@use './archive';
|
||||||
@use './settings-advanced';
|
@use './settings-advanced';
|
||||||
@use './settings-preferences';
|
@use './settings-preferences';
|
||||||
|
@use './settings-text-to-speech';
|
||||||
@use './theme';
|
@use './theme';
|
||||||
|
|||||||
@@ -298,6 +298,46 @@
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-toolbar-button-hint {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-toolbar-button-hint > button:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-toolbar-button-hint::after {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(280px, calc(100vw - 24px));
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wxex-text-primary);
|
||||||
|
color: var(--wxex-bg-elevated);
|
||||||
|
content: attr(title);
|
||||||
|
font: 12px/17px var(--wxex-font);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: left;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
transition:
|
||||||
|
opacity 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-toolbar-button-hint:hover::after,
|
||||||
|
.report-toolbar-button-hint:focus-visible::after,
|
||||||
|
.report-toolbar-button-hint:focus-within::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.report-more-menu {
|
.report-more-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,903 @@
|
|||||||
|
.text-to-speech-content {
|
||||||
|
width: min(100%, 1040px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-usage-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
border: 1px solid rgba(190, 96, 60, 0.24);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: linear-gradient(135deg, rgba(190, 96, 60, 0.055), rgba(36, 122, 99, 0.025));
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-usage-guide-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-experimental-badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(190, 96, 60, 0.12);
|
||||||
|
color: #aa5135;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-usage-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
padding: 11px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-hook-warning {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 11px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(190, 96, 60, 0.085);
|
||||||
|
padding: 12px 13px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #9c4a32;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
padding-left: 17px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-hook-warning-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(190, 96, 60, 0.16);
|
||||||
|
color: #a84e32;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-supported-versions-button {
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid rgba(190, 96, 60, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: #9c4a32;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 11px;
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 110;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(18, 29, 25, 0.46);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
|
||||||
|
padding: 22px;
|
||||||
|
|
||||||
|
> header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
> a {
|
||||||
|
border: 1px solid rgba(36, 122, 99, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-close {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 22px/28px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-intro {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 19px;
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline;
|
||||||
|
margin: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
padding: 8px 9px;
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px 28px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-summary {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--wxex-danger, #c85a5a);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
min-height: 34px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .settings-primary-button {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-progress {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 7px;
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-warning {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(190, 96, 60, 0.08);
|
||||||
|
padding: 12px 13px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #9c4a32;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 22px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-heading > button,
|
||||||
|
.tts-api-footer button,
|
||||||
|
.tts-api-input button,
|
||||||
|
.tts-voice-search button,
|
||||||
|
.tts-load-more {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-input {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-right: 0;
|
||||||
|
border-radius: var(--wxex-radius-sm) 0 0 var(--wxex-radius-sm);
|
||||||
|
outline: 0;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-save-key-button {
|
||||||
|
border-radius: 0 var(--wxex-radius-sm) var(--wxex-radius-sm) 0;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-model-select {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(0, 360px);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
outline: 0;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 9px 11px;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 30px;
|
||||||
|
|
||||||
|
.settings-section-heading {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-search {
|
||||||
|
width: min(350px, 46%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
padding: 3px 4px 3px 12px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 7px 0;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 13px;
|
||||||
|
padding: 1px 1px 5px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
transition:
|
||||||
|
border-color 120ms ease,
|
||||||
|
background 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba(36, 122, 99, 0.38);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: rgba(36, 122, 99, 0.5);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-current-voice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 15px;
|
||||||
|
border: 1px solid rgba(36, 122, 99, 0.32);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: rgba(36, 122, 99, 0.055);
|
||||||
|
padding: 13px 15px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-current-tags,
|
||||||
|
.tts-voice-tags,
|
||||||
|
.tts-voice-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-current-tags {
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
span {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-media,
|
||||||
|
.tts-voice-avatar,
|
||||||
|
.tts-voice-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-media {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(145deg, #d9eee6, #edf7f3);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: 26px;
|
||||||
|
row-gap: 2px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-card {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom-color: var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
padding: 12px 8px;
|
||||||
|
transition:
|
||||||
|
background 120ms ease,
|
||||||
|
border-color 120ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(36, 122, 99, 0.18);
|
||||||
|
background: rgba(36, 122, 99, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: rgba(36, 122, 99, 0.38);
|
||||||
|
background: rgba(36, 122, 99, 0.065);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-select {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) 20px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-title-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 10px/16px var(--wxex-font);
|
||||||
|
font-style: normal;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-copy > small {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 17px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-tags {
|
||||||
|
min-height: 18px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
max-width: 92px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
font: 9px/14px var(--wxex-font);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-stats {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 9px/14px var(--wxex-font);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
.selected & {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-preview {
|
||||||
|
min-width: 42px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 9px;
|
||||||
|
font: 10px/16px var(--wxex-font);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-loading,
|
||||||
|
.tts-settings-error {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-loading {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-settings-error {
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 18px auto 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.tts-usage-steps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.tts-api-heading,
|
||||||
|
.tts-voice-heading,
|
||||||
|
.tts-api-footer,
|
||||||
|
.tts-usage-guide-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-usage-guide-heading > p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-input > div {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-api-input .tts-save-key-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 7px;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-search {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-current-voice {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-current-tags {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-model-select {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-runtime-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal > header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-version-modal-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type RuntimePlatform = NodeJS.Platform | 'unknown'
|
||||||
|
|
||||||
|
function detectRuntimePlatform(): RuntimePlatform {
|
||||||
|
if (typeof window === 'undefined') return 'unknown'
|
||||||
|
return (window.electron?.process?.platform as NodeJS.Platform | undefined) || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtimePlatform = detectRuntimePlatform()
|
||||||
|
export const isMac = runtimePlatform === 'darwin'
|
||||||
|
export const isWindows = runtimePlatform === 'win32'
|
||||||
|
|
||||||
|
// Renderer entry points use this single capability flag. The main process still
|
||||||
|
// performs the authoritative Apple Silicon and runtime checks before sending.
|
||||||
|
export const supportsPersonalWechatSend = isMac
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type PersonalWechatRuntimeState =
|
||||||
|
| 'missing'
|
||||||
|
| 'downloading'
|
||||||
|
| 'ready'
|
||||||
|
| 'invalid'
|
||||||
|
| 'error'
|
||||||
|
| 'unsupported'
|
||||||
|
|
||||||
|
export interface PersonalWechatRuntimeStatus {
|
||||||
|
version: string
|
||||||
|
state: PersonalWechatRuntimeState
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
progress: number
|
||||||
|
platform: NodeJS.Platform
|
||||||
|
architecture: string
|
||||||
|
supported: boolean
|
||||||
|
removable: boolean
|
||||||
|
directory?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatRuntimeDownloadResult {
|
||||||
|
success: boolean
|
||||||
|
status: PersonalWechatRuntimeStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatRuntimeProgressEvent extends PersonalWechatRuntimeStatus {}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type PersonalWechatSenderState =
|
||||||
|
| 'checking'
|
||||||
|
| 'unsupported_platform'
|
||||||
|
| 'wechat_not_running'
|
||||||
|
| 'sip_enabled'
|
||||||
|
| 'unsupported_version'
|
||||||
|
| 'runtime_missing'
|
||||||
|
| 'hook_not_ready'
|
||||||
|
| 'stopped'
|
||||||
|
| 'starting'
|
||||||
|
| 'rebinding'
|
||||||
|
| 'online'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
export interface PersonalWechatSenderStatus {
|
||||||
|
state: PersonalWechatSenderState
|
||||||
|
platform: string
|
||||||
|
arch: string
|
||||||
|
sipDisabled: boolean
|
||||||
|
wechatRunning: boolean
|
||||||
|
wechatPid?: number
|
||||||
|
boundWechatPid?: number
|
||||||
|
oneBotPid?: number
|
||||||
|
endpoint: string
|
||||||
|
endpointReady: boolean
|
||||||
|
wechatVersion?: string
|
||||||
|
runtimeReady: boolean
|
||||||
|
executablePath?: string
|
||||||
|
configPath?: string
|
||||||
|
imagePath?: string
|
||||||
|
attachReady: boolean
|
||||||
|
baseAddress?: string
|
||||||
|
baseAddressReady: boolean
|
||||||
|
textHookInstalled: boolean
|
||||||
|
textHookReady: boolean
|
||||||
|
imageHookInstalled: boolean
|
||||||
|
imageHookReady: boolean
|
||||||
|
messageListenerReady: boolean
|
||||||
|
canSend: boolean
|
||||||
|
canSendText: boolean
|
||||||
|
canSendImage: boolean
|
||||||
|
canSendVoice: boolean
|
||||||
|
message: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalWechatSendBaseRequest {
|
||||||
|
to: string
|
||||||
|
isGroup: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatSendTextRequest extends PersonalWechatSendBaseRequest {
|
||||||
|
type: 'text'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest {
|
||||||
|
type: 'image'
|
||||||
|
filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
|
||||||
|
type: 'voice'
|
||||||
|
filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonalWechatSendRequest =
|
||||||
|
| PersonalWechatSendTextRequest
|
||||||
|
| PersonalWechatSendImageRequest
|
||||||
|
| PersonalWechatSendVoiceRequest
|
||||||
|
|
||||||
|
export interface PersonalWechatSendResult {
|
||||||
|
success: boolean
|
||||||
|
status: PersonalWechatSenderStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatImageSelectionResult {
|
||||||
|
canceled: boolean
|
||||||
|
path?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonalWechatVoiceSelectionResult = PersonalWechatImageSelectionResult
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export type TextToSpeechKeySource = 'secure-storage' | 'environment' | 'missing'
|
||||||
|
export type TextToSpeechModel = 's2.1-pro-free' | 's2.1-pro'
|
||||||
|
|
||||||
|
export interface TextToSpeechVoice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
languages: string[]
|
||||||
|
source: 'fish-audio'
|
||||||
|
coverImage?: string
|
||||||
|
previewUrl?: string
|
||||||
|
previewText?: string
|
||||||
|
authorName?: string
|
||||||
|
taskCount?: number
|
||||||
|
likeCount?: number
|
||||||
|
markCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextToSpeechSettings {
|
||||||
|
provider: 'fish-audio'
|
||||||
|
hasApiKey: boolean
|
||||||
|
hasStoredApiKey: boolean
|
||||||
|
hasEnvironmentApiKey: boolean
|
||||||
|
keySource: TextToSpeechKeySource
|
||||||
|
encryptionAvailable: boolean
|
||||||
|
selectedVoiceId: string
|
||||||
|
outputFormat: 'mp3'
|
||||||
|
model: TextToSpeechModel
|
||||||
|
phase: 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextToSpeechSettingsResult {
|
||||||
|
success: boolean
|
||||||
|
settings: TextToSpeechSettings
|
||||||
|
voices: TextToSpeechVoice[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveTextToSpeechSettingsRequest {
|
||||||
|
apiKey?: string
|
||||||
|
clearApiKey?: boolean
|
||||||
|
selectedVoiceId?: string
|
||||||
|
model?: TextToSpeechModel
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListTextToSpeechVoicesRequest {
|
||||||
|
pageNumber?: number
|
||||||
|
pageSize?: number
|
||||||
|
title?: string
|
||||||
|
language?: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListTextToSpeechVoicesResult {
|
||||||
|
success: boolean
|
||||||
|
items: TextToSpeechVoice[]
|
||||||
|
total: number
|
||||||
|
pageNumber: number
|
||||||
|
pageSize: number
|
||||||
|
hasMore: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeTextToSpeechRequest {
|
||||||
|
text: string
|
||||||
|
referenceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeTextToSpeechResult {
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
audioDataUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||||
|
|
||||||
|
const getStatus = vi.fn()
|
||||||
|
const rebind = vi.fn()
|
||||||
|
const selectImage = vi.fn()
|
||||||
|
const selectVoice = vi.fn()
|
||||||
|
const sendMessage = vi.fn()
|
||||||
|
const getTextToSpeechSettings = vi.fn()
|
||||||
|
const listTextToSpeechVoices = vi.fn()
|
||||||
|
const synthesizeTextToSpeech = vi.fn()
|
||||||
|
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||||
|
|
||||||
|
const contact = {
|
||||||
|
m_nsUsrName: 'fixture-room@chatroom',
|
||||||
|
m_nsNickName: '技术交流群',
|
||||||
|
md5: 'fixture-md5',
|
||||||
|
type: 'group' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyStatus = {
|
||||||
|
state: 'online' as const,
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
sipDisabled: true,
|
||||||
|
wechatRunning: true,
|
||||||
|
wechatPid: 4668,
|
||||||
|
boundWechatPid: 4668,
|
||||||
|
oneBotPid: 5401,
|
||||||
|
endpoint: '127.0.0.1:58080',
|
||||||
|
endpointReady: true,
|
||||||
|
wechatVersion: '4.1.11.53',
|
||||||
|
runtimeReady: true,
|
||||||
|
attachReady: true,
|
||||||
|
baseAddress: '0x114ef8000',
|
||||||
|
baseAddressReady: true,
|
||||||
|
textHookInstalled: true,
|
||||||
|
textHookReady: true,
|
||||||
|
imageHookInstalled: true,
|
||||||
|
imageHookReady: true,
|
||||||
|
messageListenerReady: true,
|
||||||
|
canSend: true,
|
||||||
|
canSendText: true,
|
||||||
|
canSendImage: true,
|
||||||
|
canSendVoice: true,
|
||||||
|
message: '个人微信已绑定'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PersonalWechatSendDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||||
|
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||||
|
selectImage.mockReset().mockResolvedValue({
|
||||||
|
canceled: false,
|
||||||
|
path: '/Users/fixture/test.png',
|
||||||
|
name: 'test.png'
|
||||||
|
})
|
||||||
|
selectVoice.mockReset().mockResolvedValue({
|
||||||
|
canceled: false,
|
||||||
|
path: '/Users/fixture/test.silk',
|
||||||
|
name: 'test.silk'
|
||||||
|
})
|
||||||
|
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
||||||
|
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
settings: {
|
||||||
|
provider: 'fish-audio',
|
||||||
|
hasApiKey: true,
|
||||||
|
encryptionAvailable: true,
|
||||||
|
selectedVoiceId: 'fish-warm-female',
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
model: 's2.1-pro-free',
|
||||||
|
phase: 'ready'
|
||||||
|
},
|
||||||
|
voices: []
|
||||||
|
})
|
||||||
|
listTextToSpeechVoices.mockReset().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'fish-warm-female',
|
||||||
|
name: '暖阳女声',
|
||||||
|
description: '自然温和',
|
||||||
|
tags: ['女声'],
|
||||||
|
languages: ['中文'],
|
||||||
|
source: 'fish-audio'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 24,
|
||||||
|
hasMore: false
|
||||||
|
})
|
||||||
|
synthesizeTextToSpeech.mockReset().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
filePath: '/tmp/generated.mp3',
|
||||||
|
audioDataUrl: 'data:audio/mpeg;base64,fixture'
|
||||||
|
})
|
||||||
|
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
|
||||||
|
Object.defineProperty(window, 'api', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getPersonalWechatSenderStatus: getStatus,
|
||||||
|
rebindPersonalWechatSender: rebind,
|
||||||
|
selectPersonalWechatImage: selectImage,
|
||||||
|
selectPersonalWechatVoice: selectVoice,
|
||||||
|
sendPersonalWechatMessage: sendMessage,
|
||||||
|
getTextToSpeechSettings,
|
||||||
|
listTextToSpeechVoices,
|
||||||
|
synthesizeTextToSpeech,
|
||||||
|
removeGeneratedTextToSpeechAudio
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to voice mode and sends only the selected audio file', async () => {
|
||||||
|
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||||
|
await screen.findByText('技术交流群')
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
||||||
|
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'voice',
|
||||||
|
to: 'fixture-room@chatroom',
|
||||||
|
filePath: '/Users/fixture/test.silk',
|
||||||
|
isGroup: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
|
||||||
|
const openSettings = vi.fn()
|
||||||
|
render(
|
||||||
|
<PersonalWechatSendDialog
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onOpenTextToSpeechSettings={openSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await screen.findByText('技术交流群')
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||||
|
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
|
||||||
|
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
|
||||||
|
expect(openSettings).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sendMessage).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows binding diagnostics and only offers image and voice modes', async () => {
|
||||||
|
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
|
||||||
|
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to image mode and sends only the selected image', async () => {
|
||||||
|
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||||
|
await screen.findByText('技术交流群')
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
|
||||||
|
{ exact: false }
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
||||||
|
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'image',
|
||||||
|
to: 'fixture-room@chatroom',
|
||||||
|
filePath: '/Users/fixture/test.png',
|
||||||
|
isGroup: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers a safe explicit rebind action', async () => {
|
||||||
|
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||||
|
await screen.findByText('技术交流群')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
|
||||||
|
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks image sending until the media Hook and image directory are initialized', async () => {
|
||||||
|
getStatus.mockResolvedValue({
|
||||||
|
...readyStatus,
|
||||||
|
state: 'hook_not_ready',
|
||||||
|
imageHookReady: false,
|
||||||
|
canSendImage: false,
|
||||||
|
message: '请先手动发送图片'
|
||||||
|
})
|
||||||
|
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||||
|
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,6 +41,44 @@ describe('daily report controls', () => {
|
|||||||
value: {
|
value: {
|
||||||
getAppLogPath: vi.fn(async () => ''),
|
getAppLogPath: vi.fn(async () => ''),
|
||||||
revealAppLog: vi.fn(async () => undefined),
|
revealAppLog: vi.fn(async () => undefined),
|
||||||
|
getPersonalWechatSenderStatus: vi.fn(async () => ({
|
||||||
|
state: 'online',
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
sipDisabled: true,
|
||||||
|
wechatRunning: true,
|
||||||
|
runtimeReady: true,
|
||||||
|
endpoint: '127.0.0.1:58080',
|
||||||
|
endpointReady: true,
|
||||||
|
attachReady: true,
|
||||||
|
baseAddressReady: true,
|
||||||
|
textHookInstalled: true,
|
||||||
|
textHookReady: true,
|
||||||
|
imageHookInstalled: true,
|
||||||
|
imageHookReady: true,
|
||||||
|
messageListenerReady: true,
|
||||||
|
canSend: true,
|
||||||
|
canSendText: true,
|
||||||
|
canSendImage: true,
|
||||||
|
canSendVoice: true,
|
||||||
|
message: '个人微信已绑定'
|
||||||
|
})),
|
||||||
|
getTextToSpeechSettings: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
settings: {
|
||||||
|
provider: 'fish-audio',
|
||||||
|
hasApiKey: false,
|
||||||
|
hasStoredApiKey: false,
|
||||||
|
hasEnvironmentApiKey: false,
|
||||||
|
keySource: 'missing',
|
||||||
|
encryptionAvailable: true,
|
||||||
|
selectedVoiceId: '',
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
model: 's2.1-pro-free',
|
||||||
|
phase: 'ready'
|
||||||
|
},
|
||||||
|
voices: []
|
||||||
|
})),
|
||||||
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
||||||
getGroupSnapshot: vi.fn(async () => ({
|
getGroupSnapshot: vi.fn(async () => ({
|
||||||
members: [
|
members: [
|
||||||
@@ -396,7 +434,7 @@ describe('daily report controls', () => {
|
|||||||
globalThis.ResizeObserver = originalResizeObserver
|
globalThis.ResizeObserver = originalResizeObserver
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps secondary report actions inside More and labels both AI model roles', () => {
|
it('keeps the file action inside More and labels both AI model roles', () => {
|
||||||
render(
|
render(
|
||||||
<>
|
<>
|
||||||
<ReportToolbar
|
<ReportToolbar
|
||||||
@@ -431,15 +469,86 @@ describe('daily report controls', () => {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
|
||||||
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||||
|
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible()
|
||||||
expect(screen.getByText('文字模型')).toBeVisible()
|
expect(screen.getByText('文字模型')).toBeVisible()
|
||||||
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
||||||
expect(screen.getByText('图片模型')).toBeVisible()
|
expect(screen.getByText('图片模型')).toBeVisible()
|
||||||
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the current group send dialog with the report PNG preselected', async () => {
|
||||||
|
const report: GeneratedReportRecord = {
|
||||||
|
id: 'report-send',
|
||||||
|
contactId: groupContact.md5,
|
||||||
|
contactName: groupContact.m_nsNickName,
|
||||||
|
dateRange: '今天',
|
||||||
|
messageCount: 10,
|
||||||
|
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-17',
|
||||||
|
htmlStatus: 'ready',
|
||||||
|
pngStatus: 'ready',
|
||||||
|
generatedImage: 'data:image/png;base64,fixture',
|
||||||
|
pngPath: '/Users/fixture/测试群日报.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReportViewer
|
||||||
|
report={report}
|
||||||
|
hasReports
|
||||||
|
onBackToConfigure={vi.fn()}
|
||||||
|
onRegenerate={vi.fn()}
|
||||||
|
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||||
|
onReveal={vi.fn(async () => ({ success: true }))}
|
||||||
|
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||||
|
sendTarget={groupContact}
|
||||||
|
personalWechatSendSupported
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||||
|
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||||
|
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables current group sending outside macOS with a readable hover hint', () => {
|
||||||
|
const report: GeneratedReportRecord = {
|
||||||
|
id: 'report-send-disabled',
|
||||||
|
contactId: groupContact.md5,
|
||||||
|
contactName: groupContact.m_nsNickName,
|
||||||
|
dateRange: '今天',
|
||||||
|
messageCount: 10,
|
||||||
|
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-17',
|
||||||
|
htmlStatus: 'ready',
|
||||||
|
pngStatus: 'ready',
|
||||||
|
generatedImage: 'data:image/png;base64,fixture',
|
||||||
|
pngPath: '/Users/fixture/测试群日报.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReportViewer
|
||||||
|
report={report}
|
||||||
|
hasReports
|
||||||
|
onBackToConfigure={vi.fn()}
|
||||||
|
onRegenerate={vi.fn()}
|
||||||
|
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||||
|
onReveal={vi.fn(async () => ({ success: true }))}
|
||||||
|
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||||
|
sendTarget={groupContact}
|
||||||
|
personalWechatSendSupported={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: '发送到当前群聊' })
|
||||||
|
expect(button).toBeDisabled()
|
||||||
|
expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS')
|
||||||
|
})
|
||||||
|
|
||||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||||
const report: GeneratedReportRecord = {
|
const report: GeneratedReportRecord = {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
|
||||||
|
|
||||||
|
const getSettings = vi.fn()
|
||||||
|
const saveSettings = vi.fn()
|
||||||
|
const listVoices = vi.fn()
|
||||||
|
const openApiKeys = vi.fn()
|
||||||
|
const getRuntimeStatus = vi.fn()
|
||||||
|
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
settings: {
|
||||||
|
provider: 'fish-audio' as const,
|
||||||
|
hasApiKey: true,
|
||||||
|
hasStoredApiKey: true,
|
||||||
|
hasEnvironmentApiKey: false,
|
||||||
|
keySource: 'secure-storage' as const,
|
||||||
|
encryptionAvailable: true,
|
||||||
|
selectedVoiceId: 'fish-warm-female',
|
||||||
|
outputFormat: 'mp3' as const,
|
||||||
|
model: 's2.1-pro-free' as const,
|
||||||
|
phase: 'ready' as const
|
||||||
|
},
|
||||||
|
voices: [
|
||||||
|
{
|
||||||
|
id: 'fish-warm-female',
|
||||||
|
name: '暖阳女声',
|
||||||
|
description: '自然温和,适合日常对话',
|
||||||
|
tags: ['女声', '自然', '普通话'],
|
||||||
|
languages: ['中文'],
|
||||||
|
source: 'fish-audio' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fish-calm-male',
|
||||||
|
name: '沉稳男声',
|
||||||
|
description: '低沉清晰,适合知识说明',
|
||||||
|
tags: ['男声', '沉稳', '普通话'],
|
||||||
|
languages: ['中文'],
|
||||||
|
source: 'fish-audio' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TextToSpeechPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getSettings.mockReset().mockResolvedValue(response)
|
||||||
|
listVoices.mockReset().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
items: response.voices,
|
||||||
|
total: response.voices.length,
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 24,
|
||||||
|
hasMore: false
|
||||||
|
})
|
||||||
|
saveSettings.mockReset().mockImplementation(async (request) => ({
|
||||||
|
...response,
|
||||||
|
settings: {
|
||||||
|
...response.settings,
|
||||||
|
hasApiKey: Boolean(request.apiKey),
|
||||||
|
selectedVoiceId: request.selectedVoiceId || response.settings.selectedVoiceId,
|
||||||
|
model: request.model || response.settings.model
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
openApiKeys.mockReset().mockResolvedValue({ success: true })
|
||||||
|
getRuntimeStatus.mockReset().mockResolvedValue({
|
||||||
|
version: 'v0.0.18',
|
||||||
|
state: 'ready',
|
||||||
|
downloadedBytes: 100,
|
||||||
|
totalBytes: 100,
|
||||||
|
progress: 1,
|
||||||
|
platform: 'darwin',
|
||||||
|
architecture: 'arm64',
|
||||||
|
supported: true,
|
||||||
|
removable: true
|
||||||
|
})
|
||||||
|
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||||
|
Object.defineProperty(window, 'api', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getTextToSpeechSettings: getSettings,
|
||||||
|
saveTextToSpeechSettings: saveSettings,
|
||||||
|
listTextToSpeechVoices: listVoices,
|
||||||
|
openFishAudioApiKeys: openApiKeys,
|
||||||
|
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||||
|
onPersonalWechatRuntimeProgress: onRuntimeProgress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches, selects and securely saves an API key with a voice', async () => {
|
||||||
|
const onNotice = vi.fn()
|
||||||
|
render(<TextToSpeechPage onNotice={onNotice} />)
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('暖阳女声')).not.toHaveLength(0)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('按音色名称搜索'), {
|
||||||
|
target: { value: '沉稳' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '搜索' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listVoices).toHaveBeenCalledWith({
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 24,
|
||||||
|
title: '沉稳',
|
||||||
|
tags: []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /沉稳男声/ }))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('已安全保存;输入新 Key 可替换'), {
|
||||||
|
target: { value: 'fish-fixture-key' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存 Key' }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(saveSettings).toHaveBeenCalledWith({
|
||||||
|
apiKey: 'fish-fixture-key'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(onNotice).toHaveBeenCalledWith('API Key 已安全保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the official Fish Audio API key page through the main process', async () => {
|
||||||
|
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||||
|
await screen.findByText('微信发送组件')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
|
||||||
|
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -95,6 +95,21 @@ describe('preload IPC contract', () => {
|
|||||||
expect(invoke).toHaveBeenLastCalledWith('voice:removeModel')
|
expect(invoke).toHaveBeenLastCalledWith('voice:removeModel')
|
||||||
await api.openVoiceModelDirectory()
|
await api.openVoiceModelDirectory()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('voice:openModelDirectory')
|
expect(invoke).toHaveBeenLastCalledWith('voice:openModelDirectory')
|
||||||
|
|
||||||
|
await api.getPersonalWechatSenderStatus()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus')
|
||||||
|
await api.rebindPersonalWechatSender()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
||||||
|
const sendRequest = {
|
||||||
|
to: 'fixture@chatroom',
|
||||||
|
type: 'text' as const,
|
||||||
|
text: '测试消息',
|
||||||
|
isGroup: true
|
||||||
|
}
|
||||||
|
await api.sendPersonalWechatMessage(sendRequest)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
|
||||||
|
await api.selectPersonalWechatImage()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,34 @@ import { afterEach, vi } from 'vitest'
|
|||||||
|
|
||||||
afterEach(() => cleanup())
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
function createMemoryStorage(): Storage {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return values.size
|
||||||
|
},
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||||
|
removeItem: (key) => {
|
||||||
|
values.delete(key)
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
values.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: createMemoryStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: createMemoryStorage()
|
||||||
|
})
|
||||||
|
|
||||||
Object.defineProperty(globalThis.URL, 'createObjectURL', {
|
Object.defineProperty(globalThis.URL, 'createObjectURL', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(() => 'blob:wxe-test-audio')
|
value: vi.fn(() => 'blob:wxe-test-audio')
|
||||||
|
|||||||
@@ -56,7 +56,18 @@ describe('message grouping and dates', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('search cache', () => {
|
describe('search cache', () => {
|
||||||
beforeEach(() => localStorage.clear())
|
beforeEach(() => {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
setItem: (key: string, value: string) => values.set(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes the key and survives invalid persisted state', () => {
|
it('normalizes the key and survives invalid persisted state', () => {
|
||||||
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
|
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join, sep } from 'path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getAppPath: () => '/fixture/app',
|
||||||
|
isPackaged: false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPersonalWechatOneBotRequest,
|
||||||
|
findWechatImagePath,
|
||||||
|
findPersonalWechatRuntime,
|
||||||
|
parsePersonalWechatHookLog
|
||||||
|
} from '../../src/main/services/personal-wechat-send-service'
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
function temporaryDirectory(): string {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'wechat-personal-runtime-'))
|
||||||
|
temporaryDirectories.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (temporaryDirectories.length > 0) {
|
||||||
|
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('personal WeChat OneBot request', () => {
|
||||||
|
it('builds a private text message request', () => {
|
||||||
|
expect(
|
||||||
|
buildPersonalWechatOneBotRequest({
|
||||||
|
to: ' wxid_fixture ',
|
||||||
|
type: 'text',
|
||||||
|
text: ' 测试发送 ',
|
||||||
|
isGroup: false
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
endpoint: '/send_private_msg',
|
||||||
|
body: {
|
||||||
|
user_id: 'wxid_fixture',
|
||||||
|
message: [{ type: 'text', data: { text: '测试发送' } }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the group endpoint for chatroom targets', () => {
|
||||||
|
expect(
|
||||||
|
buildPersonalWechatOneBotRequest({
|
||||||
|
to: 'fixture-room@chatroom',
|
||||||
|
type: 'text',
|
||||||
|
text: '群聊测试',
|
||||||
|
isGroup: false
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
endpoint: '/send_group_msg',
|
||||||
|
body: {
|
||||||
|
group_id: 'fixture-room@chatroom',
|
||||||
|
message: [{ type: 'text', data: { text: '群聊测试' } }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a base64 image request without mixing text content', () => {
|
||||||
|
expect(
|
||||||
|
buildPersonalWechatOneBotRequest(
|
||||||
|
{ to: 'fixture-room@chatroom', type: 'image', filePath: '/tmp/test.png', isGroup: false },
|
||||||
|
'aGVsbG8='
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
endpoint: '/send_group_msg',
|
||||||
|
body: {
|
||||||
|
group_id: 'fixture-room@chatroom',
|
||||||
|
message: [{ type: 'image', data: { file: 'base64://aGVsbG8=' } }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a base64 voice record request', () => {
|
||||||
|
expect(
|
||||||
|
buildPersonalWechatOneBotRequest(
|
||||||
|
{ to: 'filehelper', type: 'voice', filePath: '/tmp/test.silk', isGroup: false },
|
||||||
|
'dm9pY2U='
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
endpoint: '/send_private_msg',
|
||||||
|
body: {
|
||||||
|
user_id: 'filehelper',
|
||||||
|
message: [{ type: 'record', data: { file: 'base64://dm9pY2U=' } }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('personal WeChat runtime discovery', () => {
|
||||||
|
it('uses the newest current-month WeChat image directory', () => {
|
||||||
|
const root = temporaryDirectory()
|
||||||
|
const oldPath = join(root, 'account', 'temp', 'old', '2026-08', 'Img')
|
||||||
|
const latestPath = join(root, 'account', 'temp', 'latest', '2026-08', 'Img')
|
||||||
|
mkdirSync(oldPath, { recursive: true })
|
||||||
|
mkdirSync(latestPath, { recursive: true })
|
||||||
|
utimesSync(oldPath, new Date('2026-08-01'), new Date('2026-08-01'))
|
||||||
|
utimesSync(latestPath, new Date('2026-08-05'), new Date('2026-08-05'))
|
||||||
|
|
||||||
|
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${latestPath}${sep}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the current ImageTemp layout after a WeChat relogin', () => {
|
||||||
|
const root = temporaryDirectory()
|
||||||
|
const imageTempPath = join(root, 'account', 'temp', 'ImageTemp', '2026-08')
|
||||||
|
mkdirSync(imageTempPath, { recursive: true })
|
||||||
|
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${imageTempPath}${sep}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives the current ImageTemp directory before the month folder exists', () => {
|
||||||
|
const root = temporaryDirectory()
|
||||||
|
const tempRoot = join(root, 'account', 'temp')
|
||||||
|
mkdirSync(tempRoot, { recursive: true })
|
||||||
|
|
||||||
|
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(
|
||||||
|
`${join(tempRoot, 'ImageTemp', '2026-08')}${sep}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the nested release archive layout', () => {
|
||||||
|
const root = temporaryDirectory()
|
||||||
|
mkdirSync(join(root, 'onebot'), { recursive: true })
|
||||||
|
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||||
|
writeFileSync(join(root, 'onebot', 'onebot'), 'fixture')
|
||||||
|
writeFileSync(join(root, 'onebot', 'script.js'), 'fixture')
|
||||||
|
|
||||||
|
expect(findPersonalWechatRuntime([root])).toEqual({
|
||||||
|
root,
|
||||||
|
executable: join(root, 'onebot', 'onebot'),
|
||||||
|
workingDirectory: join(root, 'onebot'),
|
||||||
|
configDirectory: join(root, 'wechat_version'),
|
||||||
|
logPath: join(root, 'onebot', 'log', 'macos.log')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a flat runtime layout and rejects incomplete directories', () => {
|
||||||
|
const incomplete = temporaryDirectory()
|
||||||
|
writeFileSync(join(incomplete, 'onebot'), 'fixture')
|
||||||
|
|
||||||
|
const root = temporaryDirectory()
|
||||||
|
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||||
|
writeFileSync(join(root, 'onebot'), 'fixture')
|
||||||
|
writeFileSync(join(root, 'script.js'), 'fixture')
|
||||||
|
|
||||||
|
expect(findPersonalWechatRuntime([incomplete, root])).toEqual({
|
||||||
|
root,
|
||||||
|
executable: join(root, 'onebot'),
|
||||||
|
workingDirectory: root,
|
||||||
|
configDirectory: join(root, 'wechat_version'),
|
||||||
|
logPath: join(root, 'log', 'macos.log')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('personal WeChat hook diagnostics', () => {
|
||||||
|
it('does not treat a listening service as hook-ready after req2buf scan failure', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
[
|
||||||
|
JSON.stringify({ time: '2026-08-06T14:08:10+08:00', payload: '[+] HTTP 服务启动在' }),
|
||||||
|
JSON.stringify({
|
||||||
|
time: '2026-08-06T14:08:11+08:00',
|
||||||
|
err: "Error: [-] Cannot find 'req2buf' keyword in a range > 100MB"
|
||||||
|
})
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
).toMatchObject({ readiness: 'failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires StartTask capture before reporting ready', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||||
|
)
|
||||||
|
).toMatchObject({ readiness: 'initializing', textHookInstalled: true })
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' })
|
||||||
|
)
|
||||||
|
).toMatchObject({ readiness: 'ready', textHookReady: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets stale hook results when a new WeChat process attach starts', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
[
|
||||||
|
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' }),
|
||||||
|
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 123 }),
|
||||||
|
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
readiness: 'initializing',
|
||||||
|
boundWechatPid: 123,
|
||||||
|
textHookInstalled: true,
|
||||||
|
textHookReady: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks base, image Hook and message listener diagnostics', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
[
|
||||||
|
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 4668 }),
|
||||||
|
JSON.stringify({ message: '成功 Attach 微信进程', PID: 4668 }),
|
||||||
|
JSON.stringify({ payload: '[+] Base address from range: 0x114ef8000' }),
|
||||||
|
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }),
|
||||||
|
JSON.stringify({ payload: '[+] 图片上传 Hook Setup Complete.' }),
|
||||||
|
JSON.stringify({ payload: '[+] 捕获到图片上传上下文,uploadGlobalX0:0x1' }),
|
||||||
|
JSON.stringify({ message: '发送数据' })
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
attached: true,
|
||||||
|
baseAddress: '0x114ef8000',
|
||||||
|
textHookInstalled: true,
|
||||||
|
imageHookInstalled: true,
|
||||||
|
imageHookReady: true,
|
||||||
|
messageListenerReady: true,
|
||||||
|
boundWechatPid: 4668
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes a successful legacy image send without the new Hook marker', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
JSON.stringify({ type: 'send_image', result: '1', message: '发送图片任务执行结果' })
|
||||||
|
)
|
||||||
|
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes the real onebot two-record image result format', () => {
|
||||||
|
expect(
|
||||||
|
parsePersonalWechatHookLog(
|
||||||
|
[
|
||||||
|
JSON.stringify({ task_id: 536870915, type: 'send_image' }),
|
||||||
|
JSON.stringify({
|
||||||
|
result: '1',
|
||||||
|
task_id: 536870915,
|
||||||
|
message: '📩 发送图片任务执行结果'
|
||||||
|
})
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { SETTINGS_NAVIGATION } from '../../src/renderer/src/features/settings/model/settingsNavigation'
|
||||||
|
|
||||||
|
describe('settings navigation', () => {
|
||||||
|
it('places text-to-speech under intelligent capabilities', () => {
|
||||||
|
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
|
||||||
|
expect(intelligent?.items.map((item) => item.id)).toEqual([
|
||||||
|
'voice-recognition',
|
||||||
|
'text-to-speech',
|
||||||
|
'ai-model'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
keyGet: vi.fn(),
|
||||||
|
keySave: vi.fn(),
|
||||||
|
keyClear: vi.fn(),
|
||||||
|
loadSettings: vi.fn(),
|
||||||
|
updateSettings: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/ai-provider-key-store', () => ({
|
||||||
|
AIProviderKeyStore: class {
|
||||||
|
get = mocks.keyGet
|
||||||
|
save = mocks.keySave
|
||||||
|
clear = mocks.keyClear
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/settings-store', () => ({
|
||||||
|
loadSettings: mocks.loadSettings,
|
||||||
|
updateSettings: mocks.updateSettings
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { TextToSpeechSettingsService } from '../../src/main/services/text-to-speech-settings-service'
|
||||||
|
|
||||||
|
describe('TextToSpeechSettingsService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.keyGet.mockReset().mockReturnValue({
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
key: undefined
|
||||||
|
})
|
||||||
|
mocks.keySave.mockReset().mockReturnValue({ success: true })
|
||||||
|
mocks.keyClear.mockReset().mockReturnValue({ success: true })
|
||||||
|
mocks.loadSettings.mockReset().mockReturnValue({ ttsSelectedVoiceId: 'demo-warm-female' })
|
||||||
|
mocks.updateSettings.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty dynamic voice list without exposing a missing key', () => {
|
||||||
|
const result = new TextToSpeechSettingsService().get()
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.settings.hasApiKey).toBe(false)
|
||||||
|
expect(result.settings.selectedVoiceId).toBe('')
|
||||||
|
expect(result.voices).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the key securely and persists the selected voice', () => {
|
||||||
|
mocks.keyGet.mockReturnValue({ success: true, available: true, key: 'stored' })
|
||||||
|
const result = new TextToSpeechSettingsService().save({
|
||||||
|
apiKey: ' fish-key ',
|
||||||
|
selectedVoiceId: 'demo-calm-male'
|
||||||
|
})
|
||||||
|
expect(mocks.keySave).toHaveBeenCalledWith('fish-audio-tts', 'fish-key')
|
||||||
|
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||||
|
ttsSelectedVoiceId: 'demo-calm-male'
|
||||||
|
})
|
||||||
|
expect(result.settings.hasApiKey).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists a remotely loaded voice id without local demo validation', () => {
|
||||||
|
const result = new TextToSpeechSettingsService().save({
|
||||||
|
selectedVoiceId: 'fish-model-id'
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||||
|
ttsSelectedVoiceId: 'fish-model-id'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -36,7 +36,7 @@ describe('WechatDb normalized messages', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
|
it('passes time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||||
const row = (id: string, createTime: number): WechatMessage => ({
|
const row = (id: string, createTime: number): WechatMessage => ({
|
||||||
mesLocalID: id,
|
mesLocalID: id,
|
||||||
serverId: `server-${id}`,
|
serverId: `server-${id}`,
|
||||||
@@ -49,13 +49,20 @@ describe('WechatDb normalized messages', () => {
|
|||||||
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
||||||
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
||||||
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
||||||
const getMessagesAsync = vi.fn(async () => [
|
const rows = [
|
||||||
row('before-range', start - 1),
|
row('before-range', start - 1),
|
||||||
row('newest', start + 48 * 60 * 60),
|
row('newest', start + 48 * 60 * 60),
|
||||||
shardBoundaryMessage,
|
shardBoundaryMessage,
|
||||||
{ ...shardBoundaryMessage },
|
{ ...shardBoundaryMessage },
|
||||||
row('after-range', end + 1)
|
row('after-range', end + 1)
|
||||||
])
|
]
|
||||||
|
const getMessagesAsync = vi.fn(
|
||||||
|
async (_username: string, startTime?: number, endTime?: number) =>
|
||||||
|
rows.filter((message) => {
|
||||||
|
const createTime = Number(message.msgCreateTime)
|
||||||
|
return (!startTime || createTime >= startTime) && (!endTime || createTime <= endTime)
|
||||||
|
})
|
||||||
|
)
|
||||||
const db = Object.assign(Object.create(WechatDb.prototype), {
|
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||||
wcdb4Client: { getMessagesAsync },
|
wcdb4Client: { getMessagesAsync },
|
||||||
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||||
@@ -65,7 +72,7 @@ describe('WechatDb normalized messages', () => {
|
|||||||
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||||
|
|
||||||
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
||||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
|
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user', start, end)
|
||||||
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user