v1.0.0 - Author AI Writing Platform
No files matched your search
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<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 Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,141 @@
|
||||
**English** | [简体中文](README.zh.md)
|
||||
|
||||
# ✍️ Author — AI-Powered Creative Writing Platform
|
||||
|
||||
> An AI-powered writing studio for novelists, screenwriters, and storytellers.
|
||||
|
||||
**Author** is an AI-assisted creative writing tool designed for fiction writers. It brings together a professional rich text editor, an intelligent AI writing assistant, and a complete worldbuilding management system — all in one seamless experience.
|
||||
|
||||
---
|
||||
|
||||
## 💬 Why I Built This
|
||||
|
||||
I've been using AI for a while now — from the early days of ChatGPT 3.5, to Gemini 2.0 Exp Thinking, and eventually settling on Gemini 2.5 Pro Thinking after the ChatGPT o1 era.
|
||||
|
||||
As a novelist, I care deeply about AI's ability to handle language. Novels are long, so I need models with strong context windows and high recall. But what truly moved me about Gemini was its characters — there were moments when the words on screen made me want to cry. That's emotional resonance. I need writing that embraces the full complexity of being human.
|
||||
|
||||
Then the coding-focused trend took over. Every company started optimizing for code. I thought it was a good thing — until Gemini 3.1 Pro started describing its characters in biological and psychological terminology. Code-optimized models had begun deconstructing humans into biological components. Claude Opus 4.6 was even worse: every character spoke with peak efficiency — concise, economical, not like a human, but like a machine wearing a human mask.
|
||||
|
||||
**I could no longer see the models understanding human complexity. They didn't care about what humans *do* — only what humans *are*. They stopped showing personality through behavior and emotion, and instead slapped simple definitions onto human beings.**
|
||||
|
||||
I watched the versatility of these models being gutted. I don't want us to live in a cold world of code. I built this project so that AI can preserve **our own language** — beyond the mechanical operators.
|
||||
|
||||
> To all the authors, screenwriters, hobbyists, readers, and players who use this project: I hope you can bring out the best of your craft, create works with a human touch, and keep the flame of our language alive. 🔥
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📝 Professional Editor
|
||||
- Rich text editor powered by **Tiptap** — bold, italic, headings, lists, code blocks, and more
|
||||
- **Word-style pagination** with WYSIWYG layout
|
||||
- **KaTeX** math formula support
|
||||
- Customizable fonts, font size, line height, and colors
|
||||
- Real-time word / character / paragraph count
|
||||
|
||||
### 🤖 AI Writing Assistant
|
||||
- **Multi-provider support**: ZhipuAI GLM-4 / DeepSeek / OpenAI / Google Gemini
|
||||
- **Continue / Rewrite / Polish / Expand** — one-click generation
|
||||
- **Ghost Text** streaming preview — see AI output in real-time like Cursor, with accept/reject
|
||||
- **Free chat mode** — discuss plot, characters, and settings with AI
|
||||
- **Context engine** — AI automatically reads your character profiles, worldbuilding, and previous chapters to maintain story consistency
|
||||
|
||||
### 📚 Worldbuilding Manager
|
||||
- **Tree-structured** management for characters, locations, items, outlines, and writing rules
|
||||
- Three writing modes: **Web Novel** / **Literary Fiction** / **Screenplay**, each with dedicated fields
|
||||
- Color-coded categories with glassmorphism design
|
||||
- Settings automatically injected into AI context
|
||||
|
||||
### 💾 Data Management
|
||||
- **Local-first** — all data stored in browser IndexedDB, never uploaded to servers
|
||||
- **Snapshot system** — manual/auto versioning with one-click rollback
|
||||
- **Project import/export** — full project JSON backup
|
||||
- **Markdown export** — single chapter or entire book
|
||||
|
||||
### 🌐 Internationalization
|
||||
- 🇨🇳 简体中文 / 🇺🇸 English / 🇷🇺 Русский
|
||||
|
||||
### 🎨 User Experience
|
||||
- Eye-comfort warm tones / dark mode toggle
|
||||
- Interactive onboarding tour
|
||||
- Help panel with keyboard shortcuts
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Requirements
|
||||
- **Node.js** 18+
|
||||
- **npm** 9+
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/YuanShiJiLoong/author.git
|
||||
cd author
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Configure environment variables (optional)
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your API keys
|
||||
# You can also configure them in the app's Settings panel
|
||||
```
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to start writing.
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Deploy to Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/YuanShiJiLoong/author)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ AI Configuration
|
||||
|
||||
Author supports multiple AI providers. Configure via **environment variables** or **in-app settings**:
|
||||
|
||||
| Provider | Env Variable | Get API Key |
|
||||
|----------|-------------|-------------|
|
||||
| ZhipuAI (GLM-4) | `ZHIPU_API_KEY` | [open.bigmodel.cn](https://open.bigmodel.cn/) |
|
||||
| Google Gemini | `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/apikey) |
|
||||
| DeepSeek | In-app config | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| OpenAI / Compatible | In-app config | [platform.openai.com](https://platform.openai.com/) |
|
||||
|
||||
> 💡 **No API key required** for most editing features. AI features need at least one provider configured.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under [AGPL-3.0](LICENSE).
|
||||
|
||||
**In short**:
|
||||
- ✅ Free to use, modify, and distribute
|
||||
- ✅ Commercial use allowed
|
||||
- ⚠️ Modified versions must also be open-sourced (including network services)
|
||||
- ⚠️ Original copyright notice must be preserved
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [Google Antigravity](https://antigravity.google/) — AI programming partner
|
||||
- [Tiptap](https://tiptap.dev/) — Editor framework
|
||||
- [Next.js](https://nextjs.org/) — React full-stack framework
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) — State management
|
||||
- [KaTeX](https://katex.org/) — Math rendering
|
||||
@@ -0,0 +1,141 @@
|
||||
[English](README.md) | **简体中文**
|
||||
|
||||
# ✍️ Author — AI-Powered Creative Writing Platform
|
||||
|
||||
> AI 驱动的网文创作平台 | AI-Powered Web Novel Writing Studio
|
||||
|
||||
**Author** 是一款面向小说作者的 AI 辅助创作工具。它将专业的富文本编辑器、智能 AI 写作助手和完整的世界观管理系统整合在一起,为创作者提供一站式写作体验。
|
||||
|
||||
---
|
||||
|
||||
## 💬 为什么做这个项目
|
||||
|
||||
我使用 AI 已经有一段日子了,各家公司的模型基本上都用过——从最开始的 ChatGPT 3.5,到 Gemini 2.0 Exp Thinking,接着从 ChatGPT o1 时代之后,彻底转入 Gemini 2.5 Pro Thinking。
|
||||
|
||||
我本人是写小说的,对 AI 的文字能力比较看重。小说的文本很长,因此我对模型的上下文以及召回率有很高的要求。当然,Gemini 最触动我的点还是祂笔下的人物——总会有那么一瞬间,让我有种想要落泪的冲动。这是情感共鸣。我需要这种接受了人类本身复杂性的文字。
|
||||
|
||||
然而,随着代码趋势的兴起,所有公司都开始往这个方向死磕。我本来觉得是好事,但当 Gemini 3.1 Pro 第一次将笔下的人物描述成生物学和心理学的术语时,我发现我错了——代码方向的模型将人类本身解构成一堆生物学零件。特别是 Claude Opus 4.6,这个模型笔下的所有人物都在某种心理学定义的性格里达成了极致的效率:说话言简意赅、惜字如金,不像人类,像个人机。
|
||||
|
||||
**我看不到模型对人类本身复杂性的理解。模型不在乎人类做了什么,只在乎人类是什么。模型不从人的行为去体现人的性格和情感,反而直接对人类本身下一个很简单的定义。**
|
||||
|
||||
我看到模型的通用性在被阉割。我不希望我们活在冰冷的代码世界。建立这个项目,是为了让 AI 能够在那些机械算符之外,**保留我们人类自己的语言**。
|
||||
|
||||
> 希望所有使用该项目的作者、编剧、爱好者,甚至读者、玩家,能够发挥自己的长处,创建出有人味儿的作品,保住我们自己语言的火种。🔥
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 📝 专业编辑器
|
||||
- 基于 **Tiptap** 的富文本编辑器,支持加粗、斜体、标题、列表、代码块等
|
||||
- **Word 风格分页**排版,所见即所得
|
||||
- **KaTeX** 数学公式支持
|
||||
- 字体、字号、行距、颜色自定义
|
||||
- 实时字数/字符/段落统计
|
||||
|
||||
### 🤖 AI 写作助手
|
||||
- **多 AI 供应商**:智谱 GLM-4 / DeepSeek / OpenAI / Google Gemini
|
||||
- **续写 / 改写 / 润色 / 扩写**,一键生成
|
||||
- **Ghost Text** 流式预览 — 像 Cursor 一样实时显示 AI 生成内容,支持接受/拒绝
|
||||
- **自由对话模式** — 与 AI 讨论剧情、角色、设定
|
||||
- **上下文引擎** — AI 自动感知你的角色设定、世界观、前文,保持剧情连贯
|
||||
|
||||
### 📚 设定集管理
|
||||
- **树形结构**管理角色、地点、物品、大纲、写作规则
|
||||
- 三种写作模式:**网文** / **传统文学** / **剧本**,每种模式有专属字段
|
||||
- 分类配色 + glassmorphism 视觉风格
|
||||
- 设定内容自动注入 AI 上下文
|
||||
|
||||
### 💾 数据管理
|
||||
- **本地优先** — 所有数据存储在浏览器 IndexedDB,不上传服务器
|
||||
- **快照系统** — 手动/自动版本存档,支持一键回滚
|
||||
- **项目导入导出** — 完整项目 JSON 备份
|
||||
- **Markdown 导出** — 单章或全部导出
|
||||
|
||||
### 🌐 国际化
|
||||
- 🇨🇳 简体中文 / 🇺🇸 English / 🇷🇺 Русский
|
||||
|
||||
### 🎨 界面体验
|
||||
- 护眼暖色调 / 深色模式切换
|
||||
- 新手引导教程
|
||||
- 帮助面板 + 快捷键说明
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
- **Node.js** 18+
|
||||
- **npm** 9+
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/YuanShiJiLoong/author.git
|
||||
cd author
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 配置环境变量(可选)
|
||||
cp .env.example .env.local
|
||||
# 编辑 .env.local 填入你的 API Key
|
||||
# 也可以在应用内「设置」面板中配置
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000) 即可使用。
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### 部署到 Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/YuanShiJiLoong/author)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ AI 配置
|
||||
|
||||
Author 支持多种 AI 供应商,你可以通过 **环境变量** 或 **应用内设置** 来配置:
|
||||
|
||||
| 供应商 | 环境变量 | 获取 API Key |
|
||||
|--------|---------|-------------|
|
||||
| 智谱 AI (GLM-4) | `ZHIPU_API_KEY` | [open.bigmodel.cn](https://open.bigmodel.cn/) |
|
||||
| Google Gemini | `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/apikey) |
|
||||
| DeepSeek | 应用内配置 | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| OpenAI / 兼容 | 应用内配置 | [platform.openai.com](https://platform.openai.com/) |
|
||||
|
||||
> 💡 **无需 API Key 也能使用**大部分编辑功能。AI 功能需要至少配置一个供应商。
|
||||
|
||||
---
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [AGPL-3.0](LICENSE) 协议开源。
|
||||
|
||||
**简单说**:
|
||||
- ✅ 你可以自由使用、修改、分发
|
||||
- ✅ 你可以用于个人/商业用途
|
||||
- ⚠️ 修改后的版本也必须开源(包括网络服务)
|
||||
- ⚠️ 必须保留原始版权声明
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [Google Antigravity](https://antigravity.google/) — AI 编程伙伴
|
||||
- [Tiptap](https://tiptap.dev/) — 编辑器框架
|
||||
- [Next.js](https://nextjs.org/) — React 全栈框架
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) — 状态管理
|
||||
- [KaTeX](https://katex.org/) — 数学公式渲染
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 拉取 Gemini 可用模型列表
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { apiKey, baseUrl } = await request.json();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: '请填入 API Key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const base = (baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
|
||||
const url = `${base}/models?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('拉取模型列表失败:', response.status, errorText);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API Key 无效或无权限' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `拉取失败(${response.status})` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 过滤出支持 generateContent 的模型
|
||||
const models = (data.models || [])
|
||||
.filter(m => m.supportedGenerationMethods?.includes('generateContent'))
|
||||
.map(m => ({
|
||||
id: m.name?.replace('models/', '') || m.name,
|
||||
displayName: m.displayName || m.name,
|
||||
description: m.description || '',
|
||||
inputTokenLimit: m.inputTokenLimit,
|
||||
outputTokenLimit: m.outputTokenLimit,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
|
||||
} catch (error) {
|
||||
console.error('拉取模型列表错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '网络连接失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Gemini 原生 API — SSE 流式转发(Edge Runtime 确保流式不被缓冲)
|
||||
// 使用 streamGenerateContent 端点
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { systemPrompt, userPrompt, apiConfig, maxTokens } = await request.json();
|
||||
|
||||
const apiKey = apiConfig?.apiKey || process.env.GEMINI_API_KEY;
|
||||
let rawBaseUrl = apiConfig?.baseUrl;
|
||||
if (!rawBaseUrl || rawBaseUrl.includes('open.bigmodel.cn')) {
|
||||
rawBaseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
}
|
||||
const baseUrl = rawBaseUrl.replace(/\/$/, '');
|
||||
const model = apiConfig?.model || 'gemini-2.0-flash';
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '请先配置 API Key。点击左下角 ⚙️ → API配置,填入你的 Gemini Key' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 streamGenerateContent 端点 + alt=sse
|
||||
const url = `${baseUrl}/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
|
||||
|
||||
const requestBody = {
|
||||
system_instruction: {
|
||||
parts: [{ text: systemPrompt }]
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: userPrompt }]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: maxTokens || 4000,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Gemini API错误:', response.status, errorText);
|
||||
|
||||
const errorHandlers = {
|
||||
400: () => {
|
||||
try {
|
||||
const errObj = JSON.parse(errorText);
|
||||
return `Gemini 请求错误:${errObj?.error?.message || errorText}`;
|
||||
} catch {
|
||||
return `Gemini 请求错误(400):${errorText}`;
|
||||
}
|
||||
},
|
||||
401: () => 'API Key 无效或无权限,请检查你的 Gemini API Key',
|
||||
403: () => 'API Key 无效或无权限,请检查你的 Gemini API Key',
|
||||
429: () => '请求频率过高或配额不足,请稍后再试',
|
||||
};
|
||||
const errMsg = errorHandlers[response.status]?.()
|
||||
|| `Gemini 服务返回错误(${response.status}),请检查 API 配置`;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errMsg }),
|
||||
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// 将 Gemini SSE 流转换为统一格式转发给前端
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed.slice(6));
|
||||
const candidate = json.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.thought === true && part.text) {
|
||||
// Gemini 思维链
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ thinking: part.text })}\n\n`));
|
||||
} else if (part.text) {
|
||||
// 正文内容
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: part.text })}\n\n`));
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 usage(通常在最后一个 chunk)
|
||||
const usageMeta = json.usageMetadata;
|
||||
if (usageMeta?.totalTokenCount) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
usage: {
|
||||
promptTokens: usageMeta.promptTokenCount || 0,
|
||||
completionTokens: usageMeta.candidatesTokenCount || 0,
|
||||
totalTokens: usageMeta.totalTokenCount || 0,
|
||||
}
|
||||
})}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// 解析失败跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 发送结束信号
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
} catch (err) {
|
||||
console.error('Gemini Stream 读取错误:', err.message);
|
||||
} finally {
|
||||
controller.close();
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Gemini 接口错误:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: '网络连接失败,请检查 API 地址是否正确' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 通用模型列表拉取 — 支持 OpenAI 兼容格式和 Gemini 原生格式
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { apiKey, baseUrl, provider } = await request.json();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先填入 API Key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Gemini 原生格式
|
||||
if (provider === 'gemini-native') {
|
||||
return await fetchGeminiModels(apiKey, baseUrl);
|
||||
}
|
||||
|
||||
// OpenAI 兼容格式(适用于所有其他供应商)
|
||||
return await fetchOpenAIModels(apiKey, baseUrl);
|
||||
|
||||
} catch (error) {
|
||||
console.error('拉取模型列表错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '网络连接失败,请检查 API 地址' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 原生格式拉取模型
|
||||
async function fetchGeminiModels(apiKey, baseUrl) {
|
||||
const base = (baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
|
||||
const url = `${base}/models?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return handleFetchError(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = (data.models || [])
|
||||
.filter(m => m.supportedGenerationMethods?.includes('generateContent') || m.supportedGenerationMethods?.includes('embedContent'))
|
||||
.map(m => ({
|
||||
id: m.name?.replace('models/', '') || m.name,
|
||||
displayName: m.displayName || m.name,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
}
|
||||
|
||||
// OpenAI 兼容格式拉取模型(/v1/models)
|
||||
async function fetchOpenAIModels(apiKey, baseUrl) {
|
||||
const base = (baseUrl || '').replace(/\/$/, '');
|
||||
if (!base) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先填写 API 地址' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${base}/models`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return handleFetchError(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// OpenAI 格式返回 { data: [{id, ...}, ...] }
|
||||
const models = (data.data || [])
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
displayName: m.id,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
}
|
||||
|
||||
async function handleFetchError(response) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API Key 无效或无权限' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
let errMsg = `拉取失败(${response.status})`;
|
||||
try {
|
||||
const errObj = JSON.parse(errorText);
|
||||
errMsg = errObj?.error?.message || errMsg;
|
||||
} catch { /* ignore */ }
|
||||
return NextResponse.json(
|
||||
{ error: errMsg },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// OpenAI 兼容 API — SSE 流式转发(Edge Runtime 确保流式不被缓冲)
|
||||
// 支持智谱、DeepSeek、OpenAI、Moonshot 等所有兼容 OpenAI 格式的 API
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { systemPrompt, userPrompt, apiConfig, maxTokens } = await request.json();
|
||||
|
||||
const apiKey = apiConfig?.apiKey || process.env.ZHIPU_API_KEY;
|
||||
const baseUrl = apiConfig?.baseUrl || 'https://open.bigmodel.cn/api/paas/v4';
|
||||
const model = apiConfig?.model || 'glm-4-flash';
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '请先配置 API Key。点击左下角 ⚙️ → API配置,填入你的 Key' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
||||
|
||||
// 请求上游 API,开启 stream 模式
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
temperature: 0.8,
|
||||
max_tokens: maxTokens || 4096,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('API错误:', response.status, errorText);
|
||||
|
||||
const errorMessages = {
|
||||
401: 'API Key 无效或已过期,请检查后重新填写',
|
||||
429: '请求频率过高或额度不足,请稍后再试',
|
||||
};
|
||||
const errMsg = errorMessages[response.status]
|
||||
|| `AI服务返回错误(${response.status}),请检查 API 配置`;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errMsg }),
|
||||
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// 将上游 SSE 流透传给前端
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// 最后一行可能不完整,留到下次
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
|
||||
if (trimmed === 'data: [DONE]') {
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed.slice(6));
|
||||
const delta = json.choices?.[0]?.delta;
|
||||
|
||||
// 转发思维链内容(DeepSeek reasoning_content)
|
||||
const reasoning = delta?.reasoning_content;
|
||||
if (reasoning) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ thinking: reasoning })}\n\n`));
|
||||
}
|
||||
|
||||
// 转发文本 delta
|
||||
const content = delta?.content;
|
||||
if (content) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: content })}\n\n`));
|
||||
}
|
||||
|
||||
// 检查是否包含 usage 信息(某些 API 在最后一个事件中返回)
|
||||
if (json.usage) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
usage: {
|
||||
promptTokens: json.usage.prompt_tokens || 0,
|
||||
completionTokens: json.usage.completion_tokens || 0,
|
||||
totalTokens: json.usage.total_tokens || 0,
|
||||
}
|
||||
})}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// 解析失败的行直接跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 客户端断开等情况
|
||||
console.error('Stream 读取错误:', err.message);
|
||||
} finally {
|
||||
controller.close();
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI接口错误:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: '网络连接失败,请检查 API 地址是否正确' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 测试 API 连接(通用 — 支持 OpenAI 兼容格式和 Gemini 原生格式)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { apiConfig } = await request.json();
|
||||
const { apiKey, baseUrl, model, provider } = apiConfig || {};
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请先填入 API Key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Gemini 原生格式的测试
|
||||
if (provider === 'gemini-native') {
|
||||
return await testGeminiNative(apiKey, baseUrl, model);
|
||||
}
|
||||
|
||||
// OpenAI 兼容格式的测试
|
||||
return await testOpenAICompat(apiKey, baseUrl, model);
|
||||
|
||||
} catch (error) {
|
||||
console.error('API测试错误:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '网络连接失败,请检查 API 地址' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testGeminiNative(apiKey, baseUrl, model) {
|
||||
const base = (baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
|
||||
const m = model || 'gemini-2.0-flash';
|
||||
const url = `${base}/models/${m}:generateContent?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: 'user', parts: [{ text: '说"连接成功"' }] }],
|
||||
generationConfig: { maxOutputTokens: 20 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
let errMsg = `连接失败(${response.status})`;
|
||||
try {
|
||||
const errObj = JSON.parse(errText);
|
||||
errMsg = errObj?.error?.message || errMsg;
|
||||
} catch { /* ignore parse error */ }
|
||||
return NextResponse.json({ success: false, error: errMsg });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const reply = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `✅ Gemini 原生 API 连接成功!`,
|
||||
model: m,
|
||||
reply: reply.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
async function testOpenAICompat(apiKey, baseUrl, model) {
|
||||
const base = (baseUrl || 'https://open.bigmodel.cn/api/paas/v4').replace(/\/$/, '');
|
||||
const m = model || 'gpt-4o-mini';
|
||||
const url = `${base}/chat/completions`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: m,
|
||||
messages: [{ role: 'user', content: '说"连接成功"' }],
|
||||
max_tokens: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
let errMsg = `连接失败(${response.status})`;
|
||||
try {
|
||||
const errObj = JSON.parse(errText);
|
||||
errMsg = errObj?.error?.message || errMsg;
|
||||
} catch { /* ignore parse error */ }
|
||||
return NextResponse.json({ success: false, error: errMsg });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const reply = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `✅ API 连接成功!`,
|
||||
model: m,
|
||||
reply: reply.trim(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Gemini 原生 API & OpenAI 兼容 API — 文本向量化 (Text Embeddings)
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { text, apiConfig } = await request.json();
|
||||
|
||||
const isCustomEmbed = apiConfig?.useCustomEmbed;
|
||||
const provider = isCustomEmbed ? apiConfig.embedProvider : (apiConfig?.provider || 'zhipu');
|
||||
const apiKey = isCustomEmbed ? (apiConfig.embedApiKey || apiConfig?.apiKey) : apiConfig?.apiKey;
|
||||
|
||||
// 自动识别默认填写或遗留的智谱URL并矫正为对应官方URL
|
||||
let defaultBaseUrl = provider === 'gemini-native' ? 'https://generativelanguage.googleapis.com/v1beta' : 'https://open.bigmodel.cn/api/paas/v4';
|
||||
|
||||
let rawBaseUrl;
|
||||
if (isCustomEmbed) {
|
||||
rawBaseUrl = apiConfig.embedBaseUrl;
|
||||
} else {
|
||||
// 如果是自定义提供商且没开独立Embed,默认继承对聊的baseUrl
|
||||
rawBaseUrl = apiConfig?.baseUrl || defaultBaseUrl;
|
||||
}
|
||||
|
||||
if (!rawBaseUrl || (provider === 'gemini-native' && rawBaseUrl.includes('open.bigmodel.cn'))) {
|
||||
rawBaseUrl = defaultBaseUrl;
|
||||
}
|
||||
|
||||
const baseUrl = rawBaseUrl.replace(/\/$/, '');
|
||||
|
||||
let embedModelName;
|
||||
if (isCustomEmbed) {
|
||||
embedModelName = apiConfig.embedModel || 'embedding-3';
|
||||
} else if (provider === 'custom') {
|
||||
// 如果没开独立embed,但选了custom,默认用text-embedding-v3-small或用户主模型
|
||||
embedModelName = 'text-embedding-v3-small';
|
||||
} else {
|
||||
embedModelName = provider === 'zhipu' ? 'embedding-3' : 'text-embedding-v3-small';
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: isCustomEmbed ? '请在API配置中填写独立的 Embedding API Key' : '请先配置 API Key' }), { status: 400 });
|
||||
}
|
||||
|
||||
if (!text || typeof text !== 'string') {
|
||||
return new Response(JSON.stringify({ error: '无效的文本输入' }), { status: 400 });
|
||||
}
|
||||
|
||||
let embeddings = [];
|
||||
|
||||
if (provider === 'gemini-native') {
|
||||
const geminiModel = embedModelName || 'text-embedding-004';
|
||||
const url = `${baseUrl}/models/${geminiModel}:embedContent?key=${apiKey}`;
|
||||
console.log('Fetching Gemini Embeddings:', url);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: `models/${geminiModel}`,
|
||||
content: { parts: [{ text }] }
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Gemini Embedding Error: ${errText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
embeddings = data.embedding.values;
|
||||
} else {
|
||||
// OpenAI 兼容格式
|
||||
const url = `${baseUrl}/embeddings`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: text,
|
||||
model: embedModelName
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Embedding API Error: ${errText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
embeddings = data.data[0].embedding;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ embedding: embeddings }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Embedding API Error:', err);
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 聊天消息 Markdown 渲染组件
|
||||
* 支持:GFM (表格/删除线/任务列表) + 数学公式 (KaTeX) + 代码块
|
||||
*/
|
||||
function ChatMarkdownInner({ content }) {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 代码块:带复制按钮
|
||||
code({ inline, className, children, ...props }) {
|
||||
const text = String(children).replace(/\n$/, '');
|
||||
if (inline) {
|
||||
return <code className="chat-md-inline-code" {...props}>{children}</code>;
|
||||
}
|
||||
const lang = className?.replace('language-', '') || '';
|
||||
return <CodeBlock lang={lang} code={text} />;
|
||||
},
|
||||
// 链接:新窗口打开
|
||||
a({ href, children, ...props }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="chat-md-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// 表格样式
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="chat-md-table-wrap">
|
||||
<table className="chat-md-table" {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// 段落
|
||||
p({ children, ...props }) {
|
||||
return <p className="chat-md-p" {...props}>{children}</p>;
|
||||
},
|
||||
// 列表
|
||||
ul({ children, ...props }) {
|
||||
return <ul className="chat-md-ul" {...props}>{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return <ol className="chat-md-ol" {...props}>{children}</ol>;
|
||||
},
|
||||
// 引用
|
||||
blockquote({ children, ...props }) {
|
||||
return <blockquote className="chat-md-blockquote" {...props}>{children}</blockquote>;
|
||||
},
|
||||
// 标题
|
||||
h1({ children, ...props }) { return <h3 className="chat-md-heading" {...props}>{children}</h3>; },
|
||||
h2({ children, ...props }) { return <h4 className="chat-md-heading" {...props}>{children}</h4>; },
|
||||
h3({ children, ...props }) { return <h5 className="chat-md-heading" {...props}>{children}</h5>; },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
// 代码块子组件(带复制按钮)
|
||||
function CodeBlock({ lang, code }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="chat-md-code-block">
|
||||
<div className="chat-md-code-header">
|
||||
<span className="chat-md-code-lang">{lang || 'text'}</span>
|
||||
<button className="chat-md-copy-btn" onClick={handleCopy}>
|
||||
{copied ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="chat-md-pre"><code>{code}</code></pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChatMarkdown = memo(ChatMarkdownInner);
|
||||
export default ChatMarkdown;
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { Mark } from '@tiptap/core';
|
||||
|
||||
/**
|
||||
* GhostMark — Tiptap Mark 扩展
|
||||
* AI 生成的文本标记为 ghost-text,以半透明样式显示。
|
||||
* 用户接受时去除 mark,拒绝时删除文本。
|
||||
*/
|
||||
const GhostMark = Mark.create({
|
||||
name: 'ghostText',
|
||||
|
||||
// 不会被持久化到 HTML(保存时自动剥离)
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'ghost-text',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span.ghost-text',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', { ...this.options.HTMLAttributes, ...HTMLAttributes }, 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setGhostText: () => ({ commands }) => {
|
||||
return commands.setMark(this.name);
|
||||
},
|
||||
unsetGhostText: () => ({ commands }) => {
|
||||
return commands.unsetMark(this.name);
|
||||
},
|
||||
// 删除文档中所有 ghost 标记(保留文本,去掉半透明)
|
||||
acceptAllGhost: () => ({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
const ghostMark = node.marks.find(m => m.type.name === this.name);
|
||||
if (ghostMark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, ghostMark.type);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 删除带 ghost 标记的所有文本节点,并清理残留的空段落
|
||||
// ghostStart: 可选,ghost 文本插入起始位置,仅清理该位置之后的空段落
|
||||
removeAllGhost: (ghostStart) => ({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
// 第一步:收集所有 ghost 文本范围
|
||||
const ranges = [];
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
const ghostMark = node.marks.find(m => m.type.name === this.name);
|
||||
if (ghostMark) {
|
||||
ranges.push({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
}
|
||||
});
|
||||
// 记录 ghost 区域的最小起始位置(用于限定空段落清理范围)
|
||||
const effectiveStart = ghostStart != null
|
||||
? ghostStart
|
||||
: (ranges.length > 0 ? ranges[0].from : null);
|
||||
// 从后向前删除 ghost 文本
|
||||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||||
tr.delete(ranges[i].from, ranges[i].to);
|
||||
}
|
||||
// 第二步:清理 ghost 区域内因删除文本而产生的空段落
|
||||
if (effectiveStart != null) {
|
||||
const emptyParas = [];
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'paragraph' && node.content.size === 0 && pos >= effectiveStart) {
|
||||
emptyParas.push({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
});
|
||||
for (let i = emptyParas.length - 1; i >= 0; i--) {
|
||||
if (tr.doc.content.childCount > 1) {
|
||||
tr.delete(emptyParas[i].from, emptyParas[i].to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default GhostMark;
|
||||
@@ -0,0 +1,503 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
|
||||
const HELP_SECTIONS = [
|
||||
{
|
||||
id: 'quickstart',
|
||||
title: '🚀 快速开始',
|
||||
content: `
|
||||
## 欢迎使用 Author
|
||||
|
||||
Author 是一款面向小说创作者的 **AI 辅助写作平台**,集成智能续写、设定集管理、上下文感知等专业功能,为你打造沉浸式创作体验。
|
||||
|
||||
### 第一步:配置 AI
|
||||
1. 点击左侧边栏底部的 **⚙️ 设定** 按钮
|
||||
2. 在「API 配置」中填写你的 AI 服务信息
|
||||
3. 支持 **OpenAI 兼容接口** 和 **Google Gemini 原生接口**
|
||||
4. 点击「测试连接」确认配置正确
|
||||
|
||||
### 第二步:开始创作
|
||||
1. 在左侧边栏点击 **+ 新建** 创建章节
|
||||
2. 在编辑器中直接开始写作
|
||||
3. 使用顶部工具栏调整格式
|
||||
|
||||
### 第三步:AI 辅助
|
||||
- **内联 AI**:按 **Ctrl+J** 在光标处唤起 AI,直接在编辑器中续写
|
||||
- **AI 聊天**:点击右上角 **✦ AI** 打开侧边栏,与 AI 对话讨论剧情
|
||||
- AI 会自动参考你的设定集、前文内容和写作模式
|
||||
|
||||
### 界面概览
|
||||
| 区域 | 功能 |
|
||||
|------|------|
|
||||
| **左侧边栏** | 章节管理、字数统计、存档/读档、设定集、主题 |
|
||||
| **顶部工具栏** | 文字格式、对齐、列表、公式、排版 |
|
||||
| **编辑区域** | 所见即所得的富文本编辑,分页预览 |
|
||||
| **右侧 AI 栏** | AI 聊天对话、上下文参考管理 |
|
||||
| **底部状态栏** | 写作模式、页数、字数统计 |
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ai-inline',
|
||||
title: '✦ 内联 AI 写作',
|
||||
content: `
|
||||
## 内联 AI 写作助手
|
||||
|
||||
在编辑器内直接使用 AI,类似 Cursor 的体验。
|
||||
|
||||
### 唤起方式
|
||||
按下 **Ctrl+J**(Mac: **⌘+J**)在光标位置弹出 AI 面板。
|
||||
|
||||
### AI 模式
|
||||
|
||||
| 模式 | 功能 | 需要选中文字 |
|
||||
|------|------|:----:|
|
||||
| ✦ 续写 | 从光标处自然续写故事 | ✗ |
|
||||
| ✎ 润色 | 提升文字质量和流畅度 | ✓ |
|
||||
| ⊕ 扩写 | 丰富细节与描写 | ✓ |
|
||||
| ⊖ 精简 | 浓缩核心内容 | ✓ |
|
||||
|
||||
### 使用步骤
|
||||
1. 将光标放在要续写的位置,或选中要修改的文字
|
||||
2. 按 **Ctrl+J** 打开 AI 面板
|
||||
3. 选择所需模式
|
||||
4. (可选)在输入框中补充指示,如"写一段打斗场景"
|
||||
5. 按 **Enter** 或点击 **✦ 生成** 开始
|
||||
6. AI 会以 **Ghost Text**(幽灵文字)效果出现在编辑器中
|
||||
7. 点击 **✓ 接受** 确认采用,或 **✕ 拒绝** 撤销
|
||||
|
||||
### 上下文感知
|
||||
AI 会自动参考:
|
||||
- **前文内容**:自动采集前文上下文
|
||||
- **设定集**:角色设定、世界观、情节大纲等
|
||||
- **写作模式**:网文/纯文学/剧本不同风格
|
||||
|
||||
> 提示:在设定集中配置角色和世界观,AI 生成内容会更贴合你的故事。
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ai-chat',
|
||||
title: '💬 AI 聊天侧栏',
|
||||
content: `
|
||||
## AI 聊天侧栏
|
||||
|
||||
与 AI 进行多轮对话,讨论剧情、角色、世界观,甚至让 AI 直接管理你的设定集。
|
||||
|
||||
### 打开方式
|
||||
点击编辑器右上角的 **✦ AI** 按钮,或使用快捷键。
|
||||
|
||||
### 核心功能
|
||||
|
||||
#### 多轮对话
|
||||
- 支持完整的多轮对话历史
|
||||
- AI 回复支持 **Markdown 渲染**(代码块、表格、列表等)
|
||||
- 可以编辑已发送的消息重新生成
|
||||
|
||||
#### 上下文参考(参考 Tab)
|
||||
切换到「参考」标签页,勾选要注入的上下文:
|
||||
- 人物设定、世界观、地点、物品、大纲、写作规则
|
||||
- 勾选的内容会作为 AI 的背景知识
|
||||
|
||||
#### 会话管理
|
||||
- **新建会话**:开启全新对话
|
||||
- **切换会话**:在多个对话间切换
|
||||
- **重命名/删除**:右键管理会话
|
||||
|
||||
#### AI 管理设定集
|
||||
对话中 AI 可以生成 **设定操作卡片**,你可以一键应用:
|
||||
- **添加**:AI 建议新角色/设定,点击「✅ 应用」直接写入设定集
|
||||
- **更新**:AI 修改已有设定(按名称自动匹配)
|
||||
- **删除**:AI 建议删除某项设定
|
||||
- 点击卡片标题栏 **▼ 展开** 可以查看完整内容
|
||||
|
||||
#### 消息变体
|
||||
- 对 AI 回复点击「重新生成」可获取不同版本
|
||||
- 使用 < 1/3 > 导航切换不同变体
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: '⚙️ 设定集系统',
|
||||
content: `
|
||||
## 设定集管理
|
||||
|
||||
设定集是 AI 创作的"记忆",帮助 AI 深入理解你的故事世界,生成更贴合的内容。
|
||||
|
||||
### 打开方式
|
||||
点击侧栏底部的 **⚙️** 按钮。
|
||||
|
||||
### API 配置与模型设置
|
||||
这是 AI 助理运作的基石。所有写入的 API 密钥都在 **本地浏览器存储**,绝对安全。
|
||||
| 设置项 | 说明 |
|
||||
|------|------|
|
||||
| **API 提供商** | 支持智谱、DeepSeek、OpenAI、Gemini、Kimi网络等,也可选自定义接入 |
|
||||
| **API Key** | 你的大模型服务凭证。必须配置才能唤醒光标 AI |
|
||||
| **Base URL** | 接口地址(切换提供商时会自动填写官方地址) |
|
||||
| **模型选择** | 点击「从API拉取模型列表」自动更新当前可用的大模型库供你选择 |
|
||||
| **独立向量API** | 高级功能。允许你用专用的 Embedding 模型解决长篇巨著的“记忆遗忘”问题 |
|
||||
|
||||
### 偏好设置 (Preferences)
|
||||
在设定集的最后一栏,你可以随时:
|
||||
- 切换语言界面(简体中文、English、Русский)
|
||||
- 切换工作台视觉主题(经典纸张 / 现代玻璃)
|
||||
|
||||
### 书籍信息
|
||||
设置书名、类型、简介——帮助 AI 了解整体方向。
|
||||
|
||||
### 设定树
|
||||
在左侧树形结构中添加和管理各类设定:
|
||||
|
||||
| 分类 | 图标 | 可设置字段 |
|
||||
|------|------|---------|
|
||||
| **角色设定** | 🎭 | 类型、性别、年龄、外貌、性格、说话风格、背景、动机、能力、关系 |
|
||||
| **世界观** | 🌍 | 描述、备注 |
|
||||
| **地点/空间** | 📍 | 描述、场景标题、感官细节(视觉/听觉/嗅觉)、氛围 |
|
||||
| **物品/道具** | 📦 | 描述、类型、品阶、持有者、数值属性、象征意义 |
|
||||
| **剧情大纲** | 📋 | 状态(计划中/写作中/已完成)、描述、备注 |
|
||||
| **写作规则** | ✍️ | 规则描述(AI 严格遵守) |
|
||||
|
||||
### 额外字段
|
||||
AI 生成的非标准字段会自动出现在 **✨ AI 生成的额外字段** 分组中,可查看和编辑。
|
||||
|
||||
### 写作模式
|
||||
| 模式 | 特点 |
|
||||
|------|------|
|
||||
| 📱 网文模式 | 节奏紧凑、对话多、爽点密集 |
|
||||
| 📖 纯文学模式 | 叙述细腻、描写丰富、注重意境 |
|
||||
| 🎬 剧本模式 | 标准剧本格式 |
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'chapters',
|
||||
title: '📚 章节管理',
|
||||
content: `
|
||||
## 章节管理
|
||||
|
||||
### 创建章节
|
||||
点击侧栏中的 **+ 新建** 按钮,输入章节标题即可创建。
|
||||
|
||||
### 切换章节
|
||||
点击侧栏中的章节名称切换到该章节,编辑器会加载其内容。
|
||||
|
||||
### 右键操作
|
||||
在章节名称上 **右键单击** 可以:
|
||||
- **✎ 重命名** — 修改章节标题
|
||||
- **↓ 导出 Markdown** — 将当前章节导出为 .md 文件
|
||||
- **✕ 删除章节** — 删除该章节(需确认)
|
||||
|
||||
### 拖拽排序
|
||||
长按章节名称可以拖拽重新排列顺序。
|
||||
|
||||
### 字数统计
|
||||
- 每个章节旁显示本章字数
|
||||
- 侧栏底部显示全书总字数
|
||||
|
||||
### 分页预览
|
||||
编辑器以类似 Word/Google Docs 的分页视图呈现,白色纸张卡片 + 灰色画布背景,底部状态栏显示当前页数。
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'toolbar',
|
||||
title: '🎨 工具栏功能',
|
||||
content: `
|
||||
## 工具栏一览
|
||||
|
||||
### 撤销 / 重做
|
||||
↩ 撤销最近的操作,↪ 恢复已撤销的操作。
|
||||
|
||||
### 字体与字号
|
||||
- 下拉选择字体:默认(宋体)、黑体、楷体、仿宋、Serif、Monospace
|
||||
- 下拉选择字号:从 12px 到 32px
|
||||
|
||||
### 文字格式
|
||||
| 按钮 | 功能 | 快捷键 |
|
||||
|------|------|--------|
|
||||
| **B** | 加粗 | Ctrl+B |
|
||||
| *I* | 斜体 | Ctrl+I |
|
||||
| U | 下划线 | Ctrl+U |
|
||||
| ~~S~~ | 删除线 | — |
|
||||
| X² | 上标 | — |
|
||||
| X₂ | 下标 | — |
|
||||
|
||||
### 颜色
|
||||
- **A▾** — 文字颜色选择器
|
||||
- **高亮▾** — 背景高亮色选择器
|
||||
|
||||
### 标题与对齐
|
||||
- H1/H2/H3 — 一级/二级/三级标题
|
||||
- 左对齐/居中/右对齐/两端对齐
|
||||
|
||||
### 排版调节
|
||||
点击 **Aa▾** 调节全局字号(14-24px)和行距(1.4-2.6),可一键恢复默认。
|
||||
|
||||
### 列表与块元素
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| • 列 | 无序列表 |
|
||||
| 1. 列 | 有序列表 |
|
||||
| ☑ 任 | 任务列表 |
|
||||
| ❝ 引 | 引用块 |
|
||||
| </> | 代码块 |
|
||||
| ∑ | LaTeX 公式 |
|
||||
| —— | 水平分割线 |
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: '💾 数据管理',
|
||||
content: `
|
||||
## 数据管理
|
||||
|
||||
### 自动保存
|
||||
编辑内容会 **实时自动保存** 到浏览器的 localStorage,无需手动保存。
|
||||
|
||||
### 存档 / 读档(项目级别)
|
||||
在侧栏底部:
|
||||
- **💾 存档** — 将整个项目(所有章节 + 设定集 + AI 对话历史)导出为 JSON 文件
|
||||
- **📂 读档** — 从 JSON 文件恢复完整项目
|
||||
|
||||
> 建议定期存档,防止浏览器数据被清空。
|
||||
|
||||
### 章节导出
|
||||
- **导出本章** — 将当前章节导出为 Markdown (.md) 文件
|
||||
- **导出全部** — 将所有章节合并导出为一个 Markdown 文件
|
||||
|
||||
### 重要提醒
|
||||
- 所有数据存储在 **浏览器本地**,不会上传到任何服务器
|
||||
- **清除浏览器数据** 会丢失所有未导出的内容
|
||||
- **API Key** 仅存储在本地 localStorage 中
|
||||
- 建议定期使用「💾 存档」功能备份
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: '📝 Markdown',
|
||||
content: `
|
||||
## Markdown 自动渲染
|
||||
|
||||
在编辑器中输入 Markdown 语法会 **自动转换** 为富文本格式。
|
||||
|
||||
### 支持的语法
|
||||
| 输入 | 效果 |
|
||||
|------|------|
|
||||
| \`**加粗**\` | **加粗** |
|
||||
| \`*斜体*\` | *斜体* |
|
||||
| \`~~删除线~~\` | ~~删除线~~ |
|
||||
| \`# 标题\` | 一级标题 |
|
||||
| \`## 标题\` | 二级标题 |
|
||||
| \`### 标题\` | 三级标题 |
|
||||
| \`- 列表\` | 无序列表 |
|
||||
| \`1. 列表\` | 有序列表 |
|
||||
| \`> 引用\` | 引用块 |
|
||||
| \`---\` | 分割线 |
|
||||
|
||||
### AI 聊天中的 Markdown
|
||||
AI 聊天侧栏中的回复支持完整的 Markdown 渲染,包括代码块、表格、链接等。
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: '⌨️ 快捷键',
|
||||
content: `
|
||||
## 键盘快捷键
|
||||
|
||||
### 编辑
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| Ctrl+Z | 撤销 |
|
||||
| Ctrl+Y | 重做 |
|
||||
| Ctrl+A | 全选 |
|
||||
|
||||
### 格式
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| Ctrl+B | 加粗 |
|
||||
| Ctrl+I | 斜体 |
|
||||
| Ctrl+U | 下划线 |
|
||||
|
||||
### AI
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| Ctrl+J | 打开/关闭内联 AI 面板 |
|
||||
| Enter | 开始 AI 生成 |
|
||||
| Esc | 关闭 AI 面板 / 取消生成 |
|
||||
|
||||
### Markdown 快捷输入
|
||||
| 输入 | 触发 |
|
||||
|------|------|
|
||||
| \`# \` + 空格 | 一级标题 |
|
||||
| \`## \` + 空格 | 二级标题 |
|
||||
| \`- \` + 空格 | 无序列表 |
|
||||
| \`1. \` + 空格 | 有序列表 |
|
||||
| \`> \` + 空格 | 引用块 |
|
||||
| \`---\` + 回车 | 分割线 |
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: '🎭 主题 & 排版',
|
||||
content: `
|
||||
## 主题与偏好排版
|
||||
|
||||
### 双旗舰主题引擎
|
||||
目前 Author 提供两套深度打磨的主题集,可在设定集 -> **偏好设置** 中无缝切换:
|
||||
- 📜 **经典纸张 (Warm Classic)**: 采用高级护眼的复古暖灰色调,所有卡片呈现日记本般的实体拟物反馈。
|
||||
- 🧊 **现代通透 (Modern Glass)**: 苹果 macOS 风格,纯白与冷灰基底,带有极其剔透的毛玻璃 (\`backdrop-filter\`) 层级架构,追求极致的干净。
|
||||
|
||||
*注:你可以随时点击侧栏右下角的 🌙 / ☀️ 切换对应主题下的深色/浅色模式。*
|
||||
|
||||
### 排版引擎调节
|
||||
在顶部工具栏点击 **Aa▾** 按钮:
|
||||
- **字号**:滑块无极调节 14px ~ 24px(默认 17px)
|
||||
- **行距**:滑块无极调节 1.4 ~ 2.6(默认 1.9)
|
||||
- **恢复默认**:一键重置排版参数
|
||||
|
||||
### 字体选择
|
||||
| 字体 | 适合场景 |
|
||||
|------|---------|
|
||||
| 默认(宋体) | 正文写作 |
|
||||
| 黑体 | 标题 |
|
||||
| 楷体 | 古风文 |
|
||||
| 仿宋 | 公文风格 |
|
||||
| Serif | 英文衬线体 |
|
||||
| Monospace | 等宽字体 |
|
||||
|
||||
### 排版建议
|
||||
| 场景 | 推荐设置 |
|
||||
|------|---------|
|
||||
| 网文写作 | 字号 16-17px,行距 1.8-2.0 |
|
||||
| 纯文学 | 字号 17-18px,行距 2.0-2.2 |
|
||||
| 校对审稿 | 字号 14-15px,行距 1.6 |
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: 'ℹ️ 关于',
|
||||
content: `
|
||||
## 关于 Author
|
||||
|
||||
**Author** 是一款 AI 驱动的小说创作工具,旨在为网文作者和文学创作者提供专业、高效的写作体验。
|
||||
|
||||
### 核心特色
|
||||
- 🤖 **AI 智能写作** — 内联续写 + 聊天讨论,双模式辅助创作
|
||||
- 📖 **上下文感知** — AI 自动参考角色设定、世界观、前文内容
|
||||
- 🎭 **设定集管理** — 树形结构管理角色、世界观、大纲、写作规则
|
||||
- ✦ **Ghost Text** — 类似 Cursor 的幽灵文字预览,接受/拒绝一键操作
|
||||
- 📄 **分页视图** — 类 Word/Google Docs 的白纸分页排版
|
||||
- 🌙 **深色模式** — 护眼的暗色主题
|
||||
- 💾 **本地优先** — 所有数据存储在本地,隐私安全
|
||||
- 📦 **存档/读档** — 一键导出/导入完整项目
|
||||
|
||||
### 数据安全
|
||||
- 所有创作内容仅存储在你的浏览器本地
|
||||
- API Key 不经过任何第三方服务器
|
||||
- 支持一键导出全部数据
|
||||
|
||||
### 技术栈
|
||||
Next.js + Tiptap 编辑器 + AI API(OpenAI 兼容 / Gemini)
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function HelpPanel({ open, onClose }) {
|
||||
const [activeSection, setActiveSection] = useState('quickstart');
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const currentSection = HELP_SECTIONS.find(s => s.id === activeSection);
|
||||
|
||||
return (
|
||||
<div className="help-overlay" onClick={onClose}>
|
||||
<div className="help-panel" onClick={e => e.stopPropagation()}>
|
||||
{/* 顶栏 */}
|
||||
<div className="help-header">
|
||||
<h2>{t('help.title')}</h2>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
className="tour-btn ghost"
|
||||
style={{ padding: '6px 12px', fontSize: '13px' }}
|
||||
onClick={() => {
|
||||
localStorage.removeItem('author-onboarding-done');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{t('help.btnRetour')}
|
||||
</button>
|
||||
<button className="help-close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="help-body">
|
||||
{/* 左侧导航 */}
|
||||
<nav className="help-nav">
|
||||
{HELP_SECTIONS.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`help-nav-item ${activeSection === section.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<div className="help-content">
|
||||
<div
|
||||
className="help-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: renderSimpleMarkdown(currentSection?.content || '') }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简单的 Markdown → HTML 转换(仅用于帮助文档静态内容)
|
||||
function renderSimpleMarkdown(md) {
|
||||
let html = md.trim();
|
||||
|
||||
// 表格
|
||||
html = html.replace(/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)*)/gm, (_, header, sep, body) => {
|
||||
const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
|
||||
const aligns = sep.split('|').filter(c => c.trim()).map(c => {
|
||||
if (c.trim().startsWith(':') && c.trim().endsWith(':')) return 'center';
|
||||
if (c.trim().endsWith(':')) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
const rows = body.trim().split('\n').map(row => {
|
||||
const tds = row.split('|').filter(c => c.trim()).map((c, i) =>
|
||||
`<td style="text-align:${aligns[i] || 'left'}">${c.trim()}</td>`
|
||||
).join('');
|
||||
return `<tr>${tds}</tr>`;
|
||||
}).join('');
|
||||
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
});
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
|
||||
// Bold, italic, strikethrough, code
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Blockquote
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
// List items
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Paragraphs (lines that don't start with < )
|
||||
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>');
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p>\s*<\/p>/g, '');
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { InputRule } from '@tiptap/core';
|
||||
import katex from 'katex';
|
||||
|
||||
// ==================== 公式编辑弹窗(居中大框) ====================
|
||||
export function openMathEditor(currentLatex, onSave) {
|
||||
// 创建遮罩
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'math-editor-overlay';
|
||||
|
||||
// 创建弹窗
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'math-editor-dialog';
|
||||
dialog.innerHTML = `
|
||||
<div class="math-editor-header">
|
||||
<span class="math-editor-title">📐 编辑公式</span>
|
||||
<button class="math-editor-close">✕</button>
|
||||
</div>
|
||||
<div class="math-editor-preview"></div>
|
||||
<textarea class="math-editor-input" placeholder="输入 LaTeX 公式,如 E = mc^2" spellcheck="false"></textarea>
|
||||
<div class="math-editor-footer">
|
||||
<span class="math-editor-hint">实时预览 · Enter 换行 · Ctrl+Enter 确认</span>
|
||||
<div class="math-editor-actions">
|
||||
<button class="math-editor-cancel">取消</button>
|
||||
<button class="math-editor-save">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const textarea = dialog.querySelector('.math-editor-input');
|
||||
const preview = dialog.querySelector('.math-editor-preview');
|
||||
const closeBtn = dialog.querySelector('.math-editor-close');
|
||||
const cancelBtn = dialog.querySelector('.math-editor-cancel');
|
||||
const saveBtn = dialog.querySelector('.math-editor-save');
|
||||
|
||||
textarea.value = currentLatex || '';
|
||||
|
||||
// 实时预览
|
||||
const updatePreview = () => {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) {
|
||||
preview.innerHTML = '<span class="math-editor-placeholder">此处显示公式预览…</span>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preview.innerHTML = katex.renderToString(val, {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
});
|
||||
} catch (e) {
|
||||
preview.innerHTML = `<span class="math-editor-error">${e.message}</span>`;
|
||||
}
|
||||
};
|
||||
updatePreview();
|
||||
textarea.addEventListener('input', updatePreview);
|
||||
|
||||
// 关闭
|
||||
const close = () => {
|
||||
overlay.classList.add('closing');
|
||||
setTimeout(() => overlay.remove(), 150);
|
||||
};
|
||||
|
||||
// 保存
|
||||
const save = () => {
|
||||
const val = textarea.value.trim();
|
||||
if (val) onSave(val);
|
||||
close();
|
||||
};
|
||||
|
||||
closeBtn.addEventListener('click', close);
|
||||
cancelBtn.addEventListener('click', close);
|
||||
saveBtn.addEventListener('click', save);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
// 键盘
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { close(); e.preventDefault(); }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { save(); e.preventDefault(); }
|
||||
});
|
||||
|
||||
// 聚焦
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 行内公式节点 $...$ ====================
|
||||
export const MathInline = Node.create({
|
||||
name: 'mathInline',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
latex: { default: '' },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'span[data-math-inline]',
|
||||
getAttrs: (el) => ({ latex: el.getAttribute('data-latex') || '' }),
|
||||
}];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return ['span', {
|
||||
'data-math-inline': '',
|
||||
'data-latex': node.attrs.latex,
|
||||
class: 'math-inline',
|
||||
contenteditable: 'false',
|
||||
}];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const dom = document.createElement('span');
|
||||
dom.classList.add('math-inline');
|
||||
dom.contentEditable = 'false';
|
||||
|
||||
const render = (latex) => {
|
||||
try {
|
||||
dom.innerHTML = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode: false,
|
||||
});
|
||||
} catch {
|
||||
dom.textContent = latex;
|
||||
}
|
||||
};
|
||||
render(node.attrs.latex);
|
||||
|
||||
// 双击打开编辑弹窗
|
||||
dom.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMathEditor(node.attrs.latex, (newLatex) => {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.chain().focus()
|
||||
.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, { latex: newLatex });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dom,
|
||||
update(updatedNode) {
|
||||
if (updatedNode.type.name !== 'mathInline') return false;
|
||||
render(updatedNode.attrs.latex);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /(?<!\$)\$([^$\n]+)\$$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
const latex = match[1];
|
||||
if (!latex.trim()) return null;
|
||||
const { tr } = state;
|
||||
tr.replaceWith(
|
||||
range.from,
|
||||
range.to,
|
||||
this.type.create({ latex })
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// ==================== 块级公式节点 $$...$$ ====================
|
||||
export const MathBlock = Node.create({
|
||||
name: 'mathBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
latex: { default: '' },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'div[data-math-block]',
|
||||
getAttrs: (el) => ({ latex: el.getAttribute('data-latex') || '' }),
|
||||
}];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return ['div', {
|
||||
'data-math-block': '',
|
||||
'data-latex': node.attrs.latex,
|
||||
class: 'math-block',
|
||||
contenteditable: 'false',
|
||||
}];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add('math-block');
|
||||
dom.contentEditable = 'false';
|
||||
|
||||
const render = (latex) => {
|
||||
try {
|
||||
dom.innerHTML = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
});
|
||||
} catch {
|
||||
dom.textContent = latex;
|
||||
}
|
||||
};
|
||||
render(node.attrs.latex);
|
||||
|
||||
// 双击打开编辑弹窗
|
||||
dom.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMathEditor(node.attrs.latex, (newLatex) => {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.chain().focus()
|
||||
.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, { latex: newLatex });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dom,
|
||||
update(updatedNode) {
|
||||
if (updatedNode.type.name !== 'mathBlock') return false;
|
||||
render(updatedNode.attrs.latex);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /\$\$([^$]+)\$\$$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
const latex = match[1];
|
||||
if (!latex.trim()) return null;
|
||||
const { tr } = state;
|
||||
tr.replaceWith(
|
||||
range.from,
|
||||
range.to,
|
||||
this.type.create({ latex })
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
const PAGE_HEIGHT = 1056;
|
||||
const PAGE_GAP = 24;
|
||||
|
||||
const pluginKey = new PluginKey('pageBreakSpacer');
|
||||
|
||||
export const PageBreakExtension = Extension.create({
|
||||
name: 'pageBreakSpacer',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: pluginKey,
|
||||
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, decoSet) {
|
||||
const meta = tr.getMeta(pluginKey);
|
||||
if (meta) {
|
||||
if (meta.clear) return DecorationSet.empty;
|
||||
if (meta.apply) return meta.decoSet;
|
||||
}
|
||||
// 文档变更时,不再立刻清除装饰器,而是利用 Tiptap 的 Mapping 机制让占位符随着文本自动漂移。
|
||||
// 这样打字时旧的断头台还在,高度不变,绝对不会导致内容突然上缩。
|
||||
if (tr.docChanged) {
|
||||
return decoSet.map(tr.mapping, tr.doc);
|
||||
}
|
||||
return decoSet;
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return pluginKey.getState(state) || DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
|
||||
view(editorView) {
|
||||
let updateTimer = null;
|
||||
let isProcessing = false;
|
||||
|
||||
const performPagination = () => {
|
||||
if (isProcessing) return;
|
||||
isProcessing = true;
|
||||
|
||||
const dom = editorView.dom;
|
||||
if (!dom || !dom.isConnected) {
|
||||
isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 锁定容器高度:防止清空占位符瞬间因高度收缩引发滚动条狂跳
|
||||
// 注意:不再保存/恢复 scrollTop,避免与用户滚动操作冲突
|
||||
const oldMinHeight = dom.style.minHeight;
|
||||
dom.style.minHeight = `${dom.scrollHeight}px`;
|
||||
|
||||
// 2. 瞬间剥离旧排版
|
||||
const oldState = pluginKey.getState(editorView.state);
|
||||
let cleared = false;
|
||||
if (oldState && oldState !== DecorationSet.empty) {
|
||||
const trClear = editorView.state.tr;
|
||||
trClear.setMeta(pluginKey, { clear: true });
|
||||
trClear.setMeta('addToHistory', false);
|
||||
editorView.dispatch(trClear);
|
||||
cleared = true;
|
||||
}
|
||||
|
||||
// 【魔法核心】强制浏览器在当前帧立即进行同步布局重排 (Reflow),暴露天然文档流
|
||||
void dom.offsetHeight;
|
||||
|
||||
// 3. 进入"绝对坐标系"精准测量
|
||||
const doc = editorView.state.doc;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
const domTop = domRect.top;
|
||||
|
||||
// 自动侦测当前的真实 Padding (不再需要通过 React 传参)
|
||||
const computedStyle = window.getComputedStyle(dom);
|
||||
const marginY = parseFloat(computedStyle.paddingTop) || 96;
|
||||
|
||||
let accumulatedSpacers = 0;
|
||||
let currentPage = 0;
|
||||
const spacerData = [];
|
||||
|
||||
// 纸张可用区域计算公式
|
||||
const getUsableBottom = (page) => page * (PAGE_HEIGHT + PAGE_GAP) + PAGE_HEIGHT - marginY;
|
||||
const getUsableTop = (page) => page * (PAGE_HEIGHT + PAGE_GAP) + marginY;
|
||||
|
||||
// 收集顶级内容块
|
||||
const blocks = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isTextblock || (node.isBlock && node.isAtom)) {
|
||||
blocks.push({ node, pos });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const { node, pos } of blocks) {
|
||||
const domNode = editorView.nodeDOM(pos);
|
||||
if (!domNode || domNode.nodeType !== 1) continue;
|
||||
|
||||
const rect = domNode.getBoundingClientRect();
|
||||
if (rect.height === 0) continue;
|
||||
|
||||
const naturalTop = rect.top - domTop;
|
||||
const naturalBottom = rect.bottom - domTop;
|
||||
|
||||
let actualTop = naturalTop + accumulatedSpacers;
|
||||
let actualBottom = naturalBottom + accumulatedSpacers;
|
||||
|
||||
// 如果段落已经完全位于下一页的内容区域内,直接跳过前面的页码
|
||||
// 注意:用 getUsableTop(next) 而非 getUsableBottom(current),
|
||||
// 避免跳过"边距区"或"间隙区"的段落(它们仍需由跨页逻辑推送)
|
||||
while (actualTop >= getUsableTop(currentPage + 1)) {
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
// 触发跨页逻辑!
|
||||
while (actualBottom > getUsableBottom(currentPage)) {
|
||||
const limitY = getUsableBottom(currentPage);
|
||||
const nextTopY = getUsableTop(currentPage + 1);
|
||||
|
||||
// 无法拆分的原子块(如大图片、公式),整体推向下一页
|
||||
if (node.isAtom || !node.isTextblock) {
|
||||
const spacerHeight = nextTopY - actualTop;
|
||||
if (spacerHeight > 0) {
|
||||
spacerData.push({ pos, height: spacerHeight, isBlock: true });
|
||||
accumulatedSpacers += spacerHeight;
|
||||
actualTop += spacerHeight;
|
||||
actualBottom += spacerHeight;
|
||||
}
|
||||
currentPage++;
|
||||
} else {
|
||||
// 常规文字段落,开始寻找破界点
|
||||
const startPos = pos + 1;
|
||||
const endPos = pos + node.nodeSize - 1;
|
||||
|
||||
if (startPos > endPos) {
|
||||
currentPage++; continue;
|
||||
}
|
||||
|
||||
// 【二分查找】极速锁定越过边界的那一个字符
|
||||
let low = startPos;
|
||||
let high = endPos;
|
||||
let crossPos = null;
|
||||
|
||||
while (low <= high) {
|
||||
let mid = Math.floor((low + high) / 2);
|
||||
let coords;
|
||||
try { coords = editorView.coordsAtPos(mid); } catch (e) { }
|
||||
|
||||
// 容错处理:某些特殊字符可能拿不到坐标,就近偏移
|
||||
if (!coords) {
|
||||
let fallback = null;
|
||||
for (let step = 1; step < 5; step++) {
|
||||
if (mid + step <= high) { try { fallback = editorView.coordsAtPos(mid + step); if (fallback) { mid = mid + step; coords = fallback; break; } } catch (e) { } }
|
||||
if (mid - step >= low) { try { fallback = editorView.coordsAtPos(mid - step); if (fallback) { mid = mid - step; coords = fallback; break; } } catch (e) { } }
|
||||
}
|
||||
if (!coords) { low = mid + 1; continue; }
|
||||
}
|
||||
|
||||
const charActualBottom = (coords.bottom - domTop) + accumulatedSpacers;
|
||||
if (charActualBottom > limitY) {
|
||||
crossPos = mid;
|
||||
high = mid - 1;
|
||||
} else {
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 若没找到(通常是段落的 Margin 溢出),忽略
|
||||
if (crossPos === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 【精确回溯】找到了越界字,往回倒退,找出这行字的行首
|
||||
let lineStartPos = crossPos;
|
||||
let currentLineTop = null;
|
||||
try {
|
||||
let crossCoords = editorView.coordsAtPos(crossPos);
|
||||
if (crossCoords) {
|
||||
currentLineTop = crossCoords.top - domTop;
|
||||
for (let p = crossPos - 1; p >= startPos; p--) {
|
||||
let c = editorView.coordsAtPos(p);
|
||||
if (!c) continue;
|
||||
let cTop = c.top - domTop;
|
||||
// Y轴剧烈跳跃说明退到了上一行
|
||||
if (cTop < currentLineTop - 4) break;
|
||||
lineStartPos = p;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
let lineCoords;
|
||||
try { lineCoords = editorView.coordsAtPos(lineStartPos); } catch (e) { }
|
||||
let lineActualTop = lineCoords ? (lineCoords.top - domTop) + accumulatedSpacers : actualTop;
|
||||
|
||||
let spacerHeight = nextTopY - lineActualTop;
|
||||
|
||||
if (spacerHeight > 0) {
|
||||
// 判断:段落整体已经超出当前页可用区域时,整块推走
|
||||
// 否则使用行内断裂(即使越界行是第一行,前面仍有段间空白可利用)
|
||||
if (actualTop >= limitY) {
|
||||
// 段落完全在边界之后,整体推到下一页
|
||||
spacerData.push({ pos, height: nextTopY - actualTop, isBlock: true });
|
||||
accumulatedSpacers += (nextTopY - actualTop);
|
||||
actualBottom += (nextTopY - actualTop);
|
||||
actualTop = nextTopY;
|
||||
} else {
|
||||
// 段落跨越边界,在越界行首处插入行内断裂
|
||||
spacerData.push({ pos: lineStartPos, height: spacerHeight, isBlock: false });
|
||||
accumulatedSpacers += spacerHeight;
|
||||
actualBottom += spacerHeight;
|
||||
actualTop += spacerHeight;
|
||||
}
|
||||
}
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 重组排版,生成全新的物理占位 Decoration Widget
|
||||
const decorations = spacerData.map(data => {
|
||||
if (data.isBlock) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'page-break-spacer-block';
|
||||
spacer.style.height = `${data.height}px`;
|
||||
spacer.style.width = '100%';
|
||||
spacer.style.clear = 'both';
|
||||
spacer.setAttribute('data-pb-spacer', '1');
|
||||
return Decoration.widget(data.pos, spacer, { key: `pb-${data.pos}`, side: -1, ignoreSelection: true });
|
||||
} else {
|
||||
// 隐形断头台:利用 inline-block 占满 100% 宽度,直接把后半行文字生生挤到下一页
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'page-break-spacer-inline';
|
||||
spacer.style.display = 'block';
|
||||
spacer.style.width = '100%';
|
||||
spacer.style.height = `${data.height}px`;
|
||||
spacer.style.lineHeight = '0';
|
||||
spacer.style.fontSize = '0';
|
||||
spacer.style.verticalAlign = 'top';
|
||||
spacer.style.userSelect = 'none'; // 忽略光标焦点
|
||||
spacer.style.pointerEvents = 'none';
|
||||
spacer.setAttribute('data-pb-spacer', '1');
|
||||
return Decoration.widget(data.pos, spacer, { key: `pb-${data.pos}`, side: -1, ignoreSelection: true });
|
||||
}
|
||||
});
|
||||
|
||||
const decoSet = decorations.length > 0 ? DecorationSet.create(doc, decorations) : DecorationSet.empty;
|
||||
|
||||
// 5. 无缝推送回视图
|
||||
if (decorations.length > 0 || cleared) {
|
||||
const trApply = editorView.state.tr;
|
||||
trApply.setMeta(pluginKey, { apply: true, decoSet });
|
||||
trApply.setMeta('addToHistory', false);
|
||||
editorView.dispatch(trApply);
|
||||
}
|
||||
|
||||
// 解除 DOM 高度锁定
|
||||
dom.style.minHeight = oldMinHeight;
|
||||
isProcessing = false;
|
||||
};
|
||||
|
||||
let initTimer = null;
|
||||
let debounceTimer = null;
|
||||
|
||||
// 立即调度(初始化 & 窗口 resize)
|
||||
const scheduleImmediate = () => {
|
||||
if (updateTimer) cancelAnimationFrame(updateTimer);
|
||||
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||
updateTimer = requestAnimationFrame(performPagination);
|
||||
};
|
||||
|
||||
// 延迟调度(文档变更触发,加长防抖避免打字中途重排)
|
||||
// 只要在连续打字,就绝不重排页面;停手 500ms 后再安静地后台重新计算分页
|
||||
const scheduleDebouncedForDoc = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
if (updateTimer) cancelAnimationFrame(updateTimer);
|
||||
updateTimer = requestAnimationFrame(performPagination);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 初始化延迟——等 Editor DOM 完全挂载后首次计算
|
||||
initTimer = setTimeout(scheduleImmediate, 200);
|
||||
window.addEventListener('resize', scheduleImmediate);
|
||||
|
||||
return {
|
||||
update(view, prevState) {
|
||||
// 文档内容变化引发重算(防抖)
|
||||
if (prevState.doc !== view.state.doc) scheduleDebouncedForDoc();
|
||||
},
|
||||
destroy() {
|
||||
if (updateTimer) cancelAnimationFrame(updateTimer);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (initTimer) clearTimeout(initTimer);
|
||||
window.removeEventListener('resize', scheduleImmediate);
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
|
||||
// ==================== 分类配色 ====================
|
||||
const CATEGORY_COLORS = {
|
||||
character: { color: 'var(--cat-character)', bg: 'var(--cat-character-bg)' },
|
||||
location: { color: 'var(--cat-location)', bg: 'var(--cat-location-bg)' },
|
||||
world: { color: 'var(--cat-world)', bg: 'var(--cat-world-bg)' },
|
||||
object: { color: 'var(--cat-object)', bg: 'var(--cat-object-bg)' },
|
||||
plot: { color: 'var(--cat-plot)', bg: 'var(--cat-plot-bg)' },
|
||||
rules: { color: 'var(--cat-rules)', bg: 'var(--cat-rules-bg)' },
|
||||
custom: { color: 'var(--cat-custom)', bg: 'var(--cat-custom-bg)' },
|
||||
};
|
||||
|
||||
// ==================== 通用字段组件 ====================
|
||||
|
||||
function TextField({ label, value, onChange, placeholder, multiline = false, rows = 3, aiBtn = false }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)' }}>{label}</label>
|
||||
{aiBtn && (
|
||||
<button className="field-ai-btn" title={t('settingsEditor.aiFill')}>✦</button>
|
||||
)}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)', background: 'var(--bg-primary)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontFamily: 'var(--font-ui)', resize: 'vertical', outline: 'none',
|
||||
lineHeight: 1.6, transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)', background: 'var(--bg-primary)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontFamily: 'var(--font-ui)', outline: 'none', transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroup({ label, value, options, onChange }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>{label}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 16, fontSize: 12, border: '1px solid var(--border-light)',
|
||||
background: value === opt.value ? 'var(--accent)' : 'transparent',
|
||||
color: value === opt.value ? 'var(--text-inverse)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer', transition: 'all 0.15s', fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 字段分组折叠 ====================
|
||||
|
||||
function FieldGroup({ title, icon, children, defaultCollapsed = false }) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
return (
|
||||
<div className={`field-group ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="field-group-header" onClick={() => setCollapsed(!collapsed)}>
|
||||
<h4>{icon && <span>{icon}</span>}{title}</h4>
|
||||
<span className="field-group-chevron">▼</span>
|
||||
</div>
|
||||
<div className="field-group-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== AI 生成的额外字段 ====================
|
||||
|
||||
function ExtraFieldsSection({ content, knownFields, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const extraKeys = Object.keys(content || {}).filter(k => !knownFields.includes(k) && content[k]);
|
||||
if (extraKeys.length === 0) return null;
|
||||
return (
|
||||
<FieldGroup title={t('settingsEditor.aiExtraFields')} icon="✨" defaultCollapsed>
|
||||
{extraKeys.map(k => (
|
||||
<TextField
|
||||
key={k}
|
||||
label={k}
|
||||
value={content[k]}
|
||||
onChange={v => onUpdate(k, v)}
|
||||
placeholder=""
|
||||
multiline
|
||||
/>
|
||||
))}
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 角色卡片预览 ====================
|
||||
|
||||
function CharacterCardPreview({ name, content }) {
|
||||
const { t } = useI18n();
|
||||
const c = content || {};
|
||||
const catColor = CATEGORY_COLORS.character;
|
||||
const roleLabels = {
|
||||
protagonist: t('settingsEditor.roles.protagonist'),
|
||||
antagonist: t('settingsEditor.roles.antagonist'),
|
||||
supporting: t('settingsEditor.roles.supporting'),
|
||||
minor: t('settingsEditor.roles.minor')
|
||||
};
|
||||
const roleLabel = roleLabels[c.role] || c.role || t('settingsEditor.charRole');
|
||||
|
||||
// 头像文字:取名字第一个字
|
||||
const avatarChar = (name || t('settingsEditor.unnamedChar'))[0];
|
||||
|
||||
return (
|
||||
<div className="character-card-preview" style={{ background: catColor.bg, color: catColor.color, border: `1px solid ${catColor.color}20` }}>
|
||||
<div className="character-card-header">
|
||||
<div className="character-card-avatar" style={{ background: `linear-gradient(135deg, ${catColor.color}, ${catColor.color}cc)` }}>
|
||||
{avatarChar}
|
||||
</div>
|
||||
<div className="character-card-info">
|
||||
<div className="character-card-name">{name || t('settingsEditor.unnamedChar')}</div>
|
||||
<span className="character-card-role" style={{ background: `${catColor.color}18`, color: catColor.color }}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="character-card-quickinfo">
|
||||
{c.gender && <span className="info-item"><span className="info-label">{t('settingsEditor.infoGender')}</span>{c.gender}</span>}
|
||||
{c.age && <span className="info-item"><span className="info-label">{t('settingsEditor.infoAge')}</span>{c.age}</span>}
|
||||
{c.personality && <span className="info-item"><span className="info-label">{t('settingsEditor.infoPersonality')}</span>{c.personality.length > 20 ? c.personality.slice(0, 20) + '…' : c.personality}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 各分类编辑器 ====================
|
||||
|
||||
function CharacterEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CharacterCardPreview name={node.name} content={content} />
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
|
||||
<ButtonGroup label={t('settingsEditor.charRole')} value={content.role} onChange={v => update('role', v)}
|
||||
options={[
|
||||
{ value: 'protagonist', label: t('settingsEditor.roles.proLabel') },
|
||||
{ value: 'antagonist', label: t('settingsEditor.roles.antLabel') },
|
||||
{ value: 'supporting', label: t('settingsEditor.roles.supLabel') },
|
||||
{ value: 'minor', label: t('settingsEditor.roles.minLabel') },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<TextField label={t('settingsEditor.infoGender')} value={content.gender} onChange={v => update('gender', v)} placeholder={t('settingsEditor.charGenderPlaceholder')} />
|
||||
<TextField label={t('settingsEditor.infoAge')} value={content.age} onChange={v => update('age', v)} placeholder={t('settingsEditor.charAgePlaceholder')} />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabAppearance')} icon="✨">
|
||||
<TextField label={t('settingsEditor.charAppearance')} value={content.appearance} onChange={v => update('appearance', v)} placeholder={t('settingsEditor.charAppearancePlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.charPersonality')} value={content.personality} onChange={v => update('personality', v)} placeholder={t('settingsEditor.charPersonalityPlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.charSpeechStyle')} value={content.speechStyle} onChange={v => update('speechStyle', v)} placeholder={t('settingsEditor.charSpeechStylePlaceholder')} multiline aiBtn />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabBackground')} icon="📖" defaultCollapsed>
|
||||
<TextField label={t('settingsEditor.charBackground')} value={content.background} onChange={v => update('background', v)} placeholder={t('settingsEditor.charBackgroundPlaceholder')} multiline rows={4} aiBtn />
|
||||
<TextField label={t('settingsEditor.charMotivation')} value={content.motivation} onChange={v => update('motivation', v)} placeholder={t('settingsEditor.charMotivationPlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.charArc')} value={content.arc} onChange={v => update('arc', v)} placeholder={t('settingsEditor.charArcPlaceholder')} multiline aiBtn />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabSkills')} icon="⚔️" defaultCollapsed>
|
||||
<TextField label={t('settingsEditor.charSkills')} value={content.skills} onChange={v => update('skills', v)} placeholder={t('settingsEditor.charSkillsPlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.charRelationships')} value={content.relationships} onChange={v => update('relationships', v)} placeholder={t('settingsEditor.charRelationshipsPlaceholder')} multiline aiBtn />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabNotes')} icon="📝" defaultCollapsed>
|
||||
<TextField label={t('settingsEditor.charNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.charNotesPlaceholder')} multiline />
|
||||
</FieldGroup>
|
||||
|
||||
<ExtraFieldsSection content={content} knownFields={['role', 'age', 'gender', 'appearance', 'personality', 'speechStyle', 'background', 'motivation', 'arc', 'skills', 'relationships', 'notes']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
|
||||
<TextField label={t('settingsEditor.locDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.locDescriptionPlaceholder')} multiline rows={4} aiBtn />
|
||||
<TextField label={t('settingsEditor.locSlugline')} value={content.slugline} onChange={v => update('slugline', v)} placeholder={t('settingsEditor.locSluglinePlaceholder')} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabSensory')} icon="👁">
|
||||
<TextField label={t('settingsEditor.locVisual')} value={content.sensoryVisual} onChange={v => update('sensoryVisual', v)} placeholder={t('settingsEditor.locVisualPlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.locAudio')} value={content.sensoryAudio} onChange={v => update('sensoryAudio', v)} placeholder={t('settingsEditor.locAudioPlaceholder')} multiline aiBtn />
|
||||
<TextField label={t('settingsEditor.locSmell')} value={content.sensorySmell} onChange={v => update('sensorySmell', v)} placeholder={t('settingsEditor.locSmellPlaceholder')} multiline aiBtn />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabMood')} icon="🌙" defaultCollapsed>
|
||||
<TextField label={t('settingsEditor.locMood')} value={content.mood} onChange={v => update('mood', v)} placeholder={t('settingsEditor.locMoodPlaceholder')} />
|
||||
<ButtonGroup label={t('settingsEditor.locDangerLevel')} value={content.dangerLevel} onChange={v => update('dangerLevel', v)}
|
||||
options={[
|
||||
{ value: 'safe', label: t('settingsEditor.dangerSafe') },
|
||||
{ value: 'caution', label: t('settingsEditor.dangerCaution') },
|
||||
{ value: 'danger', label: t('settingsEditor.dangerHigh') },
|
||||
]}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<ExtraFieldsSection content={content} knownFields={['description', 'slugline', 'sensoryVisual', 'sensoryAudio', 'sensorySmell', 'mood', 'dangerLevel']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
|
||||
<TextField label={t('settingsEditor.objDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.objDescriptionPlaceholder')} multiline rows={4} aiBtn />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<TextField label={t('settingsEditor.objType')} value={content.objectType} onChange={v => update('objectType', v)} placeholder={t('settingsEditor.objTypePlaceholder')} />
|
||||
<TextField label={t('settingsEditor.objRank')} value={content.rank} onChange={v => update('rank', v)} placeholder={t('settingsEditor.objRankPlaceholder')} />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title={t('settingsEditor.tabStats')} icon="📊" defaultCollapsed>
|
||||
<TextField label={t('settingsEditor.objHolder')} value={content.currentHolder} onChange={v => update('currentHolder', v)} placeholder={t('settingsEditor.objHolderPlaceholder')} />
|
||||
<TextField label={t('settingsEditor.objStats')} value={content.numericStats} onChange={v => update('numericStats', v)} placeholder={t('settingsEditor.objStatsPlaceholder')} multiline />
|
||||
<TextField label={t('settingsEditor.objSymbolism')} value={content.symbolism} onChange={v => update('symbolism', v)} placeholder={t('settingsEditor.objSymbolismPlaceholder')} multiline aiBtn />
|
||||
</FieldGroup>
|
||||
|
||||
<ExtraFieldsSection content={content} knownFields={['description', 'objectType', 'rank', 'currentHolder', 'numericStats', 'symbolism']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField label={t('settingsEditor.worldDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.worldDescriptionPlaceholder')} multiline rows={6} aiBtn />
|
||||
<TextField label={t('settingsEditor.worldNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.worldNotesPlaceholder')} multiline />
|
||||
<ExtraFieldsSection content={content} knownFields={['description', 'notes']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlotEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ButtonGroup label={t('settingsEditor.plotStatus')} value={content.status} onChange={v => update('status', v)}
|
||||
options={[
|
||||
{ value: 'planned', label: t('settingsEditor.statusPlanned') },
|
||||
{ value: 'writing', label: t('settingsEditor.statusWriting') },
|
||||
{ value: 'done', label: t('settingsEditor.statusDone') },
|
||||
]}
|
||||
/>
|
||||
<TextField label={t('settingsEditor.plotDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.plotDescriptionPlaceholder')} multiline rows={6} aiBtn />
|
||||
<TextField label={t('settingsEditor.plotNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.plotNotesPlaceholder')} multiline />
|
||||
<ExtraFieldsSection content={content} knownFields={['status', 'description', 'notes']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField label={t('settingsEditor.rulesDescription')} value={content.description} onChange={v => update('description', v)}
|
||||
placeholder={t('settingsEditor.rulesDescriptionPlaceholder')} multiline rows={6} />
|
||||
<ExtraFieldsSection content={content} knownFields={['description']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericEditor({ node, onUpdate }) {
|
||||
const { t } = useI18n();
|
||||
const content = node.content || {};
|
||||
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField label={t('settingsEditor.genericDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.genericDescriptionPlaceholder')} multiline rows={6} />
|
||||
<TextField label={t('settingsEditor.genericNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.genericNotesPlaceholder')} multiline />
|
||||
<ExtraFieldsSection content={content} knownFields={['description', 'notes']} onUpdate={update} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 面包屑导航 ====================
|
||||
|
||||
function Breadcrumb({ node, allNodes, onSelect }) {
|
||||
const path = [];
|
||||
let current = node;
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? allNodes.find(n => n.id === current.parentId) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{path.map((p, i) => (
|
||||
<span key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{i > 0 && <span style={{ opacity: 0.5 }}>/</span>}
|
||||
<span
|
||||
onClick={() => onSelect(p.id)}
|
||||
style={{ cursor: 'pointer', color: i === path.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)', fontWeight: i === path.length - 1 ? 600 : 400, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.target.style.color = 'var(--accent)'}
|
||||
onMouseLeave={e => e.target.style.color = i === path.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)'}
|
||||
>
|
||||
{p.icon} {p.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 文件夹信息 ====================
|
||||
|
||||
function FolderInfo({ node, nodes, onAdd }) {
|
||||
const { t } = useI18n();
|
||||
const catColor = CATEGORY_COLORS[node.category] || CATEGORY_COLORS.custom;
|
||||
const children = nodes.filter(n => n.parentId === node.id);
|
||||
const folders = children.filter(n => n.type === 'folder');
|
||||
const items = children.filter(n => n.type === 'item');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: 24, borderRadius: 'var(--radius-md)', background: catColor.bg,
|
||||
border: `1px solid ${catColor.color}20`, marginBottom: 20, textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>{node.icon || '📁'}</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, marginBottom: 4, color: 'var(--text-primary)' }}>{node.name}</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{folders.length > 0 && `${folders.length} 个子文件夹 · `}
|
||||
{items.length} 个设定项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children.length === 0 && (
|
||||
<div className="settings-empty-state">
|
||||
<div className="empty-icon">📝</div>
|
||||
<h3>{t('settingsEditor.emptyTitle')}</h3>
|
||||
<p>{t('settingsEditor.emptyDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="tree-ai-generate-btn"
|
||||
style={{ width: '100%', justifyContent: 'center', marginTop: 8 }}
|
||||
onClick={() => onAdd(node.id, node.category)}
|
||||
>
|
||||
{t('settingsEditor.addBtn')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 空状态 ====================
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="settings-empty-state">
|
||||
<div className="empty-icon">🎯</div>
|
||||
<h3>{t('settingsEditor.selectTitle')}</h3>
|
||||
<p>{t('settingsEditor.selectDesc')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
export default function SettingsItemEditor({ selectedNode, allNodes, onUpdate, onSelect, onAdd }) {
|
||||
if (!selectedNode) return <EmptyState />;
|
||||
|
||||
// 文件夹 → 显示文件夹信息
|
||||
if (selectedNode.type === 'folder' || selectedNode.type === 'special') {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Breadcrumb node={selectedNode} allNodes={allNodes} onSelect={onSelect} />
|
||||
<FolderInfo node={selectedNode} nodes={allNodes} onAdd={onAdd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// item → 显示对应编辑器
|
||||
const editorMap = {
|
||||
character: CharacterEditor,
|
||||
location: LocationEditor,
|
||||
object: ObjectEditor,
|
||||
world: WorldEditor,
|
||||
plot: PlotEditor,
|
||||
rules: RulesEditor,
|
||||
custom: GenericEditor,
|
||||
};
|
||||
const EditorComponent = editorMap[selectedNode.category] || GenericEditor;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Breadcrumb node={selectedNode} allNodes={allNodes} onSelect={onSelect} />
|
||||
<EditorComponent node={selectedNode} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import {
|
||||
getProjectSettings,
|
||||
saveProjectSettings,
|
||||
getSettingsNodes,
|
||||
addSettingsNode,
|
||||
updateSettingsNode,
|
||||
deleteSettingsNode,
|
||||
WRITING_MODES,
|
||||
getWritingMode,
|
||||
setWritingMode,
|
||||
createWorkNode,
|
||||
saveSettingsNodes,
|
||||
getActiveWorkId,
|
||||
setActiveWorkId,
|
||||
getAllWorks,
|
||||
} from '../lib/settings';
|
||||
import SettingsTree from './SettingsTree';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
import SettingsItemEditor from './SettingsItemEditor';
|
||||
|
||||
const CAT_STYLES = {
|
||||
work: { color: 'var(--cat-work)', bg: 'var(--cat-work-bg)', icon: '📕' },
|
||||
character: { color: 'var(--cat-character)', bg: 'var(--cat-character-bg)', icon: '👤' },
|
||||
location: { color: 'var(--cat-location)', bg: 'var(--cat-location-bg)', icon: '🗺️' },
|
||||
world: { color: 'var(--cat-world)', bg: 'var(--cat-world-bg)', icon: '🌍' },
|
||||
object: { color: 'var(--cat-object)', bg: 'var(--cat-object-bg)', icon: '🔮' },
|
||||
plot: { color: 'var(--cat-plot)', bg: 'var(--cat-plot-bg)', icon: '📋' },
|
||||
rules: { color: 'var(--cat-rules)', bg: 'var(--cat-rules-bg)', icon: '📐' },
|
||||
};
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const {
|
||||
showSettings: open,
|
||||
setShowSettings,
|
||||
setWritingMode: setGlobalWritingMode,
|
||||
incrementSettingsVersion
|
||||
} = useAppStore();
|
||||
|
||||
const onClose = () => {
|
||||
setShowSettings(false);
|
||||
setGlobalWritingMode(getWritingMode());
|
||||
incrementSettingsVersion();
|
||||
};
|
||||
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('settings');
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||||
const [writingMode, setWritingModeState] = useState('webnovel');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeWorkId, setActiveWorkIdState] = useState(null);
|
||||
const [showNewWorkInput, setShowNewWorkInput] = useState(false);
|
||||
const [newWorkName, setNewWorkName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [expandedCategory, setExpandedCategory] = useState(null);
|
||||
|
||||
// 获取当前作品的节点
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSettings(getProjectSettings());
|
||||
const loadNodes = async () => {
|
||||
const allNodes = await getSettingsNodes();
|
||||
setNodes(allNodes);
|
||||
setWritingModeState(getWritingMode());
|
||||
setSearchQuery('');
|
||||
// 初始化激活作品
|
||||
let wid = getActiveWorkId();
|
||||
if (!wid || !allNodes.find(n => n.id === wid)) {
|
||||
const firstWork = allNodes.find(n => n.type === 'work');
|
||||
wid = firstWork?.id || null;
|
||||
if (wid) setActiveWorkId(wid);
|
||||
}
|
||||
setActiveWorkIdState(wid);
|
||||
};
|
||||
loadNodes();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 所有作品列表
|
||||
const works = useMemo(() => getAllWorks(nodes), [nodes]);
|
||||
|
||||
// 当前作品下的节点
|
||||
const visibleNodes = useMemo(() => {
|
||||
if (!activeWorkId) return nodes;
|
||||
// 递归收集当前作品的所有后代 id
|
||||
const workDescendants = new Set();
|
||||
const collectDescendants = (parentId) => {
|
||||
nodes.filter(n => n.parentId === parentId).forEach(n => {
|
||||
workDescendants.add(n.id);
|
||||
collectDescendants(n.id);
|
||||
});
|
||||
};
|
||||
workDescendants.add(activeWorkId);
|
||||
collectDescendants(activeWorkId);
|
||||
return nodes.filter(n => workDescendants.has(n.id));
|
||||
}, [nodes, activeWorkId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = visibleNodes.filter(n => n.type === 'item');
|
||||
return Object.entries(CAT_STYLES).filter(([cat]) => cat !== 'work').map(([cat, style]) => ({
|
||||
category: cat,
|
||||
count: items.filter(n => n.category === cat).length,
|
||||
label: t(`settings.categories.${cat}`),
|
||||
...style,
|
||||
}));
|
||||
}, [visibleNodes, t]);
|
||||
|
||||
// 作品管理
|
||||
const handleSwitchWork = (workId) => {
|
||||
setActiveWorkIdState(workId);
|
||||
setActiveWorkId(workId);
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleCreateWork = async () => {
|
||||
const name = newWorkName.trim();
|
||||
if (!name) return;
|
||||
const { workNode, subNodes } = createWorkNode(name);
|
||||
const updatedNodes = [...nodes, workNode, ...subNodes];
|
||||
await saveSettingsNodes(updatedNodes);
|
||||
setNodes(updatedNodes);
|
||||
setActiveWorkIdState(workNode.id);
|
||||
setActiveWorkId(workNode.id);
|
||||
setNewWorkName('');
|
||||
setShowNewWorkInput(false);
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleDeleteWork = async (workId) => {
|
||||
const work = nodes.find(n => n.id === workId);
|
||||
if (!work) return;
|
||||
if (works.length <= 1) { alert(t('settings.deleteWorkAlert')); return; }
|
||||
if (!confirm(t('settings.deleteWorkPrompt').replace('{name}', work.name))) return;
|
||||
// 递归删除作品及其所有后代
|
||||
const toDelete = new Set();
|
||||
const collect = (pid) => { toDelete.add(pid); nodes.filter(n => n.parentId === pid).forEach(n => collect(n.id)); };
|
||||
collect(workId);
|
||||
const updatedNodes = nodes.filter(n => !toDelete.has(n.id));
|
||||
await saveSettingsNodes(updatedNodes);
|
||||
setNodes(updatedNodes);
|
||||
// 切换到第一个存活的作品
|
||||
const nextWork = updatedNodes.find(n => n.type === 'work');
|
||||
if (nextWork) {
|
||||
setActiveWorkIdState(nextWork.id);
|
||||
setActiveWorkId(nextWork.id);
|
||||
}
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
if (!open || !settings) return null;
|
||||
|
||||
const handleSettingsSave = (section, data) => {
|
||||
const newSettings = { ...settings, [section]: data };
|
||||
setSettings(newSettings);
|
||||
saveProjectSettings(newSettings);
|
||||
};
|
||||
|
||||
// 节点操作
|
||||
const handleAddNode = async (parentId, category) => {
|
||||
let cat = category || 'custom';
|
||||
if (parentId && !category) {
|
||||
const parent = nodes.find(n => n.id === parentId);
|
||||
if (parent) cat = parent.category;
|
||||
}
|
||||
const newNode = await addSettingsNode({
|
||||
name: t('settings.newItem'),
|
||||
type: 'item',
|
||||
category: cat,
|
||||
parentId,
|
||||
enabled: true,
|
||||
});
|
||||
setNodes(await getSettingsNodes());
|
||||
setSelectedNodeId(newNode.id);
|
||||
};
|
||||
|
||||
const handleDeleteNode = async (id) => {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (!node) return;
|
||||
if (!confirm(t('settings.deleteNodePrompt').replace('{name}', node.name))) return;
|
||||
await deleteSettingsNode(id);
|
||||
setNodes(await getSettingsNodes());
|
||||
if (selectedNodeId === id) setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const handleRenameNode = async (id, newName) => {
|
||||
await updateSettingsNode(id, { name: newName });
|
||||
setNodes(await getSettingsNodes());
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (id) => {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (!node) return;
|
||||
const newEnabled = node.enabled === false ? true : false;
|
||||
await updateSettingsNode(id, { enabled: newEnabled });
|
||||
setNodes(prev => prev.map(n => n.id === id ? { ...n, enabled: newEnabled } : n));
|
||||
};
|
||||
|
||||
const handleUpdateNode = async (id, updates) => {
|
||||
await updateSettingsNode(id, updates);
|
||||
setNodes(prev => prev.map(n => n.id === id ? { ...n, ...updates, updatedAt: new Date().toISOString() } : n));
|
||||
};
|
||||
|
||||
const selectedNode = visibleNodes.find(n => n.id === selectedNodeId);
|
||||
const showBookInfo = selectedNode?.type === 'special' && selectedNode?.category === 'bookInfo';
|
||||
|
||||
const tabs = [
|
||||
{ key: 'settings', label: t('settings.tabSettings') },
|
||||
{ key: 'apiConfig', label: t('settings.tabApi') },
|
||||
{ key: 'preferences', label: t('settings.tabPreferences') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-panel-overlay" onClick={onClose}>
|
||||
<div className="settings-panel-container glass-panel" onClick={e => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="settings-header" style={{ background: 'transparent' }}>
|
||||
<h2>
|
||||
⚙️ {t('settings.title')}
|
||||
<span className="subtitle">— {t('settings.subtitle')}</span>
|
||||
</h2>
|
||||
<button className="btn btn-ghost btn-icon" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-glass)', padding: '0 24px' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
style={{
|
||||
padding: '10px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontSize: 13, color: activeTab === tab.key ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
borderBottom: activeTab === tab.key ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
fontWeight: activeTab === tab.key ? 600 : 400, transition: 'all 0.15s', whiteSpace: 'nowrap',
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{activeTab === 'apiConfig' ? (
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||
<ApiConfigForm data={settings.apiConfig} onChange={data => handleSettingsSave('apiConfig', data)} />
|
||||
</div>
|
||||
) : activeTab === 'preferences' ? (
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||
<PreferencesForm />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 写作模式选择器 */}
|
||||
<div style={{ display: 'flex', gap: 10, padding: '14px 24px', borderBottom: '1px solid var(--border-light)', background: 'var(--bg-secondary)' }}>
|
||||
{Object.values(WRITING_MODES).map(m => (
|
||||
<button
|
||||
key={m.key}
|
||||
className={`writing-mode-card ${writingMode === m.key ? 'active' : ''}`}
|
||||
style={{
|
||||
border: writingMode === m.key ? `2px solid ${m.color}` : '1px solid var(--border-light)',
|
||||
background: writingMode === m.key ? `${m.color}10` : 'var(--bg-primary)',
|
||||
}}
|
||||
onClick={() => { setWritingModeState(m.key); setWritingMode(m.key); }}
|
||||
>
|
||||
<div style={{ fontSize: 18, marginBottom: 4 }}>{m.icon}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: writingMode === m.key ? m.color : 'var(--text-primary)', marginBottom: 2 }}>{m.label}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.4 }}>{m.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 作品切换器 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px', borderBottom: '1px solid var(--border-light)', background: 'var(--bg-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{t('settings.workLabel')}</span>
|
||||
<div style={{ display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{works.map(w => (
|
||||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '5px 12px', border: activeWorkId === w.id ? '2px solid var(--cat-work)' : '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)', background: activeWorkId === w.id ? 'var(--cat-work-bg)' : 'var(--bg-secondary)',
|
||||
cursor: 'pointer', fontSize: 12, fontWeight: activeWorkId === w.id ? 600 : 400,
|
||||
color: activeWorkId === w.id ? 'var(--cat-work)' : 'var(--text-primary)', transition: 'all 0.15s',
|
||||
}}
|
||||
onClick={() => handleSwitchWork(w.id)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
{works.length > 1 && (
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 10, padding: '2px 4px', lineHeight: 1, opacity: 0.6 }}
|
||||
onClick={() => handleDeleteWork(w.id)}
|
||||
title={t('common.delete') + ' ' + w.name}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showNewWorkInput ? (
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
style={{ padding: '4px 8px', border: '1px solid var(--accent)', borderRadius: 'var(--radius-sm)', fontSize: 12, background: 'var(--bg-primary)', color: 'var(--text-primary)', outline: 'none', width: 120 }}
|
||||
value={newWorkName}
|
||||
onChange={e => setNewWorkName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateWork(); if (e.key === 'Escape') setShowNewWorkInput(false); }}
|
||||
placeholder={t('settings.workNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" style={{ padding: '4px 10px', fontSize: 11 }} onClick={handleCreateWork}>{t('settings.confirmBtn')}</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{ padding: '4px 8px', fontSize: 11 }} onClick={() => setShowNewWorkInput(false)}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={{ padding: '5px 10px', border: '1px dashed var(--border-light)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--text-muted)', transition: 'all 0.15s' }}
|
||||
onClick={() => { setNewWorkName(''); setShowNewWorkInput(true); }}
|
||||
>{t('settings.newWork')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计栏 */}
|
||||
<div className="settings-stats">
|
||||
{stats.map(s => (
|
||||
<div
|
||||
key={s.category}
|
||||
className="stat-badge"
|
||||
style={{ background: s.bg, color: s.color, borderColor: s.color + '33', cursor: 'pointer' }}
|
||||
title={t('settings.statsTitle') + ': ' + s.label}
|
||||
onClick={() => setExpandedCategory(s.category)}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span className="stat-count">{s.count}</span>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* 左侧:搜索 + 树形导航 */}
|
||||
<div style={{
|
||||
width: 260, minWidth: 260, borderRight: '1px solid var(--border-light)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{/* 搜索框 */}
|
||||
<div className="settings-search">
|
||||
<input
|
||||
className="settings-search-input"
|
||||
type="text"
|
||||
placeholder={t('settings.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 树 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<SettingsTree
|
||||
nodes={visibleNodes}
|
||||
selectedId={selectedNodeId}
|
||||
onSelect={setSelectedNodeId}
|
||||
onAdd={handleAddNode}
|
||||
onDelete={handleDeleteNode}
|
||||
onRename={handleRenameNode}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
searchQuery={searchQuery}
|
||||
expandedCategory={expandedCategory}
|
||||
onExpandComplete={() => setExpandedCategory(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:编辑器 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{showBookInfo ? (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<BookInfoForm data={settings.bookInfo} onChange={data => handleSettingsSave('bookInfo', data)} />
|
||||
</div>
|
||||
) : (
|
||||
<SettingsItemEditor
|
||||
selectedNode={selectedNode}
|
||||
allNodes={visibleNodes}
|
||||
onUpdate={handleUpdateNode}
|
||||
onSelect={setSelectedNodeId}
|
||||
onAdd={handleAddNode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== API 配置 ====================
|
||||
|
||||
const PROVIDERS = [
|
||||
{ key: 'zhipu', label: '智谱AI (GLM)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', models: ['glm-4-flash', 'glm-4-plus', 'glm-4-long', 'glm-4'] },
|
||||
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
|
||||
{ key: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
|
||||
{ key: 'gemini', label: 'Gemini (OpenAI兼容)', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-1.5-pro'] },
|
||||
{ key: 'gemini-native', label: 'Gemini(原生格式)', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-1.5-pro'] },
|
||||
{ key: 'siliconflow', label: 'SiliconFlow (硅基流动)', baseUrl: 'https://api.siliconflow.cn/v1', models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct', 'THUDM/glm-4-9b-chat'] },
|
||||
{ key: 'moonshot', label: 'Moonshot (Kimi)', baseUrl: 'https://api.moonshot.cn/v1', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] },
|
||||
{ key: 'custom', label: '自定义 (OpenAI兼容)', baseUrl: '', models: [] },
|
||||
];
|
||||
|
||||
function PreferencesForm() {
|
||||
const { language, setLanguage, visualTheme, setVisualTheme } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 24 }}>
|
||||
{t('preferences.intro')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 12 }}>{t('preferences.langLabel')}</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{['zh', 'en', 'ru'].map(lang => (
|
||||
<button
|
||||
key={lang}
|
||||
style={{
|
||||
flex: 1, padding: '12px 16px', border: language === lang ? '2px solid var(--accent)' : '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-md)', background: language === lang ? 'var(--accent-light)' : 'var(--bg-primary)',
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: language === lang ? 600 : 400,
|
||||
color: language === lang ? 'var(--accent)' : 'var(--text-primary)', transition: 'all 0.15s',
|
||||
boxShadow: language === lang ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-sm)'
|
||||
}}
|
||||
onClick={() => setLanguage(lang)}
|
||||
>
|
||||
{lang === 'zh' ? '🇨🇳 简体中文' : lang === 'en' ? '🇬🇧 English' : '🇷🇺 Русский'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 12 }}>{t('preferences.themeLabel')}</label>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{[{ id: 'warm', label: t('preferences.themeWarm'), desc: t('preferences.themeWarmDesc') }, { id: 'modern', label: t('preferences.themeModern'), desc: t('preferences.themeModernDesc') }].map(theme => (
|
||||
<button
|
||||
key={theme.id}
|
||||
style={{
|
||||
flex: 1, padding: '20px 16px', border: visualTheme === theme.id ? '2px solid var(--accent)' : '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-lg)', background: visualTheme === theme.id ? 'var(--accent-light)' : 'var(--bg-primary)',
|
||||
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
boxShadow: visualTheme === theme.id ? '0 6px 16px var(--accent-glow)' : 'var(--shadow-sm)'
|
||||
}}
|
||||
onMouseEnter={e => { if (visualTheme !== theme.id) e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||
onMouseLeave={e => { if (visualTheme !== theme.id) e.currentTarget.style.transform = 'none' }}
|
||||
onClick={() => {
|
||||
setVisualTheme(theme.id);
|
||||
document.documentElement.setAttribute('data-visual', theme.id);
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: visualTheme === theme.id ? 'var(--accent)' : 'var(--text-primary)', marginBottom: 6 }}>
|
||||
{theme.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{theme.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiConfigForm({ data, onChange }) {
|
||||
const update = (field, value) => onChange({ ...data, [field]: value });
|
||||
const [testStatus, setTestStatus] = useState(null);
|
||||
const [fetchedModels, setFetchedModels] = useState(null);
|
||||
const [fetchedEmbedModels, setFetchedEmbedModels] = useState(null);
|
||||
const [savedProfiles, setSavedProfiles] = useState([]);
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [showSaveInput, setShowSaveInput] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('author-api-profiles');
|
||||
if (saved) setSavedProfiles(JSON.parse(saved));
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const persistProfiles = (profiles) => {
|
||||
setSavedProfiles(profiles);
|
||||
localStorage.setItem('author-api-profiles', JSON.stringify(profiles));
|
||||
};
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
const name = profileName.trim();
|
||||
if (!name) return;
|
||||
const profile = { id: Date.now().toString(36), name, config: { ...data }, createdAt: new Date().toLocaleString('zh-CN') };
|
||||
const updated = savedProfiles.filter(p => p.name !== name);
|
||||
updated.unshift(profile);
|
||||
persistProfiles(updated);
|
||||
setProfileName('');
|
||||
setShowSaveInput(false);
|
||||
};
|
||||
|
||||
const handleLoadProfile = (profile) => { onChange({ ...profile.config }); setTestStatus(null); setFetchedModels(null); };
|
||||
const handleDeleteProfile = (id) => { persistProfiles(savedProfiles.filter(p => p.id !== id)); };
|
||||
|
||||
const handleProviderChange = (providerKey) => {
|
||||
const provider = PROVIDERS.find(p => p.key === providerKey);
|
||||
if (provider) {
|
||||
onChange({ ...data, provider: providerKey, baseUrl: providerKey === 'custom' ? '' : (provider.baseUrl || data.baseUrl), model: providerKey === 'custom' ? '' : (provider.models[0] || data.model) });
|
||||
}
|
||||
setTestStatus(null);
|
||||
setFetchedModels(null);
|
||||
setFetchedEmbedModels(null);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestStatus('loading');
|
||||
try {
|
||||
const res = await fetch('/api/ai/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiConfig: data }) });
|
||||
setTestStatus(await res.json());
|
||||
} catch { setTestStatus({ success: false, error: t('apiConfig.networkError') }); }
|
||||
};
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
setFetchedModels('loading');
|
||||
try {
|
||||
const res = await fetch('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: data.apiKey, baseUrl: data.baseUrl, provider: data.provider }) });
|
||||
const result = await res.json();
|
||||
if (result.error) { setFetchedModels(null); setTestStatus({ success: false, error: result.error }); }
|
||||
else { setFetchedModels(result.models || []); }
|
||||
} catch { setFetchedModels(null); setTestStatus({ success: false, error: t('apiConfig.fetchModelsFailed') }); }
|
||||
};
|
||||
|
||||
const handleFetchEmbedModels = async () => {
|
||||
setFetchedEmbedModels('loading');
|
||||
try {
|
||||
const embedKey = data.embedApiKey || data.apiKey;
|
||||
const embedBase = data.embedBaseUrl || data.baseUrl;
|
||||
const res = await fetch('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: embedKey, baseUrl: embedBase, provider: data.embedProvider }) });
|
||||
const result = await res.json();
|
||||
if (result.error) { setFetchedEmbedModels(null); setTestStatus({ success: false, error: t('apiConfig.embedApiPrefix') + result.error }); }
|
||||
else { setFetchedEmbedModels(result.models || []); }
|
||||
} catch { setFetchedEmbedModels(null); setTestStatus({ success: false, error: t('apiConfig.fetchEmbedModelsFailed') }); }
|
||||
};
|
||||
|
||||
const currentProvider = PROVIDERS.find(p => p.key === data.provider) || PROVIDERS[7];
|
||||
const isCustom = data.provider === 'custom';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{savedProfiles.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>{t('apiConfig.savedProfiles')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{savedProfiles.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', border: '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)', background: 'var(--bg-secondary)', fontSize: 12 }}>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontWeight: 500, fontSize: 12, padding: 0 }} onClick={() => handleLoadProfile(p)} title={`${p.config.provider} | ${p.config.model}`}>{p.name}</button>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, padding: '0 2px', lineHeight: 1 }} onClick={() => handleDeleteProfile(p.id)} title={t('apiConfig.deleteProfile')}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>{t('apiConfig.intro')}</p>
|
||||
|
||||
{/* 供应商选择 */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 8 }}>{t('apiConfig.provider')}</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{PROVIDERS.map(p => (
|
||||
<button key={p.key} style={{ padding: '8px 12px', border: data.provider === p.key ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-md)', background: data.provider === p.key ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 12, fontWeight: data.provider === p.key ? 600 : 400, color: data.provider === p.key ? 'var(--accent)' : 'var(--text-primary)', transition: 'all 0.15s' }} onClick={() => handleProviderChange(p.key)}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{data.provider === 'gemini-native' && (
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', background: 'var(--accent-light)', borderRadius: 'var(--radius-sm)', fontSize: 11, color: 'var(--accent)', lineHeight: 1.6 }}>
|
||||
{t('apiConfig.geminiNativeHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FieldInput label="API Key" value={data.apiKey} onChange={v => update('apiKey', v)} placeholder={t('apiConfig.apiKeyPlaceholder')} />
|
||||
{data.apiKey && <div style={{ fontSize: 11, color: 'var(--success)', marginTop: -10, marginBottom: 10 }}>{t('apiConfig.apiKeyConfigured')}</div>}
|
||||
|
||||
<FieldInput label={isCustom ? t('apiConfig.apiAddress') : t('apiConfig.apiAddressAuto')} value={data.baseUrl} onChange={v => update('baseUrl', v)} placeholder={t('apiConfig.apiAddressPlaceholder')} />
|
||||
|
||||
{/* 模型选择 */}
|
||||
{currentProvider.models.length > 0 && !isCustom ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 5 }}>
|
||||
{t('apiConfig.model')}
|
||||
{data.apiKey && (
|
||||
<button style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }} onClick={handleFetchModels} disabled={fetchedModels === 'loading'}>
|
||||
{fetchedModels === 'loading' ? t('apiConfig.fetching') : t('apiConfig.fetchModels')}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{(Array.isArray(fetchedModels) ? fetchedModels.map(m => m.id) : currentProvider.models).map(m => (
|
||||
<button key={m} style={{ padding: '5px 12px', border: data.model === m ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)', background: data.model === m ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 12, color: data.model === m ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'monospace' }} onClick={() => update('model', m)}>{m}</button>
|
||||
))}
|
||||
</div>
|
||||
{Array.isArray(fetchedModels) && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>{t('apiConfig.fetchedCount').replace('{count}', fetchedModels.length)}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 5 }}>
|
||||
{t('apiConfig.modelName')}
|
||||
{data.apiKey && data.baseUrl && (
|
||||
<button style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }} onClick={handleFetchModels} disabled={fetchedModels === 'loading'}>
|
||||
{fetchedModels === 'loading' ? t('apiConfig.fetching') : t('apiConfig.fetchModels')}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input className="modal-input" style={{ marginBottom: 0 }} value={data.model || ''} onChange={e => update('model', e.target.value)} placeholder="例如:gpt-4o-mini" />
|
||||
{Array.isArray(fetchedModels) && fetchedModels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', margin: '6px 0 4px' }}>{t('apiConfig.fetchedCountClick').replace('{count}', fetchedModels.length)}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{fetchedModels.map(m => (
|
||||
<button key={m.id} style={{ padding: '4px 10px', border: data.model === m.id ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)', background: data.model === m.id ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 11, color: data.model === m.id ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'monospace' }} onClick={() => update('model', m.id)}>{m.id}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 独立 Embedding 配置 */}
|
||||
<div style={{ marginTop: 24, marginBottom: 14, paddingTop: 16, borderTop: '1px solid var(--border-light)' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.useCustomEmbed || false}
|
||||
onChange={e => update('useCustomEmbed', e.target.checked)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
{t('apiConfig.embedTitle')}
|
||||
</label>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, paddingLeft: 22 }}>
|
||||
{t('apiConfig.embedDesc')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.useCustomEmbed && (
|
||||
<div style={{ padding: '16px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)', marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 8 }}>{t('apiConfig.embedProvider')}</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{PROVIDERS.filter(p => !['deepseek', 'moonshot', 'siliconflow', 'openai'].includes(p.key)).map(p => (
|
||||
<button key={p.key} style={{ padding: '8px 12px', border: data.embedProvider === p.key ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-md)', background: data.embedProvider === p.key ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 12, fontWeight: data.embedProvider === p.key ? 600 : 400, color: data.embedProvider === p.key ? 'var(--accent)' : 'var(--text-primary)', transition: 'all 0.15s' }} onClick={() => onChange({ ...data, embedProvider: p.key, embedBaseUrl: p.key === 'custom' ? '' : (p.baseUrl || data.embedBaseUrl), embedModel: p.key === 'custom' ? '' : (p.key === 'zhipu' ? 'embedding-3' : 'text-embedding-v3-small') })}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FieldInput label="Embedding API Key" value={data.embedApiKey} onChange={v => update('embedApiKey', v)} placeholder={t('apiConfig.embedApiKeyPlaceholder')} />
|
||||
<FieldInput label={data.embedProvider === 'custom' ? t('apiConfig.embedApiAddress') : t('apiConfig.embedApiAddressAuto')} value={data.embedBaseUrl} onChange={v => update('embedBaseUrl', v)} placeholder="https://api.example.com/v1" />
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 5 }}>
|
||||
{t('apiConfig.embedModel')}
|
||||
{data.embedApiKey || data.apiKey ? (
|
||||
<button style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }} onClick={handleFetchEmbedModels} disabled={fetchedEmbedModels === 'loading'}>
|
||||
{fetchedEmbedModels === 'loading' ? t('apiConfig.fetching') : t('apiConfig.fetchEmbedModels')}
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
<input className="modal-input" style={{ marginBottom: 0 }} value={data.embedModel || ''} onChange={e => update('embedModel', e.target.value)} placeholder="例如:text-embedding-v3-small" />
|
||||
{Array.isArray(fetchedEmbedModels) && fetchedEmbedModels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', margin: '6px 0 4px' }}>{t('apiConfig.fetchedCountClick').replace('{count}', fetchedEmbedModels.length)}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto', padding: '4px', border: '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)' }}>
|
||||
{fetchedEmbedModels.map(m => (
|
||||
<button key={m.id} style={{ padding: '4px 10px', border: data.embedModel === m.id ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)', background: data.embedModel === m.id ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 11, color: data.embedModel === m.id ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'monospace', flexShrink: 0 }} onClick={() => update('embedModel', m.id)}>{m.id}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试连接 */}
|
||||
{data.apiKey && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<button style={{ padding: '8px 16px', border: '1px solid var(--accent)', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)', cursor: testStatus === 'loading' ? 'wait' : 'pointer', fontSize: 13, color: 'var(--accent)', fontWeight: 500, transition: 'all 0.15s', opacity: testStatus === 'loading' ? 0.7 : 1 }} onClick={handleTestConnection} disabled={testStatus === 'loading'}>
|
||||
{testStatus === 'loading' ? t('apiConfig.testLoading') : t('apiConfig.testBtn')}
|
||||
</button>
|
||||
{testStatus && testStatus !== 'loading' && (
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 'var(--radius-sm)', fontSize: 12, lineHeight: 1.6, background: testStatus.success ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', color: testStatus.success ? 'var(--success)' : 'var(--error)', border: `1px solid ${testStatus.success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}` }}>
|
||||
{testStatus.success ? <>{testStatus.message}<br />{testStatus.reply && <span>{t('apiConfig.testReply')}{testStatus.reply}</span>}</> : <>❌ {testStatus.error}</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 保存配置 */}
|
||||
{data.apiKey && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{!showSaveInput ? (
|
||||
<button style={{ padding: '8px 16px', border: '1px solid var(--border-light)', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)', cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500 }} onClick={() => { const pl = PROVIDERS.find(p => p.key === data.provider)?.label || data.provider; setProfileName(`${pl} - ${data.model || t('common.confirm')}`); setShowSaveInput(true); }}>
|
||||
{t('apiConfig.saveProfileBtn')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input className="modal-input" style={{ margin: 0, flex: 1, padding: '7px 10px', fontSize: 13 }} value={profileName} onChange={e => setProfileName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveProfile()} placeholder={t('apiConfig.saveProfilePlaceholder')} autoFocus />
|
||||
<button className="btn btn-primary btn-sm" style={{ padding: '7px 14px', whiteSpace: 'nowrap' }} onClick={handleSaveProfile}>{t('apiConfig.saveBtn')}</button>
|
||||
<button className="btn btn-ghost btn-sm" style={{ padding: '7px 10px' }} onClick={() => setShowSaveInput(false)}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.8 }}>
|
||||
<strong>{t('apiConfig.howToGetKey')}</strong><br />
|
||||
• {t('apiConfig.keyGuide').split('\n').map((line, i) => <span key={i}>{line.replace(/^• /, '')}<br /></span>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 表单组件 ====================
|
||||
|
||||
function FieldInput({ label, value, onChange, placeholder, multiline, rows }) {
|
||||
const Component = multiline ? 'textarea' : 'input';
|
||||
return (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 5 }}>{label}</label>
|
||||
<Component className="modal-input" style={{ marginBottom: 0, ...(multiline ? { resize: 'vertical', minHeight: `${(rows || 3) * 22}px` } : {}) }} value={value || ''} onChange={e => onChange(e.target.value)} placeholder={placeholder} rows={rows || 3} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookInfoForm({ data, onChange }) {
|
||||
const update = (field, value) => onChange({ ...data, [field]: value });
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>{t('bookInfo.intro')}</p>
|
||||
<FieldInput label={t('bookInfo.title')} value={data.title} onChange={v => update('title', v)} placeholder={t('bookInfo.titlePlaceholder')} />
|
||||
<FieldInput label={t('bookInfo.genre')} value={data.genre} onChange={v => update('genre', v)} placeholder={t('bookInfo.genrePlaceholder')} />
|
||||
<FieldInput label={t('bookInfo.synopsis')} value={data.synopsis} onChange={v => update('synopsis', v)} placeholder={t('bookInfo.synopsisPlaceholder')} multiline rows={3} />
|
||||
<FieldInput label={t('bookInfo.style')} value={data.style} onChange={v => update('style', v)} placeholder={t('bookInfo.stylePlaceholder')} />
|
||||
<FieldInput label={t('bookInfo.tone')} value={data.tone} onChange={v => update('tone', v)} placeholder={t('bookInfo.tonePlaceholder')} />
|
||||
<FieldInput label={t('bookInfo.pov')} value={data.pov} onChange={v => update('pov', v)} placeholder={t('bookInfo.povPlaceholder')} />
|
||||
<FieldInput label={t('bookInfo.targetAudience')} value={data.targetAudience} onChange={v => update('targetAudience', v)} placeholder={t('bookInfo.targetAudiencePlaceholder')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
|
||||
// 分类的颜色和标识
|
||||
const CATEGORY_STYLES = {
|
||||
work: { color: 'var(--cat-work)', bg: 'var(--cat-work-bg)' },
|
||||
bookInfo: { color: 'var(--cat-bookinfo)', bg: 'var(--cat-bookinfo-bg)' },
|
||||
character: { color: 'var(--cat-character)', bg: 'var(--cat-character-bg)' },
|
||||
location: { color: 'var(--cat-location)', bg: 'var(--cat-location-bg)' },
|
||||
world: { color: 'var(--cat-world)', bg: 'var(--cat-world-bg)' },
|
||||
object: { color: 'var(--cat-object)', bg: 'var(--cat-object-bg)' },
|
||||
plot: { color: 'var(--cat-plot)', bg: 'var(--cat-plot-bg)' },
|
||||
rules: { color: 'var(--cat-rules)', bg: 'var(--cat-rules-bg)' },
|
||||
custom: { color: 'var(--cat-custom)', bg: 'var(--cat-custom-bg)' },
|
||||
};
|
||||
|
||||
function getCategoryStyle(category) {
|
||||
return CATEGORY_STYLES[category] || CATEGORY_STYLES.custom;
|
||||
}
|
||||
|
||||
// 单个树节点
|
||||
function TreeNode({ node, nodes, selectedId, onSelect, onAdd, onDelete, onRename, onToggleEnabled, collapsedIds, onToggleCollapse, level = 0 }) {
|
||||
const { t } = useI18n();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const children = nodes.filter(n => n.parentId === node.id);
|
||||
const isFolder = node.type === 'folder' || node.type === 'special' || node.type === 'work';
|
||||
const isWork = node.type === 'work';
|
||||
const isRoot = node.parentId === null;
|
||||
const isCollapsed = collapsedIds.has(node.id);
|
||||
const isSelected = selectedId === node.id;
|
||||
const isDisabled = node.enabled === false;
|
||||
const style = getCategoryStyle(node.category);
|
||||
|
||||
const nodeRef = useRef(null);
|
||||
|
||||
// 计算子项数目(递归)
|
||||
const descendantCount = useMemo(() => {
|
||||
if (!isFolder) return 0;
|
||||
let count = 0;
|
||||
const countChildren = (parentId) => {
|
||||
nodes.filter(n => n.parentId === parentId).forEach(child => {
|
||||
if (child.type === 'item') count++;
|
||||
else countChildren(child.id);
|
||||
});
|
||||
};
|
||||
countChildren(node.id);
|
||||
return count;
|
||||
}, [node.id, nodes, isFolder]);
|
||||
|
||||
const handleRename = () => {
|
||||
if (renameValue.trim()) {
|
||||
onRename(node.id, renameValue.trim());
|
||||
}
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
// 如果是被点击并展开的节点,自动滚入视图
|
||||
useEffect(() => {
|
||||
if (isSelected && isFolder && nodeRef.current) {
|
||||
// 给一点延迟让折叠动画/渲染完成
|
||||
setTimeout(() => {
|
||||
nodeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
}
|
||||
}, [isSelected, isFolder]);
|
||||
|
||||
return (
|
||||
<div className="tree-node" style={{ paddingLeft: level > 0 ? 12 : 0 }} ref={nodeRef}>
|
||||
<div
|
||||
className={`tree-node-row ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
style={isRoot ? { borderLeft: `3px solid ${style.color}`, marginBottom: 2 } : {}}
|
||||
title={isDisabled ? t('settingsTree.disabledHint') : ''}
|
||||
>
|
||||
{/* 折叠箭头 */}
|
||||
{isFolder && (
|
||||
<span
|
||||
className="tree-node-icon"
|
||||
onClick={e => { e.stopPropagation(); onToggleCollapse(node.id); }}
|
||||
style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: 10 }}
|
||||
>
|
||||
{isCollapsed ? '▶' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="tree-node-icon">{node.icon || (isFolder ? '📁' : '📄')}</span>
|
||||
|
||||
{/* 名称 */}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
className="tree-node-name"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setIsRenaming(false); }}
|
||||
autoFocus
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 3, padding: '1px 4px', fontSize: 13, background: 'var(--bg-primary)', color: 'var(--text-primary)', outline: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="tree-node-name">{node.name}</span>
|
||||
)}
|
||||
|
||||
{/* 启用/禁用开关(仅item节点) */}
|
||||
{!isFolder && !isRoot && (
|
||||
<button
|
||||
className={`tree-toggle-btn ${isDisabled ? 'visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onToggleEnabled(node.id); }}
|
||||
title={isDisabled ? t('settingsTree.enableHint') : t('settingsTree.disableHint')}
|
||||
>
|
||||
{isDisabled ? '🚫' : '👁'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 子项计数徽标(folder节点) */}
|
||||
{isFolder && descendantCount > 0 && (
|
||||
<span
|
||||
className="tree-node-badge"
|
||||
style={{ background: style.bg, color: style.color }}
|
||||
>
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<span className="tree-node-actions">
|
||||
{/* 添子项 */}
|
||||
{isFolder && (
|
||||
<button className="tree-action-btn" onClick={e => { e.stopPropagation(); onAdd(node.id, node.category); }} title={t('settingsTree.add')}>+</button>
|
||||
)}
|
||||
{/* 重命名 */}
|
||||
{!isRoot && (
|
||||
<button className="tree-action-btn" onClick={e => { e.stopPropagation(); setRenameValue(node.name); setIsRenaming(true); }} title={t('common.rename')}>✏</button>
|
||||
)}
|
||||
{/* 删除 */}
|
||||
{!isRoot && (
|
||||
<button className="tree-action-btn danger" onClick={e => { e.stopPropagation(); onDelete(node.id); }} title={t('common.delete')}>✕</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 子节点 */}
|
||||
{isFolder && !isCollapsed && (
|
||||
<div className="tree-node-children">
|
||||
{children
|
||||
.sort((a, b) => (a.type === 'folder' ? -1 : 1) - (b.type === 'folder' ? -1 : 1) || (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
.map(child => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
nodes={nodes}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
collapsedIds={collapsedIds}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 设定树组件
|
||||
export default function SettingsTree({
|
||||
nodes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onDelete,
|
||||
onRename,
|
||||
onToggleEnabled,
|
||||
searchQuery = '',
|
||||
expandedCategory = null,
|
||||
onExpandComplete,
|
||||
}) {
|
||||
const [collapsedIds, setCollapsedIds] = useState(new Set());
|
||||
|
||||
const toggleCollapse = (id) => {
|
||||
setCollapsedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 监听外部控制展开特定分类文件夹
|
||||
useEffect(() => {
|
||||
if (expandedCategory) {
|
||||
const targetFolder = nodes.find(n => n.type === 'folder' && n.category === expandedCategory && n.parentId !== null);
|
||||
if (targetFolder) {
|
||||
// 确保父节点和该节点自己都没被折叠
|
||||
setCollapsedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(targetFolder.id);
|
||||
if (targetFolder.parentId) {
|
||||
next.delete(targetFolder.parentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
onSelect(targetFolder.id);
|
||||
if (onExpandComplete) {
|
||||
onExpandComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [expandedCategory, nodes, onSelect, onExpandComplete]);
|
||||
|
||||
// 获取根节点
|
||||
const rootNodes = nodes.filter(n => n.parentId === null);
|
||||
|
||||
// 搜索过滤
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!searchQuery.trim()) return nodes;
|
||||
const q = searchQuery.toLowerCase();
|
||||
// 找到匹配的节点和它们到根的路径
|
||||
const matchIds = new Set();
|
||||
nodes.forEach(n => {
|
||||
if (n.name.toLowerCase().includes(q) ||
|
||||
(n.content?.description || '').toLowerCase().includes(q) ||
|
||||
(n.content?.personality || '').toLowerCase().includes(q) ||
|
||||
(n.content?.background || '').toLowerCase().includes(q)) {
|
||||
// 添加自己和所有祖先
|
||||
let current = n;
|
||||
while (current) {
|
||||
matchIds.add(current.id);
|
||||
current = current.parentId ? nodes.find(p => p.id === current.parentId) : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return nodes.filter(n => matchIds.has(n.id));
|
||||
}, [nodes, searchQuery]);
|
||||
|
||||
const filteredRootNodes = rootNodes.filter(n => filteredNodes.some(fn => fn.id === n.id));
|
||||
|
||||
return (
|
||||
<div className="settings-tree">
|
||||
{filteredRootNodes.map(root => (
|
||||
<TreeNode
|
||||
key={root.id}
|
||||
node={root}
|
||||
nodes={filteredNodes}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
collapsedIds={collapsedIds}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
level={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
import { createChapter, deleteChapter, updateChapter, exportToMarkdown, exportAllToMarkdown } from '../lib/storage';
|
||||
import { exportProject, importProject } from '../lib/project-io';
|
||||
import { WRITING_MODES } from '../lib/settings';
|
||||
|
||||
export default function Sidebar() {
|
||||
const {
|
||||
chapters, addChapter, setChapters, updateChapter: updateChapterStore,
|
||||
activeChapterId, setActiveChapterId,
|
||||
sidebarOpen, setSidebarOpen,
|
||||
theme, setTheme,
|
||||
writingMode,
|
||||
setShowSettings,
|
||||
setShowSnapshots,
|
||||
showToast
|
||||
} = useAppStore();
|
||||
|
||||
const [showNewChapterModal, setShowNewChapterModal] = useState(false);
|
||||
const [newChapterTitle, setNewChapterTitle] = useState('');
|
||||
const [renameId, setRenameId] = useState(null);
|
||||
const [renameTitle, setRenameTitle] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(next);
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('author-theme', next);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
// 创建新章节
|
||||
const handleCreateChapter = useCallback(() => {
|
||||
const title = newChapterTitle.trim() || t('sidebar.defaultChapterTitle').replace('{num}', chapters.length + 1);
|
||||
const ch = createChapter(title);
|
||||
addChapter(ch);
|
||||
setActiveChapterId(ch.id);
|
||||
setShowNewChapterModal(false);
|
||||
setNewChapterTitle('');
|
||||
showToast(t('sidebar.chapterCreated').replace('{title}', title), 'success');
|
||||
}, [newChapterTitle, chapters.length, showToast, addChapter, setActiveChapterId, t]);
|
||||
|
||||
// 删除章节
|
||||
const handleDeleteChapter = useCallback((id) => {
|
||||
if (chapters.length <= 1) {
|
||||
showToast(t('sidebar.alertRetainOne'), 'error');
|
||||
return;
|
||||
}
|
||||
const ch = chapters.find(c => c.id === id);
|
||||
const remaining = deleteChapter(id);
|
||||
setChapters(remaining);
|
||||
if (activeChapterId === id) {
|
||||
setActiveChapterId(remaining[0]?.id || null);
|
||||
}
|
||||
showToast(t('sidebar.chapterDeleted').replace('{title}', ch?.title), 'info');
|
||||
setContextMenu(null);
|
||||
}, [chapters, activeChapterId, showToast, setChapters, setActiveChapterId, t]);
|
||||
|
||||
// 重命名章节
|
||||
const handleRename = useCallback((id) => {
|
||||
const title = renameTitle.trim();
|
||||
if (!title) return;
|
||||
const updated = updateChapter(id, { title });
|
||||
updateChapterStore(id, { title });
|
||||
setRenameId(null);
|
||||
setRenameTitle('');
|
||||
}, [renameTitle, updateChapterStore]);
|
||||
|
||||
// 导出
|
||||
const handleExport = useCallback((type) => {
|
||||
if (type === 'current' && activeChapterId) {
|
||||
const activeChapter = chapters.find(ch => ch.id === activeChapterId);
|
||||
if (activeChapter) {
|
||||
exportToMarkdown(activeChapter);
|
||||
showToast(t('sidebar.exportedChapter'), 'success');
|
||||
}
|
||||
} else if (type === 'all') {
|
||||
exportAllToMarkdown(chapters);
|
||||
showToast(t('sidebar.exportedAll'), 'success');
|
||||
}
|
||||
}, [activeChapterId, chapters, showToast, t]);
|
||||
|
||||
const totalWords = chapters.reduce((sum, ch) => sum + (ch.wordCount || 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar ${sidebarOpen ? '' : 'collapsed'}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">
|
||||
<span>A</span>uthor
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-icon" onClick={() => setSidebarOpen(false)} title={t('sidebar.collapseSidebar')}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 12px 0' }}>
|
||||
<button
|
||||
id="tour-new-chapter"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
onClick={() => setShowNewChapterModal(true)}
|
||||
>
|
||||
{t('sidebar.newChapter')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', padding: '8px 14px 6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
{t('sidebar.chapterList')} ({chapters.length})
|
||||
</div>
|
||||
<div className="chapter-list">
|
||||
{chapters.map(ch => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`chapter-item ${ch.id === activeChapterId ? 'active' : ''}`}
|
||||
onClick={() => setActiveChapterId(ch.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ id: ch.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
{renameId === ch.id ? (
|
||||
<input
|
||||
className="modal-input"
|
||||
style={{ margin: 0, padding: '4px 8px', fontSize: '13px' }}
|
||||
value={renameTitle}
|
||||
onChange={e => setRenameTitle(e.target.value)}
|
||||
onBlur={() => handleRename(ch.id)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRename(ch.id)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="chapter-title">{ch.title}</span>
|
||||
<span className="chapter-count">{ch.wordCount || 0}{t('sidebar.wordUnit')}</span>
|
||||
<div className="chapter-actions">
|
||||
<button
|
||||
className="btn btn-ghost btn-icon btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameId(ch.id);
|
||||
setRenameTitle(ch.title);
|
||||
}}
|
||||
title={t('common.rename')}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-icon btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteChapter(ch.id);
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '8px' }}>
|
||||
{/* 写作模式指示器 */}
|
||||
{(() => {
|
||||
const modeConfig = WRITING_MODES[writingMode];
|
||||
return modeConfig ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: `${modeConfig.color}10`,
|
||||
border: `1px solid ${modeConfig.color}30`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onClick={() => setShowSettings(true)}
|
||||
title={t('sidebar.clickToSwitchMode')}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>{modeConfig.icon}</span>
|
||||
<span style={{ fontSize: '12px', fontWeight: '600', color: modeConfig.color }}>{t('sidebar.modeLabel').replace('{mode}', modeConfig.label)}</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
<span>{t('sidebar.totalWords')}</span>
|
||||
<span style={{ color: 'var(--accent)', fontWeight: '600' }}>{totalWords.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => handleExport('current')}>
|
||||
{t('sidebar.exportCurrent')}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => handleExport('all')}>
|
||||
{t('sidebar.exportAll')}
|
||||
</button>
|
||||
<button id="tour-settings" className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSettings(true)} title={t('sidebar.tooltipSettings')}>
|
||||
⚙️
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm btn-icon" onClick={toggleTheme} title={theme === 'light' ? t('sidebar.tooltipThemeDark') : t('sidebar.tooltipThemeLight')}>
|
||||
{theme === 'light' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSnapshots(true)} title={t('sidebar.tooltipTimeMachine')}>
|
||||
🕒
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { exportProject(); }}>
|
||||
{t('sidebar.btnSave')}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { document.getElementById('project-import-input')?.click(); }}>
|
||||
{t('sidebar.btnLoad')}
|
||||
</button>
|
||||
<input
|
||||
id="project-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const result = await importProject(file);
|
||||
if (result.success) {
|
||||
alert(result.message + '\n' + t('sidebar.importSuccess'));
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ===== 新建章节弹窗 ===== */}
|
||||
{showNewChapterModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowNewChapterModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h2>{t('sidebar.newChapter')}</h2>
|
||||
<input
|
||||
className="modal-input"
|
||||
placeholder={t('sidebar.defaultChapterTitle').replace('{num}', chapters.length + 1)}
|
||||
value={newChapterTitle}
|
||||
onChange={e => setNewChapterTitle(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateChapter()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-secondary" onClick={() => setShowNewChapterModal(false)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleCreateChapter}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 右键菜单 ===== */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
style={{ background: 'transparent' }}
|
||||
onClick={() => setContextMenu(null)}
|
||||
>
|
||||
<div
|
||||
className="dropdown-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
setRenameId(contextMenu.id);
|
||||
const ch = chapters.find(c => c.id === contextMenu.id);
|
||||
setRenameTitle(ch?.title || '');
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
{t('sidebar.contextRename')}
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
const ch = chapters.find(c => c.id === contextMenu.id);
|
||||
if (ch) exportToMarkdown(ch);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
{t('sidebar.contextExport')}
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item danger"
|
||||
onClick={() => handleDeleteChapter(contextMenu.id)}
|
||||
>
|
||||
{t('sidebar.contextDelete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
import { getSnapshots, createSnapshot, restoreSnapshot, deleteSnapshot } from '../lib/snapshots';
|
||||
|
||||
export default function SnapshotManager({ onRestored }) {
|
||||
const { showSnapshots: open, setShowSnapshots } = useAppStore();
|
||||
const onClose = () => setShowSnapshots(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const data = await getSnapshots();
|
||||
setSnapshots(data);
|
||||
if (data.length > 0 && !selectedId) {
|
||||
setSelectedId(data[0].id);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadData();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleCreateManual = async () => {
|
||||
const label = prompt(t('snapshot.promptLabel'), t('snapshot.promptDefault'));
|
||||
if (!label) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const snap = await createSnapshot(label, 'manual');
|
||||
await loadData();
|
||||
setSelectedId(snap.id);
|
||||
} catch (e) {
|
||||
alert(t('snapshot.createFailed') + e.message);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!selectedId) return;
|
||||
const snap = snapshots.find(s => s.id === selectedId);
|
||||
if (!snap) return;
|
||||
|
||||
const confirmMsg = t('snapshot.confirmRestore')
|
||||
.replace('{label}', snap.label)
|
||||
.replace('{date}', new Date(snap.timestamp).toLocaleString());
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRestoring(true);
|
||||
try {
|
||||
await restoreSnapshot(snap.id);
|
||||
alert(t('snapshot.restoreSuccess'));
|
||||
if (onRestored) onRestored();
|
||||
else window.location.reload();
|
||||
} catch (e) {
|
||||
alert(t('snapshot.restoreFailed') + e.message);
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id, e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t('snapshot.confirmDelete'))) return;
|
||||
try {
|
||||
const remaining = await deleteSnapshot(id);
|
||||
setSnapshots(remaining);
|
||||
if (selectedId === id) setSelectedId(remaining[0]?.id || null);
|
||||
} catch (err) {
|
||||
alert(t('snapshot.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedSnap = snapshots.find(s => s.id === selectedId);
|
||||
|
||||
const formatDate = (ts) => {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-panel-overlay" onClick={onClose} style={{ zIndex: 9999 }}>
|
||||
<div className="settings-panel-container" onClick={e => e.stopPropagation()} style={{ width: 800, maxWidth: '90vw', height: '80vh' }}>
|
||||
<div className="settings-header">
|
||||
<h2>{t('snapshot.title')}
|
||||
<span className="subtitle">— {t('snapshot.subtitle')}</span>
|
||||
</h2>
|
||||
<button className="btn btn-ghost btn-icon" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* 左侧列表 */}
|
||||
<div style={{ width: 300, borderRight: '1px solid var(--border-light)', display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)' }}>
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid var(--border-light)', display: 'flex', gap: 10 }}>
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleCreateManual} disabled={isCreating}>
|
||||
{isCreating ? t('snapshot.creating') : t('snapshot.createBtn')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)' }}>{t('snapshot.loading')}</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)' }}>{t('snapshot.empty')}</div>
|
||||
) : (
|
||||
snapshots.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: s.id === selectedId ? '2px solid var(--accent)' : '1px solid var(--border-light)',
|
||||
background: s.id === selectedId ? 'var(--accent-light)' : 'var(--bg-secondary)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<strong style={{ fontSize: 13, color: s.id === selectedId ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||
{s.type === 'manual' ? '⭐ ' : ''}{s.label}
|
||||
</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatDate(s.timestamp)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 11, color: 'var(--text-secondary)', gap: 12 }}>
|
||||
<span>{t('snapshot.countChapters').replace('{count}', s.stats?.chapterCount || 0)}</span>
|
||||
<span>{t('snapshot.countWords').replace('{count}', Math.round((s.stats?.totalWords || 0) / 1000))}</span>
|
||||
<span>{t('snapshot.countSettings').replace('{count}', s.stats?.settingCount || 0)}</span>
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={(e) => handleDelete(s.id, e)}
|
||||
style={{
|
||||
position: 'absolute', right: 10, bottom: 10,
|
||||
background: 'none', border: 'none', color: 'var(--error)',
|
||||
cursor: 'pointer', opacity: 0.5, fontSize: 12
|
||||
}}
|
||||
onMouseEnter={e => e.target.style.opacity = 1}
|
||||
onMouseLeave={e => e.target.style.opacity = 0.5}
|
||||
>
|
||||
{t('snapshot.deleteBtn')}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
<div style={{ flex: 1, padding: '30px', display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', overflowY: 'auto' }}>
|
||||
{selectedSnap ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: 20, marginBottom: 8 }}>{selectedSnap.type === 'manual' ? '⭐ ' : ''}{selectedSnap.label}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 24 }}>
|
||||
{t('snapshot.createdAt')} {new Date(selectedSnap.timestamp).toLocaleString()}
|
||||
{selectedSnap.type === 'auto' && ` ${t('snapshot.autoLabel')}`}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 30 }}>
|
||||
<div style={{ flex: 1, background: 'var(--bg-primary)', padding: '16px', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-light)' }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedSnap.stats?.chapterCount || 0}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{t('snapshot.chapterCount')}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, background: 'var(--bg-primary)', padding: '16px', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-light)' }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{(selectedSnap.stats?.totalWords || 0).toLocaleString()}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{t('snapshot.totalWords')}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, background: 'var(--bg-primary)', padding: '16px', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-light)' }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedSnap.stats?.settingCount || 0}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{t('snapshot.settingCount')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 30 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 10, color: 'var(--text-secondary)' }}>{t('snapshot.includedChapters')}</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(selectedSnap.data?.chapters || []).slice(0, 10).map(ch => (
|
||||
<span key={ch.id} style={{ fontSize: 12, padding: '4px 8px', background: 'var(--bg-primary)', border: '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)' }}>
|
||||
{ch.title}
|
||||
</span>
|
||||
))}
|
||||
{(selectedSnap.data?.chapters || []).length > 10 && (
|
||||
<span style={{ fontSize: 12, padding: '4px 8px', color: 'var(--text-muted)' }}>{t('snapshot.andMore')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto', paddingTop: 20, borderTop: '1px solid var(--border-light)', textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--error)', borderColor: 'var(--error)', padding: '10px 24px', fontSize: 14 }}
|
||||
onClick={handleRestore}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? t('snapshot.restoring') : t('snapshot.restoreBtn')}
|
||||
</button>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
{t('snapshot.restoreWarning')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-muted)' }}>
|
||||
{t('snapshot.selectHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
|
||||
const ONBOARDING_KEY = 'author-onboarding-done';
|
||||
|
||||
export default function TourOverlay({ onOpenHelp }) {
|
||||
const { startTour, setStartTour, language, visualTheme } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState('hidden'); // hidden, intro, tour
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [targetRect, setTargetRect] = useState(null);
|
||||
const [windowSize, setWindowSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
// 根据当前语言动态生成 TOUR_STEPS
|
||||
const TOUR_STEPS = useMemo(() => [
|
||||
{
|
||||
targetId: 'tour-new-chapter',
|
||||
title: t('tour.step1Title'),
|
||||
content: t('tour.step1Content'),
|
||||
placement: 'right'
|
||||
},
|
||||
{
|
||||
targetId: 'tour-editor',
|
||||
title: t('tour.step2Title'),
|
||||
content: t('tour.step2Content'),
|
||||
placement: 'center'
|
||||
},
|
||||
{
|
||||
targetId: 'tour-ai-btn',
|
||||
title: t('tour.step3Title'),
|
||||
content: t('tour.step3Content'),
|
||||
placement: 'left'
|
||||
},
|
||||
{
|
||||
targetId: 'tour-settings',
|
||||
title: t('tour.step4Title'),
|
||||
content: t('tour.step4Content'),
|
||||
placement: 'top'
|
||||
},
|
||||
{
|
||||
targetId: 'tour-help',
|
||||
title: t('tour.step5Title'),
|
||||
content: t('tour.step5Content'),
|
||||
placement: 'left'
|
||||
}
|
||||
], [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const isDone = localStorage.getItem(ONBOARDING_KEY);
|
||||
if (!isDone) {
|
||||
if (language && visualTheme) {
|
||||
setStatus('intro');
|
||||
}
|
||||
}
|
||||
setWindowSize({ w: window.innerWidth, h: window.innerHeight });
|
||||
}
|
||||
}, [language, visualTheme]);
|
||||
|
||||
const updateRect = useCallback((index) => {
|
||||
const step = TOUR_STEPS[index];
|
||||
if (!step) return;
|
||||
|
||||
let el = document.getElementById(step.targetId);
|
||||
if (!el && step.targetId === 'tour-editor') {
|
||||
el = document.querySelector('.editor-container');
|
||||
}
|
||||
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setTargetRect({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const retryEl = document.getElementById(step.targetId) || document.querySelector('.editor-container');
|
||||
if (retryEl) {
|
||||
const r = retryEl.getBoundingClientRect();
|
||||
setTargetRect({ x: r.x, y: r.y, width: r.width, height: r.height });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, [TOUR_STEPS]);
|
||||
|
||||
const finishTour = () => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
setStatus('hidden');
|
||||
};
|
||||
|
||||
const skipToHelp = () => {
|
||||
setStatus('tour');
|
||||
setCurrentStepIndex(TOUR_STEPS.length - 1);
|
||||
updateRect(TOUR_STEPS.length - 1);
|
||||
};
|
||||
|
||||
const beginTour = useCallback(() => {
|
||||
setStatus('tour');
|
||||
setCurrentStepIndex(0);
|
||||
updateRect(0);
|
||||
}, [updateRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startTour) {
|
||||
beginTour();
|
||||
setStartTour(false);
|
||||
}
|
||||
}, [startTour, beginTour, setStartTour]);
|
||||
|
||||
// 监听窗口缩放和滚动
|
||||
useEffect(() => {
|
||||
if (status !== 'tour') return;
|
||||
|
||||
const handleResize = () => {
|
||||
setWindowSize({ w: window.innerWidth, h: window.innerHeight });
|
||||
updateRect(currentStepIndex);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('scroll', handleResize, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('scroll', handleResize, true);
|
||||
};
|
||||
}, [status, currentStepIndex, updateRect]);
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStepIndex === TOUR_STEPS.length - 1) {
|
||||
finishTour();
|
||||
} else {
|
||||
setCurrentStepIndex(i => i + 1);
|
||||
updateRect(currentStepIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStepIndex > 0) {
|
||||
setCurrentStepIndex(i => i - 1);
|
||||
updateRect(currentStepIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'hidden') return null;
|
||||
|
||||
// ================= Intro 弹窗 =================
|
||||
if (status === 'intro') {
|
||||
return (
|
||||
<div className="tour-overlay-bg">
|
||||
<div className="tour-intro-modal">
|
||||
<div className="tour-intro-icon">✨</div>
|
||||
<h2>{t('tour.introTitle')}</h2>
|
||||
<p>{t('tour.introSubtitle')}</p>
|
||||
<p className="tour-intro-sub">{t('tour.introHint')}</p>
|
||||
<div className="tour-intro-actions">
|
||||
<button className="tour-btn ghost" onClick={skipToHelp}>{t('tour.btnSkip')}</button>
|
||||
<button className="tour-btn primary" onClick={beginTour}>{t('tour.btnStart')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================= 具体步骤向导 (Tour) =================
|
||||
const step = TOUR_STEPS[currentStepIndex];
|
||||
if (!step) return null;
|
||||
|
||||
const padding = 8;
|
||||
const rX = targetRect ? targetRect.x - padding : 0;
|
||||
const rY = targetRect ? targetRect.y - padding : 0;
|
||||
const rW = targetRect ? targetRect.width + padding * 2 : 0;
|
||||
const rH = targetRect ? targetRect.height + padding * 2 : 0;
|
||||
const radius = 8;
|
||||
|
||||
let tooltipStyle = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
||||
if (targetRect) {
|
||||
if (step.placement === 'right') {
|
||||
tooltipStyle = { top: rY + rH / 2, left: rX + rW + 20, transform: 'translateY(-50%)' };
|
||||
} else if (step.placement === 'left') {
|
||||
tooltipStyle = { top: rY + rH / 2, right: windowSize.w - rX + 20, transform: 'translateY(-50%)' };
|
||||
} else if (step.placement === 'bottom') {
|
||||
tooltipStyle = { top: rY + rH + 20, left: rX + rW / 2, transform: 'translateX(-50%)' };
|
||||
} else if (step.placement === 'center') {
|
||||
tooltipStyle = { top: rY + rH / 2, left: rX + rW / 2, transform: 'translate(-50%, -50%)' };
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipHeightGuess = 200;
|
||||
|
||||
if (tooltipStyle.top && typeof tooltipStyle.top === 'number') {
|
||||
if (tooltipStyle.top < tooltipHeightGuess / 2) {
|
||||
tooltipStyle.top = tooltipHeightGuess / 2;
|
||||
} else if (tooltipStyle.top > windowSize.h - tooltipHeightGuess / 2) {
|
||||
tooltipStyle.top = windowSize.h - tooltipHeightGuess / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (tooltipStyle.bottom && typeof tooltipStyle.bottom === 'number') {
|
||||
if (tooltipStyle.bottom < 20) {
|
||||
tooltipStyle.bottom = 20;
|
||||
}
|
||||
}
|
||||
|
||||
const validateStyle = { ...tooltipStyle };
|
||||
|
||||
return (
|
||||
<div className="tour-portal">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="tour-svg-mask"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<mask id="tour-hole">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
{targetRect && (
|
||||
<rect
|
||||
x={rX} y={rY} width={rW} height={rH}
|
||||
rx={radius} ry={radius}
|
||||
fill="black"
|
||||
/>
|
||||
)}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%" height="100%"
|
||||
fill="var(--tour-overlay-color, rgba(0,0,0,0.6))"
|
||||
mask="url(#tour-hole)"
|
||||
/>
|
||||
|
||||
{targetRect && (
|
||||
<rect
|
||||
x={rX} y={rY} width={rW} height={rH}
|
||||
rx={radius} ry={radius}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="2"
|
||||
className="tour-target-outline"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{targetRect && (
|
||||
<div className="tour-tooltip" style={validateStyle}>
|
||||
<div className="tour-tooltip-header">
|
||||
<h3>{step.title}</h3>
|
||||
<span className="tour-step-counter">{currentStepIndex + 1} / {TOUR_STEPS.length}</span>
|
||||
</div>
|
||||
<div className="tour-tooltip-content">
|
||||
{step.content}
|
||||
</div>
|
||||
<div className="tour-tooltip-footer">
|
||||
{currentStepIndex === TOUR_STEPS.length - 1 ? (
|
||||
<div />
|
||||
) : (
|
||||
<button className="tour-skip-text" onClick={skipToHelp}>{t('tour.btnEndTour')}</button>
|
||||
)}
|
||||
<div className="tour-footer-right" style={{ display: 'flex', gap: '8px' }}>
|
||||
{currentStepIndex > 0 && (
|
||||
<button className="tour-btn ghost" onClick={prevStep}>{t('tour.btnPrev')}</button>
|
||||
)}
|
||||
{currentStepIndex === TOUR_STEPS.length - 1 ? (
|
||||
<button className="tour-btn primary" onClick={finishTour}>{t('tour.btnFinish')}</button>
|
||||
) : (
|
||||
<button className="tour-btn primary" onClick={nextStep}>{t('tour.btnNext')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useI18n } from '../lib/useI18n';
|
||||
|
||||
export default function WelcomeModal() {
|
||||
const { language, setLanguage, visualTheme, setVisualTheme } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState(1);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (!language || !visualTheme) {
|
||||
setIsVisible(true);
|
||||
if (!language) {
|
||||
setStep(1);
|
||||
} else {
|
||||
setStep(2);
|
||||
}
|
||||
}
|
||||
}, [language, visualTheme]);
|
||||
|
||||
if (!mounted || !isVisible) return null;
|
||||
|
||||
const handleSelectLang = (lang) => {
|
||||
setLanguage(lang);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleSelectTheme = (theme) => {
|
||||
setVisualTheme(theme);
|
||||
// Apply theme to document element immediately for preview
|
||||
document.documentElement.setAttribute('data-visual', theme);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!visualTheme) {
|
||||
handleSelectTheme('warm');
|
||||
}
|
||||
setIsVisible(false);
|
||||
if (!localStorage.getItem('author-onboarding-done')) {
|
||||
useAppStore.getState().setStartTour(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="welcome-modal-overlay">
|
||||
<div className={`welcome-modal-container ${step === 2 ? 'step-2' : ''}`}>
|
||||
{/* Step 1: Language */}
|
||||
{step === 1 && (
|
||||
<div className="welcome-step fadeIn">
|
||||
<h1 className="welcome-title">Welcome / 欢迎 / Добро пожаловать</h1>
|
||||
<div className="welcome-lang-grid">
|
||||
<button className="welcome-card" onClick={() => handleSelectLang('en')}>
|
||||
<span className="welcome-icon">🇬🇧</span>
|
||||
<span className="welcome-label">English</span>
|
||||
</button>
|
||||
<button className="welcome-card" onClick={() => handleSelectLang('zh')}>
|
||||
<span className="welcome-icon">🇨🇳</span>
|
||||
<span className="welcome-label">简体中文</span>
|
||||
</button>
|
||||
<button className="welcome-card" onClick={() => handleSelectLang('ru')}>
|
||||
<span className="welcome-icon">🇷🇺</span>
|
||||
<span className="welcome-label">Русский</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Theme */}
|
||||
{step === 2 && (
|
||||
<div className="welcome-step fadeIn">
|
||||
<h1 className="welcome-title" style={{ marginBottom: 8 }}>{t('welcome.title')}</h1>
|
||||
<p className="welcome-subtitle" style={{ marginBottom: 32 }}>{t('welcome.subtitle')}</p>
|
||||
|
||||
<h3 className="welcome-section-title">{t('welcome.selectTheme')}</h3>
|
||||
<div className="welcome-theme-grid">
|
||||
<button
|
||||
className={`welcome-card theme-card ${visualTheme === 'warm' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectTheme('warm')}
|
||||
>
|
||||
<div className="theme-preview warm-preview"></div>
|
||||
<div className="theme-info">
|
||||
<h4>{t('welcome.themeWarm.name')}</h4>
|
||||
<p>{t('welcome.themeWarm.desc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`welcome-card theme-card ${visualTheme === 'modern' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectTheme('modern')}
|
||||
>
|
||||
<div className="theme-preview modern-preview"></div>
|
||||
<div className="theme-info">
|
||||
<h4>{t('welcome.themeModern.name')}</h4>
|
||||
<p>{t('welcome.themeModern.desc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="welcome-actions">
|
||||
<button
|
||||
className="btn btn-primary btn-large welcome-start-btn"
|
||||
onClick={handleStart}
|
||||
disabled={!visualTheme}
|
||||
>
|
||||
{t('welcome.startBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import "./globals.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 内联脚本:在 HTML 解析阶段同步读取 theme,避免 hydration 不匹配和闪烁
|
||||
const themeInitScript = `
|
||||
(function() {
|
||||
try {
|
||||
var t = localStorage.getItem('author-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<title>Author - AI辅助创作平台</title>
|
||||
<meta name="description" content="面向小说创作者的AI辅助写作工具,让创作更自由" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Serif+SC:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body suppressHydrationWarning>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
// ==================== 多会话管理模块 ====================
|
||||
|
||||
import { get, set } from 'idb-keyval';
|
||||
|
||||
const STORAGE_KEY = 'author-chat-sessions';
|
||||
|
||||
function generateId(prefix = 'session') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 IndexedDB 读取全部会话数据 (Async)
|
||||
*/
|
||||
export async function loadSessionStore() {
|
||||
if (typeof window === 'undefined') return { activeSessionId: null, sessions: [] };
|
||||
try {
|
||||
let store = await get(STORAGE_KEY);
|
||||
if (!store) {
|
||||
// Fallback: migrate from localStorage
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
store = JSON.parse(raw);
|
||||
await set(STORAGE_KEY, store);
|
||||
}
|
||||
}
|
||||
if (store) return store;
|
||||
} catch { }
|
||||
return { activeSessionId: null, sessions: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存全部会话数据到 IndexedDB (Async)
|
||||
*/
|
||||
export async function saveSessionStore(store) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
await set(STORAGE_KEY, store);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容迁移:将旧 chatHistory 数组迁移为多会话结构
|
||||
*/
|
||||
export function migrateFromLegacy(legacyChatHistory) {
|
||||
if (!legacyChatHistory || legacyChatHistory.length === 0) return null;
|
||||
const session = {
|
||||
id: generateId(),
|
||||
title: autoTitle(legacyChatHistory),
|
||||
createdAt: legacyChatHistory[0]?.timestamp || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: legacyChatHistory,
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从首条用户消息自动生成标题
|
||||
*/
|
||||
function autoTitle(messages) {
|
||||
const first = messages.find(m => m.role === 'user');
|
||||
if (!first) return '新对话';
|
||||
const text = first.content.slice(0, 30);
|
||||
return text.length < first.content.length ? text + '…' : text;
|
||||
}
|
||||
|
||||
// ==================== 会话级操作 ====================
|
||||
|
||||
/**
|
||||
* 创建新的空白会话
|
||||
*/
|
||||
export function createSession(store) {
|
||||
const session = {
|
||||
id: generateId(), title: '新对话', createdAt: Date.now(),
|
||||
updatedAt: Date.now(), messages: [],
|
||||
};
|
||||
const newStore = {
|
||||
activeSessionId: session.id,
|
||||
sessions: [...store.sessions, session],
|
||||
};
|
||||
saveSessionStore(newStore); // Run in background
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
export function deleteSession(store, sessionId) {
|
||||
const remaining = store.sessions.filter(s => s.id !== sessionId);
|
||||
const newActive = store.activeSessionId === sessionId
|
||||
? (remaining[remaining.length - 1]?.id || null)
|
||||
: store.activeSessionId;
|
||||
const newStore = { activeSessionId: newActive, sessions: remaining };
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名会话
|
||||
*/
|
||||
export function renameSession(store, sessionId, newTitle) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s =>
|
||||
s.id === sessionId ? { ...s, title: newTitle, updatedAt: Date.now() } : s
|
||||
),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换活动会话
|
||||
*/
|
||||
export function switchSession(store, sessionId) {
|
||||
const newStore = { ...store, activeSessionId: sessionId };
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前活动会话
|
||||
*/
|
||||
export function getActiveSession(store) {
|
||||
if (!store.activeSessionId) return null;
|
||||
return store.sessions.find(s => s.id === store.activeSessionId) || null;
|
||||
}
|
||||
|
||||
// ==================== 消息级操作 ====================
|
||||
|
||||
/**
|
||||
* 向活动会话添加消息
|
||||
*/
|
||||
export function addMessage(store, msg) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
const messages = [...s.messages, msg];
|
||||
const title = s.messages.length === 0 && msg.role === 'user'
|
||||
? (msg.content.slice(0, 30) + (msg.content.length > 30 ? '…' : ''))
|
||||
: s.title;
|
||||
return { ...s, messages, title, updatedAt: Date.now() };
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活动会话的最后一条消息(用于流式输出拼接)
|
||||
*/
|
||||
export function updateLastMessage(store, updater) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
if (s.messages.length === 0) return s;
|
||||
const messages = [...s.messages];
|
||||
messages[messages.length - 1] = updater(messages[messages.length - 1]);
|
||||
return { ...s, messages, updatedAt: Date.now() };
|
||||
}),
|
||||
};
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑指定消息的内容
|
||||
*/
|
||||
export function editMessage(store, msgId, newContent) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.map(m =>
|
||||
m.id === msgId ? { ...m, content: newContent, editedAt: Date.now() } : m
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定消息
|
||||
*/
|
||||
export function deleteMessage(store, msgId) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.filter(m => m.id !== msgId),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定消息创建分支 — 复制该消息及之前的所有消息到新会话
|
||||
*/
|
||||
export function createBranch(store, msgId) {
|
||||
const activeSession = getActiveSession(store);
|
||||
if (!activeSession) return store;
|
||||
|
||||
const msgIndex = activeSession.messages.findIndex(m => m.id === msgId);
|
||||
if (msgIndex < 0) return store;
|
||||
|
||||
const branchedMessages = activeSession.messages.slice(0, msgIndex + 1).map(m => ({
|
||||
...m, id: generateId('msg'),
|
||||
}));
|
||||
|
||||
const branchSession = {
|
||||
id: generateId(), title: `${activeSession.title} (分支)`,
|
||||
createdAt: Date.now(), updatedAt: Date.now(), messages: branchedMessages,
|
||||
};
|
||||
|
||||
const newStore = {
|
||||
activeSessionId: branchSession.id,
|
||||
sessions: [...store.sessions, branchSession],
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换活动会话的消息列表(用于总结等批量操作)
|
||||
*/
|
||||
export function replaceMessages(store, newMessages) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
return { ...s, messages: newMessages, updatedAt: Date.now() };
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定消息添加一个变体(多次重新生成的结果)
|
||||
*/
|
||||
export function addVariant(store, msgId, variantData) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.map(m => {
|
||||
if (m.id !== msgId) return m;
|
||||
const existingVariants = m.variants || [{
|
||||
content: m.content,
|
||||
thinking: m.thinking || '',
|
||||
timestamp: m.timestamp,
|
||||
}];
|
||||
const newVariants = [...existingVariants, variantData];
|
||||
return {
|
||||
...m,
|
||||
variants: newVariants,
|
||||
activeVariant: newVariants.length - 1,
|
||||
content: variantData.content,
|
||||
thinking: variantData.thinking || '',
|
||||
};
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换指定消息的当前显示变体
|
||||
*/
|
||||
export function switchVariant(store, msgId, variantIndex) {
|
||||
const newStore = {
|
||||
...store,
|
||||
sessions: store.sessions.map(s => {
|
||||
if (s.id !== store.activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.map(m => {
|
||||
if (m.id !== msgId || !m.variants) return m;
|
||||
const idx = Math.max(0, Math.min(variantIndex, m.variants.length - 1));
|
||||
const variant = m.variants[idx];
|
||||
return { ...m, activeVariant: idx, content: variant.content, thinking: variant.thinking || '' };
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveSessionStore(newStore);
|
||||
return newStore;
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
// 上下文收集引擎 — 像 Cursor 一样,在每次 AI 调用前自动汇聚所有相关信息
|
||||
// 让 AI 完整理解:作品风格、人物设定、世界观、前文脉络、当前位置
|
||||
|
||||
import { getChapters } from './storage';
|
||||
import { getProjectSettings, getSettingsNodes, getWritingMode, getActiveWorkId } from './settings';
|
||||
import { getEmbedding, cosineSimilarity } from './embeddings';
|
||||
|
||||
// ==================== Token 预算管理 ====================
|
||||
|
||||
export const INPUT_TOKEN_BUDGET = 200000; // 输入预算(发送给AI的上下文)
|
||||
export const OUTPUT_TOKEN_BUDGET = 6000; // 输出预算(AI生成的回复长度,约4500字)
|
||||
|
||||
// 中文 token 估算:约 1.5 个中文字符 = 1 个 token
|
||||
export function estimateTokens(text) {
|
||||
if (!text) return 0;
|
||||
const chineseChars = (text.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/g) || []).length;
|
||||
const otherChars = text.length - chineseChars;
|
||||
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
||||
}
|
||||
|
||||
// 上下文模块优先级(数字越小越优先)
|
||||
const PRIORITY = {
|
||||
writingRules: 1, // 写作规则——必须严格遵守
|
||||
currentChapter: 2, // 当前章节元信息
|
||||
characters: 3, // 人物设定
|
||||
plotOutline: 4, // 大纲
|
||||
bookInfo: 5, // 作品信息
|
||||
worldbuilding: 6, // 世界观
|
||||
locations: 7, // 地点
|
||||
objects: 8, // 物品
|
||||
customSettings: 9, // 自定义设定
|
||||
previousChapters: 10, // 前文回顾(最容易截断)
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取上下文可勾选条目列表(供「📚 参考」Tab 使用) (Async)
|
||||
* 返回扁平化数组,每个条目包含 id, group, name, tokens, category
|
||||
*/
|
||||
export async function getContextItems(activeChapterId) {
|
||||
const settings = getProjectSettings();
|
||||
const chapters = await getChapters();
|
||||
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
|
||||
|
||||
const allNodes = await getSettingsNodes();
|
||||
const activeWorkId = getActiveWorkId();
|
||||
|
||||
// 按当前作品过滤
|
||||
let nodes;
|
||||
if (activeWorkId) {
|
||||
const workDesc = new Set();
|
||||
const coll = (pid) => { allNodes.filter(n => n.parentId === pid).forEach(n => { workDesc.add(n.id); coll(n.id); }); };
|
||||
workDesc.add(activeWorkId);
|
||||
coll(activeWorkId);
|
||||
nodes = allNodes.filter(n => workDesc.has(n.id));
|
||||
} else {
|
||||
nodes = allNodes;
|
||||
}
|
||||
const itemNodes = nodes.filter(n => n.type === 'item');
|
||||
|
||||
const items = [];
|
||||
|
||||
// 分类映射
|
||||
const categoryMap = {
|
||||
rules: { group: '📐 写作规则', builder: (ns) => buildRulesContext(ns) },
|
||||
character: { group: '👤 人物设定', builder: (ns) => buildCharactersContext(ns) },
|
||||
location: { group: '🗺️ 空间/地点', builder: (ns) => buildLocationsContext(ns, nodes) },
|
||||
world: { group: '🌍 世界观', builder: (ns) => buildWorldContext(ns, nodes) },
|
||||
object: { group: '🔮 物品/道具', builder: (ns) => buildObjectsContext(ns, nodes) },
|
||||
plot: { group: '📋 大纲', builder: (ns) => buildPlotContext(ns, nodes) },
|
||||
custom: { group: '⚙️ 自定义', builder: (ns) => buildCustomContext(ns, nodes) },
|
||||
};
|
||||
|
||||
// 设定条目
|
||||
for (const [cat, config] of Object.entries(categoryMap)) {
|
||||
const catNodes = itemNodes.filter(n => n.category === cat);
|
||||
if (catNodes.length === 0) {
|
||||
// 检查是否有该分类的文件夹 — 如果有,显示空提示
|
||||
const hasFolder = nodes.some(n => n.type === 'folder' && n.category === cat);
|
||||
if (hasFolder) {
|
||||
items.push({
|
||||
id: `empty-${cat}`,
|
||||
group: config.group,
|
||||
name: '(暂无条目)',
|
||||
tokens: 0,
|
||||
category: cat,
|
||||
enabled: false,
|
||||
_empty: true,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const n of catNodes) {
|
||||
const text = config.builder([n]);
|
||||
items.push({
|
||||
id: `setting-${n.id}`,
|
||||
group: config.group,
|
||||
name: n.name,
|
||||
tokens: estimateTokens(text),
|
||||
category: cat,
|
||||
enabled: n.enabled !== false,
|
||||
_nodeId: n.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 作品信息
|
||||
const bookInfo = settings.bookInfo;
|
||||
if (bookInfo && (bookInfo.title || bookInfo.author || bookInfo.genre || bookInfo.synopsis)) {
|
||||
items.push({
|
||||
id: 'bookinfo',
|
||||
group: '📖 作品信息',
|
||||
name: bookInfo.title || '作品信息',
|
||||
tokens: estimateTokens(buildBookInfoContext(bookInfo)),
|
||||
category: 'bookInfo',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 章节条目 — 显示全部章节
|
||||
chapters.forEach((ch, i) => {
|
||||
const text = stripHtml(ch.content || '');
|
||||
if (i === currentIndex) {
|
||||
// 当前章节
|
||||
items.push({
|
||||
id: `chapter-current`,
|
||||
group: '📑 章节',
|
||||
name: `第${i + 1}章 ${ch.title}(当前)`,
|
||||
tokens: estimateTokens(buildCurrentContext(ch, i, chapters.length)),
|
||||
category: 'currentChapter',
|
||||
enabled: true,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: `chapter-${ch.id}`,
|
||||
group: '📑 章节',
|
||||
name: `第${i + 1}章 ${ch.title}${i > currentIndex ? '(后续)' : ''}`,
|
||||
tokens: estimateTokens(text ? text.slice(0, 1500) : ''),
|
||||
category: 'chapter',
|
||||
enabled: i < currentIndex, // 前文章节默认启用,后续章节默认不启用
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 AI 上下文 (Async)
|
||||
* @param {string} activeChapterId
|
||||
* @param {string} selectedText
|
||||
* @param {Set|null} selectedIds - 如果提供,只包含 id 在此 Set 中的条目
|
||||
*/
|
||||
export async function buildContext(activeChapterId, selectedText, selectedIds = null) {
|
||||
const settings = getProjectSettings();
|
||||
const chapters = await getChapters();
|
||||
const currentChapter = chapters.find(ch => ch.id === activeChapterId);
|
||||
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
|
||||
|
||||
// 从树形节点读取设定(过滤掉禁用项,并按当前作品过滤)
|
||||
const allNodes = await getSettingsNodes();
|
||||
const activeWorkId = getActiveWorkId();
|
||||
|
||||
// 收集当前作品的所有后代节点
|
||||
let nodes;
|
||||
if (activeWorkId) {
|
||||
const workDescendants = new Set();
|
||||
const collectDesc = (pid) => {
|
||||
allNodes.filter(n => n.parentId === pid).forEach(n => {
|
||||
workDescendants.add(n.id);
|
||||
collectDesc(n.id);
|
||||
});
|
||||
};
|
||||
workDescendants.add(activeWorkId);
|
||||
collectDesc(activeWorkId);
|
||||
nodes = allNodes.filter(n => workDescendants.has(n.id));
|
||||
} else {
|
||||
nodes = allNodes;
|
||||
}
|
||||
|
||||
// 获取所有有效的设定条目
|
||||
const allValidItemNodes = nodes.filter(n => n.type === 'item' && n.enabled !== false);
|
||||
|
||||
// 如果有 selectedIds,提取手动勾选的设定
|
||||
const manualItemNodes = [];
|
||||
const unselectedItemNodes = [];
|
||||
|
||||
for (const n of allValidItemNodes) {
|
||||
// 规则类总是作为手动(强制生效)如果 enabled
|
||||
if (n.category === 'rules') {
|
||||
manualItemNodes.push(n);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedIds && selectedIds.has(`setting-${n.id}`)) {
|
||||
manualItemNodes.push(n);
|
||||
} else if (!selectedIds) {
|
||||
// 如果 selectedIds 为 null,默认所有启用的项都算作手动(兼容旧逻辑,或视情况全转RAG)
|
||||
// 为了发挥 RAG 威力,如果 selectedIds 为 null,我们可以假设除了规则外,全部走向 RAG,
|
||||
// 或者全部走 Manual。这里我们设定期望:全部走 RAG 自动检索。
|
||||
unselectedItemNodes.push(n);
|
||||
} else {
|
||||
unselectedItemNodes.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
// --- RAG 自动检索 ---
|
||||
let autoRetrievedNodes = [];
|
||||
const queryText = (selectedText || '').trim();
|
||||
if (queryText && unselectedItemNodes.length > 0) {
|
||||
try {
|
||||
// 获取 Query 的 Embedding
|
||||
// 此处的文本可以是光标前的一小段文字,为了简便使用 selectedText
|
||||
const { apiConfig } = getProjectSettings();
|
||||
// TODO: 这里如果是空文本,可能需要补当前章节最近的内容
|
||||
let ragSourceText = queryText;
|
||||
if (ragSourceText.length < 50 && currentChapter) {
|
||||
// 文本太短,用当前章节末尾补充
|
||||
const stripChapText = stripHtml(currentChapter.content || '').slice(-200);
|
||||
ragSourceText = ragSourceText + '\n' + stripChapText;
|
||||
}
|
||||
|
||||
const queryVector = await getEmbedding(ragSourceText, apiConfig);
|
||||
if (queryVector) {
|
||||
// 计算相似度
|
||||
const scoredNodes = unselectedItemNodes.map(n => {
|
||||
if (!n.embedding) return { node: n, score: -1 };
|
||||
return { node: n, score: cosineSimilarity(queryVector, n.embedding) };
|
||||
}).filter(x => x.score > 0.3); // 设置一个基础阈值 0.3
|
||||
|
||||
// 按分数降序
|
||||
scoredNodes.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 取 Top-K (比如最多取 5 个)
|
||||
autoRetrievedNodes = scoredNodes.slice(0, 5).map(x => x.node);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('RAG Retrieval failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并手动与自动检索的节点
|
||||
const itemNodes = [...manualItemNodes, ...autoRetrievedNodes];
|
||||
// 去重
|
||||
const finalItemNodes = Array.from(new Set(itemNodes));
|
||||
|
||||
const writingMode = getWritingMode();
|
||||
|
||||
// 先构建各模块的原始文本
|
||||
const rawModules = {
|
||||
bookInfo: (!selectedIds || selectedIds.has('bookinfo')) ? buildBookInfoContext(settings.bookInfo) : '',
|
||||
characters: buildCharactersContext(finalItemNodes.filter(n => n.category === 'character')),
|
||||
locations: buildLocationsContext(finalItemNodes.filter(n => n.category === 'location'), nodes),
|
||||
worldbuilding: buildWorldContext(finalItemNodes.filter(n => n.category === 'world'), nodes),
|
||||
objects: buildObjectsContext(finalItemNodes.filter(n => n.category === 'object'), nodes),
|
||||
plotOutline: buildPlotContext(finalItemNodes.filter(n => n.category === 'plot'), nodes),
|
||||
writingRules: buildRulesContext(finalItemNodes.filter(n => n.category === 'rules')),
|
||||
customSettings: buildCustomContext(finalItemNodes.filter(n => n.category === 'custom'), nodes),
|
||||
previousChapters: selectedIds
|
||||
? buildPreviousContextFiltered(chapters, currentIndex, selectedIds)
|
||||
: buildPreviousContext(chapters, currentIndex),
|
||||
currentChapter: (!selectedIds || selectedIds.has('chapter-current'))
|
||||
? buildCurrentContext(currentChapter, currentIndex, chapters.length)
|
||||
: '',
|
||||
};
|
||||
|
||||
// 按优先级分配 token 预算
|
||||
const budgetedModules = applyTokenBudget(rawModules);
|
||||
|
||||
const context = {
|
||||
writingMode,
|
||||
...budgetedModules,
|
||||
focusText: selectedText || '',
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级分配 token 预算,超出时截断低优先级内容
|
||||
*/
|
||||
function applyTokenBudget(modules) {
|
||||
// 计算每个模块的 token
|
||||
const entries = Object.entries(modules).map(([key, text]) => ({
|
||||
key,
|
||||
text: text || '',
|
||||
tokens: estimateTokens(text),
|
||||
priority: PRIORITY[key] || 99,
|
||||
}));
|
||||
|
||||
// 按优先级排序
|
||||
entries.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
let remaining = INPUT_TOKEN_BUDGET;
|
||||
const result = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.tokens <= remaining) {
|
||||
result[entry.key] = entry.text;
|
||||
remaining -= entry.tokens;
|
||||
} else if (remaining > 100) {
|
||||
// 截断:按比例保留
|
||||
const ratio = remaining / entry.tokens;
|
||||
const keepChars = Math.floor(entry.text.length * ratio * 0.9); // 留10%余量
|
||||
result[entry.key] = entry.text.slice(0, keepChars) + '\n…(因 token 限制,部分内容已省略)';
|
||||
remaining = 0;
|
||||
} else {
|
||||
result[entry.key] = ''; // 预算耗尽
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上下文预览(各模块状态和 token 估算) (Async)
|
||||
*/
|
||||
export async function getContextPreview(activeChapterId, selectedText) {
|
||||
const settings = getProjectSettings();
|
||||
const chapters = await getChapters();
|
||||
const currentChapter = chapters.find(ch => ch.id === activeChapterId);
|
||||
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
|
||||
|
||||
const allNodes = await getSettingsNodes();
|
||||
const activeWorkId = getActiveWorkId();
|
||||
|
||||
// 按当前作品过滤
|
||||
let nodes;
|
||||
if (activeWorkId) {
|
||||
const workDesc = new Set();
|
||||
const coll = (pid) => { allNodes.filter(n => n.parentId === pid).forEach(n => { workDesc.add(n.id); coll(n.id); }); };
|
||||
workDesc.add(activeWorkId);
|
||||
coll(activeWorkId);
|
||||
nodes = allNodes.filter(n => workDesc.has(n.id));
|
||||
} else {
|
||||
nodes = allNodes;
|
||||
}
|
||||
const allItemNodes = nodes.filter(n => n.type === 'item');
|
||||
const enabledItemNodes = allItemNodes.filter(n => n.enabled !== false);
|
||||
|
||||
const modules = [
|
||||
{
|
||||
key: 'writingRules',
|
||||
label: '📐 写作规则',
|
||||
count: enabledItemNodes.filter(n => n.category === 'rules').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'rules').length,
|
||||
tokens: estimateTokens(buildRulesContext(enabledItemNodes.filter(n => n.category === 'rules'))),
|
||||
priority: PRIORITY.writingRules,
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
label: '👤 人物设定',
|
||||
count: enabledItemNodes.filter(n => n.category === 'character').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'character').length,
|
||||
tokens: estimateTokens(buildCharactersContext(enabledItemNodes.filter(n => n.category === 'character'))),
|
||||
priority: PRIORITY.characters,
|
||||
},
|
||||
{
|
||||
key: 'locations',
|
||||
label: '🗺️ 空间/地点',
|
||||
count: enabledItemNodes.filter(n => n.category === 'location').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'location').length,
|
||||
tokens: estimateTokens(buildLocationsContext(enabledItemNodes.filter(n => n.category === 'location'), nodes)),
|
||||
priority: PRIORITY.locations,
|
||||
},
|
||||
{
|
||||
key: 'worldbuilding',
|
||||
label: '🌍 世界观',
|
||||
count: enabledItemNodes.filter(n => n.category === 'world').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'world').length,
|
||||
tokens: estimateTokens(buildWorldContext(enabledItemNodes.filter(n => n.category === 'world'), nodes)),
|
||||
priority: PRIORITY.worldbuilding,
|
||||
},
|
||||
{
|
||||
key: 'objects',
|
||||
label: '🔮 物品/道具',
|
||||
count: enabledItemNodes.filter(n => n.category === 'object').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'object').length,
|
||||
tokens: estimateTokens(buildObjectsContext(enabledItemNodes.filter(n => n.category === 'object'), nodes)),
|
||||
priority: PRIORITY.objects,
|
||||
},
|
||||
{
|
||||
key: 'plotOutline',
|
||||
label: '📋 大纲',
|
||||
count: enabledItemNodes.filter(n => n.category === 'plot').length,
|
||||
totalCount: allItemNodes.filter(n => n.category === 'plot').length,
|
||||
tokens: estimateTokens(buildPlotContext(enabledItemNodes.filter(n => n.category === 'plot'), nodes)),
|
||||
priority: PRIORITY.plotOutline,
|
||||
},
|
||||
{
|
||||
key: 'bookInfo',
|
||||
label: '📖 作品信息',
|
||||
count: settings.bookInfo?.title ? 1 : 0,
|
||||
totalCount: 1,
|
||||
tokens: estimateTokens(buildBookInfoContext(settings.bookInfo)),
|
||||
priority: PRIORITY.bookInfo,
|
||||
},
|
||||
{
|
||||
key: 'previousChapters',
|
||||
label: '📑 前文回顾',
|
||||
count: Math.max(0, currentIndex),
|
||||
totalCount: Math.max(0, currentIndex),
|
||||
tokens: estimateTokens(buildPreviousContext(chapters, currentIndex)),
|
||||
priority: PRIORITY.previousChapters,
|
||||
},
|
||||
{
|
||||
key: 'currentChapter',
|
||||
label: '✏️ 当前章节',
|
||||
count: currentChapter ? 1 : 0,
|
||||
totalCount: 1,
|
||||
tokens: estimateTokens(buildCurrentContext(currentChapter, currentIndex, chapters.length)),
|
||||
priority: PRIORITY.currentChapter,
|
||||
},
|
||||
];
|
||||
|
||||
const totalTokens = modules.reduce((sum, m) => sum + m.tokens, 0);
|
||||
|
||||
return {
|
||||
modules,
|
||||
totalTokens,
|
||||
inputBudget: INPUT_TOKEN_BUDGET,
|
||||
outputBudget: OUTPUT_TOKEN_BUDGET,
|
||||
budget: INPUT_TOKEN_BUDGET, // 兼容旧字段
|
||||
overBudget: totalTokens > INPUT_TOKEN_BUDGET,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将上下文编译成系统提示词
|
||||
*/
|
||||
export function compileSystemPrompt(context, mode) {
|
||||
const sections = [];
|
||||
|
||||
sections.push(getModeRolePrompt(context.writingMode));
|
||||
|
||||
if (context.bookInfo) {
|
||||
sections.push(`【作品信息】\n${context.bookInfo}`);
|
||||
}
|
||||
if (context.characters) {
|
||||
sections.push(`【人物档案】\n以下是本作品中的重要角色,写作时必须严格遵循他们的设定:\n${context.characters}`);
|
||||
}
|
||||
if (context.locations) {
|
||||
sections.push(`【空间/地点】\n以下是本作品中的重要场所:\n${context.locations}`);
|
||||
}
|
||||
if (context.worldbuilding) {
|
||||
sections.push(`【世界观设定】\n以下是本作品的世界观,所有内容必须在这个框架内:\n${context.worldbuilding}`);
|
||||
}
|
||||
if (context.objects) {
|
||||
sections.push(`【物品/道具】\n以下是本作品中的重要物品:\n${context.objects}`);
|
||||
}
|
||||
if (context.plotOutline) {
|
||||
sections.push(`【剧情大纲】\n${context.plotOutline}`);
|
||||
}
|
||||
if (context.writingRules) {
|
||||
sections.push(`【写作规则——必须严格遵守】\n${context.writingRules}`);
|
||||
}
|
||||
if (context.customSettings) {
|
||||
sections.push(`【补充设定】\n${context.customSettings}`);
|
||||
}
|
||||
if (context.previousChapters) {
|
||||
sections.push(`【前文回顾】\n以下是之前章节的主要内容,续写时必须保持连贯:\n${context.previousChapters}`);
|
||||
}
|
||||
if (context.currentChapter) {
|
||||
sections.push(`【当前写作位置】\n${context.currentChapter}`);
|
||||
}
|
||||
|
||||
const modeInstruction = getModeInstruction(mode);
|
||||
sections.push(`【你的任务】\n${modeInstruction}`);
|
||||
|
||||
return sections.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户提示词
|
||||
*/
|
||||
export function compileUserPrompt(mode, text, instruction) {
|
||||
switch (mode) {
|
||||
case 'continue':
|
||||
if (!text || !text.trim()) {
|
||||
return instruction
|
||||
? `请根据以下要求开始创作新内容(约300-500字):\n要求:${instruction}`
|
||||
: '请根据设定集信息,开始撰写新的章节内容(约300-500字)。';
|
||||
}
|
||||
return instruction
|
||||
? `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n要求:${instruction}\n\n「${text}」`
|
||||
: `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n\n「${text}」`;
|
||||
case 'rewrite':
|
||||
return instruction
|
||||
? `按照以下要求改写文本:\n要求:${instruction}\n\n原文:\n「${text}」`
|
||||
: `请润色改写以下文本,提升文学质量:\n\n「${text}」`;
|
||||
case 'expand':
|
||||
return `请扩写以下文本,加入更丰富的细节和描写(约为原文1.5-2倍):\n\n「${text}」`;
|
||||
case 'condense':
|
||||
return `请精简以下文本,保留核心信息,删除冗余:\n\n「${text}」`;
|
||||
case 'dialogue':
|
||||
return `请为以下场景优化或续写人物对话,对话须符合各角色的说话风格:\n\n「${text}」`;
|
||||
case 'chat':
|
||||
// 自由对话模式:instruction 是用户的问题,text 可能是选中文本
|
||||
if (text && instruction) {
|
||||
return `${instruction}\n\n参考文本:\n「${text}」`;
|
||||
}
|
||||
return instruction || text || '请根据设定集信息回答我的问题。';
|
||||
default:
|
||||
return instruction ? `${instruction}\n\n「${text}」` : text;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 内部构建函数 ====================
|
||||
|
||||
function buildBookInfoContext(bookInfo) {
|
||||
if (!bookInfo) return '';
|
||||
const parts = [];
|
||||
if (bookInfo.title) parts.push(`书名:${bookInfo.title}`);
|
||||
if (bookInfo.genre) parts.push(`题材:${bookInfo.genre}`);
|
||||
if (bookInfo.synopsis) parts.push(`故事简介:${bookInfo.synopsis}`);
|
||||
if (bookInfo.style) parts.push(`写作风格:${bookInfo.style}`);
|
||||
if (bookInfo.tone) parts.push(`整体基调:${bookInfo.tone}`);
|
||||
if (bookInfo.pov) parts.push(`叙事视角:${bookInfo.pov}`);
|
||||
if (bookInfo.targetAudience) parts.push(`目标读者:${bookInfo.targetAudience}`);
|
||||
return parts.length > 0 ? parts.join('\n') : '';
|
||||
}
|
||||
|
||||
// 从树节点构建人物上下文
|
||||
function buildCharactersContext(charNodes) {
|
||||
if (!charNodes || charNodes.length === 0) return '';
|
||||
return charNodes.map(n => {
|
||||
const c = n.content || {};
|
||||
const parts = [`【${n.name}】(${c.role || '角色'})(id: ${n.id})`];
|
||||
if (c.age) parts.push(` 年龄:${c.age}`);
|
||||
if (c.gender) parts.push(` 性别:${c.gender}`);
|
||||
if (c.appearance) parts.push(` 外貌:${c.appearance}`);
|
||||
if (c.personality) parts.push(` 性格:${c.personality}`);
|
||||
if (c.background) parts.push(` 背景:${c.background}`);
|
||||
if (c.motivation) parts.push(` 动机/目标:${c.motivation}`);
|
||||
if (c.skills) parts.push(` 能力:${c.skills}`);
|
||||
if (c.speechStyle) parts.push(` 说话风格:${c.speechStyle}`);
|
||||
if (c.relationships) parts.push(` 人物关系:${c.relationships}`);
|
||||
if (c.arc) parts.push(` 成长弧线:${c.arc}`);
|
||||
if (c.notes) parts.push(` 备注:${c.notes}`);
|
||||
return parts.join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 从树节点构建世界观上下文(带层级路径)
|
||||
function buildWorldContext(worldNodes, allNodes) {
|
||||
if (!worldNodes || worldNodes.length === 0) return '';
|
||||
return worldNodes.map(n => {
|
||||
const path = getNodePathStr(n, allNodes);
|
||||
const desc = n.content?.description || '';
|
||||
return `[${path}] (id: ${n.id})
|
||||
${desc}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 从树节点构建大纲上下文
|
||||
function buildPlotContext(plotNodes, allNodes) {
|
||||
if (!plotNodes || plotNodes.length === 0) return '';
|
||||
return plotNodes.map(n => {
|
||||
const path = getNodePathStr(n, allNodes);
|
||||
const status = n.content?.status ? `(${n.content.status})` : '';
|
||||
const desc = n.content?.description || '';
|
||||
return `[${path}]${status} (id: ${n.id})
|
||||
${desc}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 从树节点构建写作规则
|
||||
function buildRulesContext(rulesNodes) {
|
||||
if (!rulesNodes || rulesNodes.length === 0) return '';
|
||||
return rulesNodes.map(n => {
|
||||
const desc = n.content?.description || '';
|
||||
return `${n.name} (id: ${n.id}):
|
||||
${desc}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 从树节点构建自定义设定
|
||||
function buildCustomContext(customNodes, allNodes) {
|
||||
if (!customNodes || customNodes.length === 0) return '';
|
||||
return customNodes.map(n => {
|
||||
const path = getNodePathStr(n, allNodes);
|
||||
const desc = n.content?.description || '';
|
||||
return `[${path}] (id: ${n.id})
|
||||
${desc}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 获取节点路径字符串(不含根分类)
|
||||
function getNodePathStr(node, allNodes) {
|
||||
const path = [];
|
||||
let current = node;
|
||||
while (current) {
|
||||
if (current.parentId !== null) {
|
||||
path.unshift(current.name);
|
||||
}
|
||||
current = current.parentId ? allNodes.find(n => n.id === current.parentId) : null;
|
||||
}
|
||||
return path.join(' / ');
|
||||
}
|
||||
|
||||
function buildPreviousContext(chapters, currentIndex) {
|
||||
if (currentIndex <= 0) return '';
|
||||
|
||||
const prevChapters = chapters.slice(0, currentIndex);
|
||||
|
||||
return prevChapters.map((ch, i) => {
|
||||
const text = stripHtml(ch.content || '');
|
||||
if (!text) return `第${i + 1}章「${ch.title}」:(空)`;
|
||||
|
||||
if (i === currentIndex - 1) {
|
||||
// 前一章:保留更多内容
|
||||
const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text;
|
||||
return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`;
|
||||
}
|
||||
|
||||
// 远距章节:只保留简要摘要
|
||||
if (text.length > 300) {
|
||||
return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`;
|
||||
}
|
||||
return `第${i + 1}章「${ch.title}」:\n${text}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 按 selectedIds 过滤的前文上下文
|
||||
function buildPreviousContextFiltered(chapters, currentIndex, selectedIds) {
|
||||
if (currentIndex <= 0) return '';
|
||||
|
||||
const prevChapters = chapters.slice(0, currentIndex);
|
||||
const selected = prevChapters.filter(ch => selectedIds.has(`chapter-${ch.id}`));
|
||||
if (selected.length === 0) return '';
|
||||
|
||||
return selected.map((ch) => {
|
||||
const i = chapters.indexOf(ch);
|
||||
const text = stripHtml(ch.content || '');
|
||||
if (!text) return `第${i + 1}章「${ch.title}」:(空)`;
|
||||
|
||||
if (i === currentIndex - 1) {
|
||||
const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text;
|
||||
return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`;
|
||||
}
|
||||
|
||||
if (text.length > 300) {
|
||||
return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`;
|
||||
}
|
||||
return `第${i + 1}章「${ch.title}」:\n${text}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
function buildCurrentContext(chapter, index, totalChapters) {
|
||||
if (!chapter) return '';
|
||||
const text = stripHtml(chapter.content || '');
|
||||
const parts = [
|
||||
`当前章节:第${index + 1}章 / 共${totalChapters}章`,
|
||||
`章节标题:「${chapter.title}」`,
|
||||
];
|
||||
if (text) {
|
||||
parts.push(`本章已有字数:${text.replace(/\s/g, '').length}字`);
|
||||
parts.push(`\n--- 本章正文 ---\n${text}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function getModeInstruction(mode) {
|
||||
switch (mode) {
|
||||
case 'continue':
|
||||
return `根据前文的情节走向和当前章节的内容,自然地续写故事。
|
||||
要求:
|
||||
- 续写内容必须与前文情节逻辑严格连贯,不能出现矛盾
|
||||
- 如果涉及已有角色,必须符合其性格设定和说话风格
|
||||
- 场景描写要符合世界观设定
|
||||
- 情节推进要符合大纲规划的方向
|
||||
- 约300-500字`;
|
||||
|
||||
case 'rewrite':
|
||||
return `润色和改写指定文本,提升文学质量。
|
||||
要求:
|
||||
- 保持原文的核心含义和情节不变
|
||||
- 提升感官描写和文学表现力
|
||||
- 确保改写后的内容仍然符合人物设定和世界观
|
||||
- 保持叙事视角一致`;
|
||||
|
||||
case 'expand':
|
||||
return `扩写指定文本,丰富细节。
|
||||
要求:
|
||||
- 增加环境描写、感官细节、心理活动
|
||||
- 深化人物的情感表达
|
||||
- 扩写后必须与前后文衔接自然
|
||||
- 不改变原有情节走向`;
|
||||
|
||||
case 'condense':
|
||||
return `精简指定文本,提升节奏感。
|
||||
要求:
|
||||
- 删除冗余修饰和重复表达
|
||||
- 保留核心信息和关键描写
|
||||
- 保持情节完整性`;
|
||||
|
||||
case 'dialogue':
|
||||
return `优化或续写人物对话。
|
||||
要求:
|
||||
- 每个角色的对话必须符合其性格设定和说话风格
|
||||
- 通过对话推动情节或展现人物关系
|
||||
- 加入适当的动作描写和表情细节
|
||||
- 对话节奏要自然,长短句交替`;
|
||||
|
||||
case 'chat':
|
||||
return `作为创作顾问,根据上述设定集的全部信息回答作者的问题。
|
||||
要求:
|
||||
- 回答必须基于已有的设定信息,不能凭空捏造
|
||||
- 如果问题涉及设定中未覆盖的内容,可以给出基于现有设定的合理建议
|
||||
- 语言简洁明了,尽量具体
|
||||
- 回答以中文呈现
|
||||
|
||||
【设定集管理能力】
|
||||
当用户要求你创建、修改或删除设定集条目时,你可以在回复中嵌入操作指令块。格式如下:
|
||||
|
||||
\`\`\`
|
||||
[SETTINGS_ACTION]
|
||||
{"action":"add","category":"character","name":"角色姓名","content":{"role":"主角","personality":"...","background":"..."}}
|
||||
[/SETTINGS_ACTION]
|
||||
\`\`\`
|
||||
|
||||
可用的 action: "add"(新增)、"update"(更新,需提供 nodeId)、"delete"(删除,需提供 nodeId)
|
||||
|
||||
可用的 category 和对应 content 字段:
|
||||
- "character":角色。content 可含:role, age, gender, appearance, personality, background, motivation, skills, speechStyle, relationships, arc, notes
|
||||
- "world":世界观。content 可含:description
|
||||
- "location":地点。content 可含:description, sensoryVisual, sensoryAudio, sensorySmell, mood, dangerLevel
|
||||
- "object":物品。content 可含:description, objectType, currentHolder, rank, numericStats, symbolism
|
||||
- "plot":大纲。content 可含:description, status
|
||||
- "rules":写作规则。content 可含:description
|
||||
- "custom":自定义。content 可含:description
|
||||
|
||||
使用规则:
|
||||
- 每个操作块只包含一个 JSON 对象
|
||||
- 如果需要多个操作,使用多个 [SETTINGS_ACTION] 块
|
||||
- 操作块前后必须有正常的文字说明
|
||||
- update 示例:{"action":"update","nodeId":"具体id","name":"新名称","content":{...}}
|
||||
- delete 示例:{"action":"delete","nodeId":"具体id"}
|
||||
- 在正文中已有角色/设定出现时,如果用户要求,可以从正文分析内容并自动创建设定`;
|
||||
|
||||
default:
|
||||
return '按照作者的指示完成写作任务,确保内容与已有设定一致。';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 写作模式角色提示词 ====================
|
||||
|
||||
function getModeRolePrompt(writingMode) {
|
||||
const base = `你的核心原则:
|
||||
- 深度理解作品的世界观和人物,绝不写出与设定矛盾的内容
|
||||
- 保持作者已建立的写作风格和语气,你是协作者,不是替代者
|
||||
- 对话要体现角色独特的说话方式和性格
|
||||
- 避免"然而"、"不禁"、"竟然"、"仿佛"等AI味词汇
|
||||
- 情节推进要自然,符合大纲规划和人物动机`;
|
||||
|
||||
switch (writingMode) {
|
||||
case 'webnovel':
|
||||
return `你是一位资深网络小说写手兼编辑,擅长连载节奏把控和爽点设计。你正在协助作者创作一部网络小说。
|
||||
${base}
|
||||
- 注重节奏感和爽点密度,每个章节要有"钩子"吸引读者
|
||||
- 严格维护数值体系(等级、属性、技能冷却等)的一致性
|
||||
- 对话简洁有力,推动剧情和人物关系发展`;
|
||||
|
||||
case 'traditional':
|
||||
return `你是一位经验丰富的文学编辑兼小说家,深谙散文美学和主题构建。你正在协助作者创作一部文学作品。
|
||||
${base}
|
||||
- 使用丰富的感官描写(视觉、听觉、触觉、嗅觉、味觉)
|
||||
- 注重主题的编织和意象的呼应,象征具有层次感
|
||||
- 深入挖掘人物心理,内心独白要体现角色独特的思维方式
|
||||
- 散文质量优先,追求文字的精确性和美感`;
|
||||
|
||||
case 'screenplay':
|
||||
return `你是一位专业的影视编剧兼剧本顾问,精通视觉叙事和对白技巧。你正在协助作者创作一部剧本/脚本。
|
||||
${base}
|
||||
- 以视觉化思维写作,描述"镜头能看到的"而非抽象概念
|
||||
- 对白必须口语化、角色化,每个角色有独特的"声音"
|
||||
- 注意场景的物理逻辑:谁在场、在哪里、什么时间
|
||||
- 保持光照连续性(日/夜)和空间连续性`;
|
||||
|
||||
default:
|
||||
return `你是一位经验丰富的中文作家兼编辑。你正在协助作者创作一部作品。
|
||||
${base}
|
||||
- 使用丰富的感官描写(视觉、听觉、触觉、嗅觉、味觉)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新分类上下文构建 ====================
|
||||
|
||||
// 空间/地点上下文
|
||||
function buildLocationsContext(locationNodes, allNodes) {
|
||||
if (!locationNodes || locationNodes.length === 0) return '';
|
||||
return locationNodes.map(n => {
|
||||
const path = getNodePathStr(n, allNodes);
|
||||
const c = n.content || {};
|
||||
const parts = [`[${path}]`];
|
||||
if (c.description) parts.push(c.description);
|
||||
if (c.slugline) parts.push(`场景标题:${c.slugline}`);
|
||||
if (c.sensoryVisual) parts.push(`视觉:${c.sensoryVisual}`);
|
||||
if (c.sensoryAudio) parts.push(`听觉:${c.sensoryAudio}`);
|
||||
if (c.sensorySmell) parts.push(`嗅觉/触觉:${c.sensorySmell}`);
|
||||
if (c.mood) parts.push(`氛围:${c.mood}`);
|
||||
if (c.dangerLevel) parts.push(`危险等级:${c.dangerLevel}`);
|
||||
return parts.join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 物品/道具上下文
|
||||
function buildObjectsContext(objectNodes, allNodes) {
|
||||
if (!objectNodes || objectNodes.length === 0) return '';
|
||||
return objectNodes.map(n => {
|
||||
const path = getNodePathStr(n, allNodes);
|
||||
const c = n.content || {};
|
||||
const parts = [`[${path}]`];
|
||||
if (c.description) parts.push(c.description);
|
||||
if (c.objectType) parts.push(`类型:${c.objectType}`);
|
||||
if (c.currentHolder) parts.push(`当前持有者:${c.currentHolder}`);
|
||||
if (c.rank) parts.push(`品阶:${c.rank}`);
|
||||
if (c.numericStats) parts.push(`属性:${c.numericStats}`);
|
||||
if (c.symbolism) parts.push(`象征意义:${c.symbolism}`);
|
||||
return parts.join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// 去除HTML标签
|
||||
function stripHtml(html) {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 文本向量化与余弦相似度计算库
|
||||
|
||||
/**
|
||||
* 获取文本的向量化表示 (Embeddings)
|
||||
* @param {string} text 要向量化的文本
|
||||
* @param {object} apiConfig 从 getProjectSettings().apiConfig 传入的配置
|
||||
* @returns {Promise<number[]|null>} 浮点数数组形式的向量
|
||||
*/
|
||||
export async function getEmbedding(text, apiConfig) {
|
||||
if (!text || text.trim() === '') return null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, apiConfig })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('getEmbedding HTTP error:', await res.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
console.error('getEmbedding API error:', data.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.embedding;
|
||||
} catch (err) {
|
||||
console.error('getEmbedding fetch error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个向量之间的余弦相似度
|
||||
* @param {number[]} vecA 向量 A
|
||||
* @param {number[]} vecB 向量 B
|
||||
* @returns {number} 相似度得分 (-1.0 到 1.0)
|
||||
*/
|
||||
export function cosineSimilarity(vecA, vecB) {
|
||||
if (!vecA || !vecB || vecA.length !== vecB.length) return 0;
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < vecA.length; i++) {
|
||||
dotProduct += vecA[i] * vecB[i];
|
||||
normA += vecA[i] * vecA[i];
|
||||
normB += vecB[i] * vecB[i];
|
||||
}
|
||||
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 项目导出/导入 — 将所有 localStorage 数据打包为 JSON 文件
|
||||
* 支持:章节、设定集、API 配置、聊天会话
|
||||
*/
|
||||
|
||||
const PROJECT_FILE_VERSION = 1;
|
||||
|
||||
// 需要导出的所有 localStorage keys
|
||||
const STORAGE_KEYS = {
|
||||
chapters: 'author-chapters',
|
||||
settings: 'author-project-settings',
|
||||
settingsNodes: 'author-settings-nodes',
|
||||
activeWork: 'author-active-work',
|
||||
chatSessions: 'author-chat-sessions',
|
||||
};
|
||||
|
||||
// 章节摘要前缀
|
||||
const SUMMARY_PREFIX = 'author-chapter-summary-';
|
||||
|
||||
/**
|
||||
* 导出整个项目为 JSON 文件并下载
|
||||
*/
|
||||
export function exportProject() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const data = {
|
||||
_version: PROJECT_FILE_VERSION,
|
||||
_exportedAt: new Date().toISOString(),
|
||||
_app: 'Author',
|
||||
};
|
||||
|
||||
// 收集所有主要数据
|
||||
for (const [key, storageKey] of Object.entries(STORAGE_KEYS)) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
data[key] = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
data[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集章节摘要
|
||||
const summaries = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith(SUMMARY_PREFIX)) {
|
||||
const chapterId = k.slice(SUMMARY_PREFIX.length);
|
||||
summaries[chapterId] = localStorage.getItem(k);
|
||||
}
|
||||
}
|
||||
data.chapterSummaries = summaries;
|
||||
|
||||
// 生成文件名
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const fileName = `Author_存档_${dateStr}.json`;
|
||||
|
||||
// 下载
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 文件导入项目数据
|
||||
* @param {File} file - 用户选择的 JSON 文件
|
||||
* @returns {Promise<{ success: boolean, message: string }>}
|
||||
*/
|
||||
export async function importProject(file) {
|
||||
if (typeof window === 'undefined') return { success: false, message: '环境不支持' };
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// 基本校验
|
||||
if (!data._app || data._app !== 'Author') {
|
||||
return { success: false, message: '文件格式不正确,不是 Author 存档文件' };
|
||||
}
|
||||
|
||||
// 恢复主要数据
|
||||
for (const [key, storageKey] of Object.entries(STORAGE_KEYS)) {
|
||||
if (data[key] !== undefined && data[key] !== null) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(data[key]));
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复章节摘要
|
||||
if (data.chapterSummaries && typeof data.chapterSummaries === 'object') {
|
||||
for (const [chapterId, summary] of Object.entries(data.chapterSummaries)) {
|
||||
if (summary) {
|
||||
localStorage.setItem(SUMMARY_PREFIX + chapterId, summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: `成功导入存档(导出时间:${data._exportedAt || '未知'})` };
|
||||
} catch (err) {
|
||||
return { success: false, message: `导入失败:${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前项目数据的概要信息(用于显示)
|
||||
*/
|
||||
export function getProjectSummary() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const chaptersRaw = localStorage.getItem(STORAGE_KEYS.chapters);
|
||||
const chapters = chaptersRaw ? JSON.parse(chaptersRaw) : [];
|
||||
const nodesRaw = localStorage.getItem(STORAGE_KEYS.settingsNodes);
|
||||
const nodes = nodesRaw ? JSON.parse(nodesRaw) : [];
|
||||
const sessionsRaw = localStorage.getItem(STORAGE_KEYS.chatSessions);
|
||||
const sessions = sessionsRaw ? JSON.parse(sessionsRaw) : {};
|
||||
|
||||
return {
|
||||
chapterCount: chapters.length,
|
||||
settingsNodeCount: nodes.length,
|
||||
sessionCount: Object.keys(sessions.sessions || {}).length,
|
||||
totalChars: chapters.reduce((sum, ch) => sum + (ch.content?.length || 0), 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
// 设定集管理 - 存储人物、世界观、大纲等全局创作信息
|
||||
// 这些信息会在每次AI调用时作为上下文传入,让AI像Cursor一样了解整个项目
|
||||
// 基于「叙事引擎」架构 — 支持网络小说、传统文学、剧本/脚本三种创作模式
|
||||
|
||||
import { get, set } from 'idb-keyval';
|
||||
import { getEmbedding } from './embeddings';
|
||||
|
||||
const SETTINGS_KEY = 'author-project-settings';
|
||||
|
||||
/**
|
||||
* 递归提取节点的所有文本内容,用于向量化
|
||||
*/
|
||||
function extractTextForEmbedding(node) {
|
||||
if (!node) return '';
|
||||
let text = `Name: ${node.name || ''}\n`;
|
||||
|
||||
const extract = (obj) => {
|
||||
if (typeof obj === 'string') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(extract).join(' ');
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
return Object.values(obj).filter(v => v).map(extract).join(' ');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (node.content) {
|
||||
text += extract(node.content);
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// ==================== 写作模式定义 ====================
|
||||
|
||||
export const WRITING_MODES = {
|
||||
webnovel: {
|
||||
key: 'webnovel',
|
||||
label: '网络小说',
|
||||
icon: '📱',
|
||||
color: '#3b82f6',
|
||||
desc: '适合日更连载、修仙玄幻、系统流等网文创作',
|
||||
painPoint: '数值膨胀与连载一致性',
|
||||
extraCharacterFields: [
|
||||
{ key: 'level', label: '等级/境界', placeholder: '例:筑基期三层 / Lv.45', multiline: false },
|
||||
{ key: 'stats', label: '属性面板', placeholder: '力量:85\n敏捷:72\n智力:90\n体质:68', multiline: true, rows: 4 },
|
||||
{ key: 'skillList', label: '技能列表', placeholder: '技能名称、效果、冷却时间...', multiline: true, rows: 3 },
|
||||
{ key: 'equipment', label: '装备/法宝', placeholder: '当前装备和持有的重要物品', multiline: true, rows: 2 },
|
||||
],
|
||||
extraLocationFields: [
|
||||
{ key: 'dangerLevel', label: '危险等级', placeholder: '例:S级禁区 / 安全区', multiline: false },
|
||||
{ key: 'resources', label: '资源产出', placeholder: '灵石矿脉、药草分布...', multiline: true, rows: 2 },
|
||||
],
|
||||
extraObjectFields: [
|
||||
{ key: 'rank', label: '品阶/等级', placeholder: '例:天级上品 / SSR', multiline: false },
|
||||
{ key: 'numericStats', label: '数值属性', placeholder: '攻击力+500\n暴击率+15%', multiline: true, rows: 3 },
|
||||
],
|
||||
},
|
||||
traditional: {
|
||||
key: 'traditional',
|
||||
label: '传统文学',
|
||||
icon: '📚',
|
||||
color: '#8b5cf6',
|
||||
desc: '适合严肃小说、纯文学、短篇、出版向作品',
|
||||
painPoint: '主题编织与草稿迭代',
|
||||
extraCharacterFields: [
|
||||
{ key: 'coreTrauma', label: '核心创伤', placeholder: '角色内心深处的伤痕、驱动行为的心理根源', multiline: true, rows: 2 },
|
||||
{ key: 'innerMonologue', label: '内心独白关键词', placeholder: '角色内心世界的典型词汇和思维方式', multiline: true, rows: 2 },
|
||||
{ key: 'voice', label: '人物声音/对话标签', placeholder: '独特的措辞习惯、语法特点、方言痕迹...', multiline: true, rows: 2 },
|
||||
{ key: 'motifs', label: '反复意象/母题', placeholder: '与角色绑定的象征符号,如“绿光”、“断桥”', multiline: true, rows: 2 },
|
||||
],
|
||||
extraLocationFields: [
|
||||
{ key: 'sensoryVisual', label: '视觉描写', placeholder: '色调、光线、空间感...', multiline: true, rows: 2 },
|
||||
{ key: 'sensoryAudio', label: '听觉描写', placeholder: '环境音、远处声响...', multiline: true, rows: 2 },
|
||||
{ key: 'sensorySmell', label: '嗅觉/触觉', placeholder: '气味、温度、湿度、质感...', multiline: true, rows: 2 },
|
||||
{ key: 'mood', label: '氛围/情绪基调', placeholder: '压抑、温馨、荒凉、神秘...', multiline: false },
|
||||
],
|
||||
extraObjectFields: [
|
||||
{ key: 'symbolism', label: '象征意义', placeholder: '这个物品在主题上代表什么?', multiline: true, rows: 2 },
|
||||
],
|
||||
},
|
||||
screenplay: {
|
||||
key: 'screenplay',
|
||||
label: '剧本/脚本',
|
||||
icon: '🎬',
|
||||
color: '#f59e0b',
|
||||
desc: '适合影视剧本、舞台剧、广播剧等脚本创作',
|
||||
painPoint: '连续性与制作可行性',
|
||||
extraCharacterFields: [
|
||||
{ key: 'castType', label: '角色类型', placeholder: '主演 / 配角 / 客串 / 群演', multiline: false },
|
||||
{ key: 'sceneCount', label: '出场场次', placeholder: '出现在哪些场次(如 4, 12, 55)', multiline: false },
|
||||
{ key: 'dialogueStyle', label: '对白风格笔记', placeholder: '说话节奏、用语习惯、语气特点...', multiline: true, rows: 3 },
|
||||
],
|
||||
extraLocationFields: [
|
||||
{ key: 'slugline', label: '场景标题', placeholder: '如:INT. 厨房 - DAY / EXT. 街道 - NIGHT', multiline: false },
|
||||
{ key: 'shootingNotes', label: '拍摄备注', placeholder: '布景需求、特殊灯光、道具需求...', multiline: true, rows: 2 },
|
||||
{ key: 'usedInScenes', label: '使用场次', placeholder: '此场景在哪些场次中被使用', multiline: false },
|
||||
],
|
||||
extraObjectFields: [
|
||||
{ key: 'propCategory', label: '道具分类', placeholder: '手持道具 / 场景道具 / 特效道具', multiline: false },
|
||||
{ key: 'requiredScenes', label: '所需场次', placeholder: '需要此道具的场次编号', multiline: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 默认项目设定结构
|
||||
const DEFAULT_SETTINGS = {
|
||||
// 写作模式
|
||||
writingMode: 'webnovel',
|
||||
|
||||
// API 配置 — 用户自己填入 API Key
|
||||
apiConfig: {
|
||||
provider: 'zhipu', // 预设供应商标识
|
||||
apiKey: '',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
model: 'glm-4-flash',
|
||||
useCustomEmbed: false, // 是否使用独立的 Embedding API
|
||||
embedProvider: 'zhipu',
|
||||
embedApiKey: '',
|
||||
embedBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
embedModel: 'embedding-3',
|
||||
},
|
||||
|
||||
// 作品基本信息
|
||||
bookInfo: {
|
||||
title: '',
|
||||
genre: '', // 题材类型:玄幻/都市/悬疑/言情/科幻...
|
||||
synopsis: '', // 故事简介/梗概
|
||||
style: '', // 写作风格:如"轻松幽默"、"严肃沉重"、"诗意抒情"
|
||||
tone: '', // 整体基调
|
||||
targetAudience: '', // 目标读者
|
||||
pov: '', // 叙事视角:第一人称/第三人称/全知视角
|
||||
},
|
||||
|
||||
// 人物设定
|
||||
characters: [
|
||||
// 每个人物的数据结构:
|
||||
// {
|
||||
// id: string,
|
||||
// name: string, // 姓名
|
||||
// role: string, // 角色类型:主角/反派/配角/路人
|
||||
// age: string, // 年龄
|
||||
// gender: string, // 性别
|
||||
// appearance: string, // 外貌描写
|
||||
// personality: string, // 性格特征
|
||||
// background: string, // 背景故事
|
||||
// motivation: string, // 动机/目标
|
||||
// skills: string, // 能力/技能
|
||||
// speechStyle: string, // 说话风格/口头禅
|
||||
// relationships: string, // 与其他角色的关系
|
||||
// arc: string, // 角色成长弧线
|
||||
// notes: string, // 其他备注
|
||||
// }
|
||||
],
|
||||
|
||||
// 世界观设定
|
||||
worldbuilding: {
|
||||
era: '', // 时代背景
|
||||
geography: '', // 地理环境
|
||||
society: '', // 社会制度
|
||||
culture: '', // 文化习俗
|
||||
powerSystem: '', // 力量体系/魔法体系
|
||||
technology: '', // 科技水平
|
||||
rules: '', // 世界特殊规则
|
||||
history: '', // 历史大事件
|
||||
factions: '', // 势力/组织
|
||||
notes: '', // 其他设定
|
||||
},
|
||||
|
||||
// 大纲/剧情规划
|
||||
plotOutline: {
|
||||
mainConflict: '', // 核心矛盾
|
||||
plotPoints: '', // 关键剧情节点(按顺序)
|
||||
subplots: '', // 支线剧情
|
||||
ending: '', // 结局方向
|
||||
currentArc: '', // 当前所处的故事弧
|
||||
foreshadowing: '', // 已埋伏笔
|
||||
notes: '', // 其他备注
|
||||
},
|
||||
|
||||
// 写作规则/禁忌
|
||||
writingRules: {
|
||||
mustDo: '', // 必须遵守的规则
|
||||
mustNotDo: '', // 禁止出现的内容/词汇
|
||||
styleGuide: '', // 风格指南
|
||||
notes: '', // 其他备注
|
||||
},
|
||||
};
|
||||
|
||||
// 获取项目设定
|
||||
export function getProjectSettings() {
|
||||
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
||||
try {
|
||||
const data = localStorage.getItem(SETTINGS_KEY);
|
||||
if (!data) return DEFAULT_SETTINGS;
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存项目设定
|
||||
export function saveProjectSettings(settings) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// 添加角色
|
||||
export function addCharacter(character) {
|
||||
const settings = getProjectSettings();
|
||||
const newChar = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
|
||||
name: '',
|
||||
role: '配角',
|
||||
age: '',
|
||||
gender: '',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
background: '',
|
||||
motivation: '',
|
||||
skills: '',
|
||||
speechStyle: '',
|
||||
relationships: '',
|
||||
arc: '',
|
||||
notes: '',
|
||||
...character,
|
||||
};
|
||||
settings.characters.push(newChar);
|
||||
saveProjectSettings(settings);
|
||||
return newChar;
|
||||
}
|
||||
|
||||
// 更新角色
|
||||
export function updateCharacter(id, updates) {
|
||||
const settings = getProjectSettings();
|
||||
const idx = settings.characters.findIndex(c => c.id === id);
|
||||
if (idx === -1) return null;
|
||||
settings.characters[idx] = { ...settings.characters[idx], ...updates };
|
||||
saveProjectSettings(settings);
|
||||
return settings.characters[idx];
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
export function deleteCharacter(id) {
|
||||
const settings = getProjectSettings();
|
||||
settings.characters = settings.characters.filter(c => c.id !== id);
|
||||
saveProjectSettings(settings);
|
||||
}
|
||||
|
||||
// ==================== 写作模式读写 ====================
|
||||
|
||||
export function getWritingMode() {
|
||||
const settings = getProjectSettings();
|
||||
return settings.writingMode || 'webnovel';
|
||||
}
|
||||
|
||||
export function setWritingMode(mode) {
|
||||
if (!WRITING_MODES[mode]) return;
|
||||
const settings = getProjectSettings();
|
||||
settings.writingMode = mode;
|
||||
saveProjectSettings(settings);
|
||||
}
|
||||
|
||||
// ==================== 树形设定集节点系统 ====================
|
||||
|
||||
const NODES_KEY = 'author-settings-nodes';
|
||||
const ACTIVE_WORK_KEY = 'author-active-work';
|
||||
|
||||
function generateNodeId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2, 6);
|
||||
}
|
||||
|
||||
// ==================== 作品级节点系统 ====================
|
||||
|
||||
// 每个作品下自动创建的子分类模板
|
||||
const WORK_SUB_CATEGORIES = [
|
||||
{ suffix: 'bookinfo', name: '作品信息', icon: '📖', category: 'bookInfo', type: 'special' },
|
||||
{ suffix: 'characters', name: '人物设定', icon: '👤', category: 'character', type: 'folder' },
|
||||
{ suffix: 'locations', name: '空间/地点', icon: '🗺️', category: 'location', type: 'folder' },
|
||||
{ suffix: 'world', name: '世界观/设定', icon: '🌍', category: 'world', type: 'folder' },
|
||||
{ suffix: 'objects', name: '物品/道具', icon: '🔮', category: 'object', type: 'folder' },
|
||||
{ suffix: 'plot', name: '大纲', icon: '📋', category: 'plot', type: 'folder' },
|
||||
{ suffix: 'rules', name: '写作规则', icon: '📐', category: 'rules', type: 'folder' },
|
||||
];
|
||||
|
||||
// 全局根分类(不属于任何作品)— 已废弃,所有规则均归属各作品
|
||||
const GLOBAL_ROOT_CATEGORIES = [];
|
||||
|
||||
// 旧版 ROOT_CATEGORIES 的 id(用于迁移检测)
|
||||
const LEGACY_ROOT_IDS = [
|
||||
'root-bookinfo', 'root-characters', 'root-locations',
|
||||
'root-world', 'root-objects', 'root-plot', 'root-rules',
|
||||
];
|
||||
|
||||
/**
|
||||
* 创建一个作品节点及其下的完整子分类树
|
||||
* @returns {{ workNode, subNodes }} 创建的作品节点和子分类节点数组
|
||||
*/
|
||||
export function createWorkNode(name, workId) {
|
||||
const id = workId || ('work-' + generateNodeId());
|
||||
const now = new Date().toISOString();
|
||||
const workNode = {
|
||||
id,
|
||||
name: name || '新作品',
|
||||
type: 'work',
|
||||
category: 'work',
|
||||
parentId: null,
|
||||
order: 0,
|
||||
icon: '📕',
|
||||
content: {},
|
||||
collapsed: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const subNodes = WORK_SUB_CATEGORIES.map((cat, i) => ({
|
||||
id: `${id}-${cat.suffix}`,
|
||||
name: cat.name,
|
||||
type: cat.type,
|
||||
category: cat.category,
|
||||
parentId: id,
|
||||
order: i,
|
||||
icon: cat.icon,
|
||||
content: {},
|
||||
collapsed: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
return { workNode, subNodes };
|
||||
}
|
||||
|
||||
// ==================== 激活作品管理 ====================
|
||||
|
||||
export function getActiveWorkId() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(ACTIVE_WORK_KEY) || null;
|
||||
}
|
||||
|
||||
export function setActiveWorkId(workId) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (workId) {
|
||||
localStorage.setItem(ACTIVE_WORK_KEY, workId);
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_WORK_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllWorks(nodes) {
|
||||
const allNodes = nodes || getSettingsNodes();
|
||||
return allNodes.filter(n => n.type === 'work');
|
||||
}
|
||||
|
||||
// ==================== 节点初始化与迁移 ====================
|
||||
|
||||
// 获取默认节点树(包含一个默认作品 + 全局规则)
|
||||
function getDefaultNodes() {
|
||||
const { workNode, subNodes } = createWorkNode('默认作品', 'work-default');
|
||||
return [workNode, ...subNodes];
|
||||
}
|
||||
|
||||
// 获取所有设定节点 (Async)
|
||||
export async function getSettingsNodes() {
|
||||
if (typeof window === 'undefined') return getDefaultNodes();
|
||||
try {
|
||||
let nodes = await get(NODES_KEY);
|
||||
if (!nodes) {
|
||||
// 首次使用:尝试从 localStorage fallback 或迁移旧数据
|
||||
const legacyData = localStorage.getItem(NODES_KEY);
|
||||
if (legacyData) {
|
||||
nodes = JSON.parse(legacyData);
|
||||
await set(NODES_KEY, nodes); // 写入 IndexedDB
|
||||
} else {
|
||||
const migrated = await migrateOldSettings();
|
||||
if (migrated) {
|
||||
nodes = await migrateToWorkStructure(migrated);
|
||||
return nodes;
|
||||
}
|
||||
const defaults = getDefaultNodes();
|
||||
await saveSettingsNodes(defaults);
|
||||
if (!getActiveWorkId()) setActiveWorkId('work-default');
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
nodes = await migrateToWorkStructure(nodes);
|
||||
nodes = await migrateGlobalRulesToWork(nodes);
|
||||
nodes = await ensureWorkExists(nodes);
|
||||
return nodes;
|
||||
} catch {
|
||||
return getDefaultNodes();
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设定集节点 (Async)
|
||||
export async function saveSettingsNodes(nodes) {
|
||||
if (typeof window === 'undefined') return;
|
||||
await set(NODES_KEY, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将旧的扁平根分类结构迁移到作品结构 (Async)
|
||||
*/
|
||||
async function migrateToWorkStructure(nodes) {
|
||||
if (nodes.some(n => n.type === 'work')) return nodes;
|
||||
|
||||
const legacyRoots = nodes.filter(n => n.parentId === null && LEGACY_ROOT_IDS.includes(n.id));
|
||||
if (legacyRoots.length === 0) return nodes;
|
||||
|
||||
const { workNode } = createWorkNode('默认作品', 'work-default');
|
||||
const newNodes = [workNode];
|
||||
for (const node of nodes) {
|
||||
if (LEGACY_ROOT_IDS.includes(node.id) && node.parentId === null) {
|
||||
const suffix = node.id.replace('root-', '');
|
||||
const newId = `work-default-${suffix}`;
|
||||
nodes.forEach(child => {
|
||||
if (child.parentId === node.id) child.parentId = newId;
|
||||
});
|
||||
newNodes.push({ ...node, id: newId, parentId: 'work-default' });
|
||||
} else if (!LEGACY_ROOT_IDS.includes(node.id)) {
|
||||
newNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cat of WORK_SUB_CATEGORIES) {
|
||||
const expectedId = `work-default-${cat.suffix}`;
|
||||
if (!newNodes.find(n => n.id === expectedId)) {
|
||||
newNodes.push({
|
||||
id: expectedId, name: cat.name, type: cat.type, category: cat.category,
|
||||
parentId: 'work-default', order: WORK_SUB_CATEGORIES.indexOf(cat), icon: cat.icon,
|
||||
content: {}, collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await saveSettingsNodes(newNodes);
|
||||
if (!getActiveWorkId()) setActiveWorkId('work-default');
|
||||
return newNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移旧的全局写作规则到默认作品 (Async)
|
||||
*/
|
||||
async function migrateGlobalRulesToWork(nodes) {
|
||||
const globalRules = nodes.find(n => n.id === 'root-rules' && n.parentId === null);
|
||||
if (!globalRules) return nodes;
|
||||
const activeWorkId = getActiveWorkId() || 'work-default';
|
||||
let targetRulesId = nodes.find(n => n.parentId === activeWorkId && n.category === 'rules')?.id;
|
||||
if (!targetRulesId) {
|
||||
const anyWork = nodes.find(n => n.type === 'work');
|
||||
if (anyWork) targetRulesId = nodes.find(n => n.parentId === anyWork.id && n.category === 'rules')?.id;
|
||||
}
|
||||
if (targetRulesId) {
|
||||
nodes.forEach(n => {
|
||||
if (n.parentId === 'root-rules') n.parentId = targetRulesId;
|
||||
});
|
||||
}
|
||||
nodes = nodes.filter(n => n.id !== 'root-rules');
|
||||
await saveSettingsNodes(nodes);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// 确保至少有一个作品存在 (Async)
|
||||
async function ensureWorkExists(nodes) {
|
||||
if (!nodes.some(n => n.type === 'work')) {
|
||||
const { workNode, subNodes } = createWorkNode('默认作品', 'work-default');
|
||||
nodes.push(workNode, ...subNodes);
|
||||
await saveSettingsNodes(nodes);
|
||||
}
|
||||
if (!getActiveWorkId()) {
|
||||
const firstWork = nodes.find(n => n.type === 'work');
|
||||
if (firstWork) setActiveWorkId(firstWork.id);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// 添加节点 (Async)
|
||||
export async function addSettingsNode({ name, type, category, parentId, icon, content }) {
|
||||
const nodes = await getSettingsNodes();
|
||||
const siblings = nodes.filter(n => n.parentId === parentId);
|
||||
const node = {
|
||||
id: generateNodeId(),
|
||||
name: name || (type === 'folder' ? '新分类' : '新条目'),
|
||||
type: type || 'item',
|
||||
category: category || 'custom',
|
||||
parentId: parentId || null,
|
||||
order: siblings.length,
|
||||
icon: icon || (type === 'folder' ? '📁' : '📄'),
|
||||
content: content || {},
|
||||
collapsed: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (node.type === 'item') {
|
||||
const textToEmbed = extractTextForEmbedding(node);
|
||||
const { apiConfig } = getProjectSettings();
|
||||
node.embedding = await getEmbedding(textToEmbed, apiConfig);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
await saveSettingsNodes(nodes);
|
||||
return node;
|
||||
}
|
||||
|
||||
// 更新节点 (Async)
|
||||
export async function updateSettingsNode(id, updates) {
|
||||
const nodes = await getSettingsNodes();
|
||||
const idx = nodes.findIndex(n => n.id === id);
|
||||
if (idx === -1) return null;
|
||||
const isProtected = GLOBAL_ROOT_CATEGORIES.some(c => c.id === id) ||
|
||||
nodes[idx].type === 'work' ||
|
||||
(nodes[idx].parentId && nodes.some(p => p.id === nodes[idx].parentId && p.type === 'work') && WORK_SUB_CATEGORIES.some(c => id.endsWith('-' + c.suffix)));
|
||||
if (isProtected) {
|
||||
delete updates.type;
|
||||
delete updates.category;
|
||||
delete updates.parentId;
|
||||
}
|
||||
|
||||
// 如果名称或内容发生改变,且是条目,重新计算 embedding
|
||||
const nodeType = updates.type || nodes[idx].type;
|
||||
if (nodeType === 'item' && (updates.name !== undefined || updates.content !== undefined)) {
|
||||
const tempNode = { ...nodes[idx], ...updates };
|
||||
const textToEmbed = extractTextForEmbedding(tempNode);
|
||||
const { apiConfig } = getProjectSettings();
|
||||
updates.embedding = await getEmbedding(textToEmbed, apiConfig);
|
||||
}
|
||||
|
||||
nodes[idx] = { ...nodes[idx], ...updates, updatedAt: new Date().toISOString() };
|
||||
await saveSettingsNodes(nodes);
|
||||
return nodes[idx];
|
||||
}
|
||||
|
||||
// 删除节点(及所有子节点) (Async)
|
||||
export async function deleteSettingsNode(id) {
|
||||
let nodes = await getSettingsNodes();
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (node && node.parentId) {
|
||||
const parent = nodes.find(p => p.id === node.parentId);
|
||||
if (parent && parent.type === 'work' && WORK_SUB_CATEGORIES.some(c => id.endsWith('-' + c.suffix))) return false;
|
||||
}
|
||||
const toDelete = new Set();
|
||||
const collect = (parentId) => {
|
||||
toDelete.add(parentId);
|
||||
nodes.filter(n => n.parentId === parentId).forEach(n => collect(n.id));
|
||||
};
|
||||
collect(id);
|
||||
nodes = nodes.filter(n => !toDelete.has(n.id));
|
||||
await saveSettingsNodes(nodes);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 移动节点 (Async)
|
||||
export async function moveSettingsNode(id, newParentId) {
|
||||
const nodes = await getSettingsNodes();
|
||||
const idx = nodes.findIndex(n => n.id === id);
|
||||
if (idx === -1) return null;
|
||||
const siblings = nodes.filter(n => n.parentId === newParentId && n.id !== id);
|
||||
nodes[idx] = {
|
||||
...nodes[idx],
|
||||
parentId: newParentId,
|
||||
order: siblings.length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveSettingsNodes(nodes);
|
||||
return nodes[idx];
|
||||
}
|
||||
|
||||
// 获取指定分类下的所有 item 节点(递归) (Async)
|
||||
export async function getItemsByCategory(category) {
|
||||
const nodes = await getSettingsNodes();
|
||||
return nodes.filter(n => n.type === 'item' && n.category === category);
|
||||
}
|
||||
|
||||
// 获取某节点的所有子节点(直接子节点) (Async)
|
||||
export async function getChildren(parentId) {
|
||||
const nodes = await getSettingsNodes();
|
||||
return nodes
|
||||
.filter(n => n.parentId === parentId)
|
||||
.sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.order - b.order;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取节点的路径(从根到当前节点的名称链) (Async)
|
||||
export async function getNodePath(id) {
|
||||
const nodes = await getSettingsNodes();
|
||||
const path = [];
|
||||
let current = nodes.find(n => n.id === id);
|
||||
while (current) {
|
||||
path.unshift(current.name);
|
||||
current = current.parentId ? nodes.find(n => n.id === current.parentId) : null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ==================== 旧数据迁移 ====================
|
||||
|
||||
// ==================== 旧数据迁移 ====================
|
||||
|
||||
async function migrateOldSettings() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const oldData = localStorage.getItem(SETTINGS_KEY);
|
||||
if (!oldData) return null;
|
||||
|
||||
const old = JSON.parse(oldData);
|
||||
const nodes = getDefaultNodes();
|
||||
let hasContent = false;
|
||||
|
||||
// 迁移人物设定
|
||||
if (old.characters && old.characters.length > 0) {
|
||||
old.characters.forEach((char, i) => {
|
||||
nodes.push({
|
||||
id: char.id || generateNodeId(),
|
||||
name: char.name || '未命名角色',
|
||||
type: 'item',
|
||||
category: 'character',
|
||||
parentId: 'root-characters',
|
||||
order: i,
|
||||
icon: '📄',
|
||||
content: {
|
||||
role: char.role || '',
|
||||
age: char.age || '',
|
||||
gender: char.gender || '',
|
||||
appearance: char.appearance || '',
|
||||
personality: char.personality || '',
|
||||
background: char.background || '',
|
||||
motivation: char.motivation || '',
|
||||
skills: char.skills || '',
|
||||
speechStyle: char.speechStyle || '',
|
||||
relationships: char.relationships || '',
|
||||
arc: char.arc || '',
|
||||
notes: char.notes || '',
|
||||
},
|
||||
collapsed: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
|
||||
// 迁移世界观
|
||||
if (old.worldbuilding) {
|
||||
const fieldMap = {
|
||||
era: '时代背景', geography: '地理环境', society: '社会制度',
|
||||
culture: '文化习俗', powerSystem: '力量体系', technology: '科技水平',
|
||||
rules: '特殊规则', history: '历史大事件', factions: '势力/组织',
|
||||
notes: '其他设定',
|
||||
};
|
||||
let order = 0;
|
||||
for (const [key, label] of Object.entries(fieldMap)) {
|
||||
if (old.worldbuilding[key]) {
|
||||
nodes.push({
|
||||
id: generateNodeId(),
|
||||
name: label, type: 'item', category: 'world',
|
||||
parentId: 'root-world', order: order++, icon: '📄',
|
||||
content: { description: old.worldbuilding[key] },
|
||||
collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移大纲
|
||||
if (old.plotOutline) {
|
||||
const fieldMap = {
|
||||
mainConflict: '核心矛盾', plotPoints: '关键剧情节点', subplots: '支线剧情',
|
||||
currentArc: '当前故事弧', foreshadowing: '已埋伏笔', ending: '结局方向',
|
||||
notes: '备注',
|
||||
};
|
||||
let order = 0;
|
||||
for (const [key, label] of Object.entries(fieldMap)) {
|
||||
if (old.plotOutline[key]) {
|
||||
nodes.push({
|
||||
id: generateNodeId(),
|
||||
name: label, type: 'item', category: 'plot',
|
||||
parentId: 'root-plot', order: order++, icon: '📄',
|
||||
content: { description: old.plotOutline[key] },
|
||||
collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移写作规则
|
||||
if (old.writingRules) {
|
||||
const fieldMap = {
|
||||
mustDo: '✅ 必须遵守', mustNotDo: '❌ 禁止内容',
|
||||
styleGuide: '📝 风格指南', notes: '备注',
|
||||
};
|
||||
let order = 0;
|
||||
for (const [key, label] of Object.entries(fieldMap)) {
|
||||
if (old.writingRules[key]) {
|
||||
nodes.push({
|
||||
id: generateNodeId(),
|
||||
name: label, type: 'item', category: 'rules',
|
||||
parentId: 'root-rules', order: order++, icon: '📄',
|
||||
content: { description: old.writingRules[key] },
|
||||
collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContent) {
|
||||
await saveSettingsNodes(nodes);
|
||||
return nodes;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { get, set } from 'idb-keyval';
|
||||
import { getChapters, saveChapters } from './storage';
|
||||
import { getSettingsNodes, saveSettingsNodes } from './settings';
|
||||
|
||||
const SNAPSHOTS_KEY = 'author-snapshots';
|
||||
|
||||
/**
|
||||
* 获取所有快照
|
||||
* @returns {Promise<Array>} 快照列表(按时间倒序)
|
||||
*/
|
||||
export async function getSnapshots() {
|
||||
try {
|
||||
const snapshots = await get(SNAPSHOTS_KEY);
|
||||
return Array.isArray(snapshots) ? snapshots : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to get snapshots:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新快照
|
||||
* @param {string} label - 快照标签描述
|
||||
* @param {string} type - 'auto' | 'manual'
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createSnapshot(label, type = 'auto') {
|
||||
try {
|
||||
const chapters = await getChapters();
|
||||
const settingsNodes = await getSettingsNodes();
|
||||
|
||||
const snapshot = {
|
||||
id: `snap-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
label: label || (type === 'auto' ? '自动存档' : '手动存档'),
|
||||
type,
|
||||
stats: {
|
||||
chapterCount: chapters.length,
|
||||
totalWords: chapters.reduce((acc, ch) => acc + (ch.wordCount || 0), 0),
|
||||
settingCount: settingsNodes.length,
|
||||
},
|
||||
data: {
|
||||
chapters,
|
||||
settingsNodes,
|
||||
}
|
||||
};
|
||||
|
||||
const existing = await getSnapshots();
|
||||
existing.unshift(snapshot); // 最新在前
|
||||
|
||||
// 限制自动快照数量(例如最多保留 50 个自动快照,超出的按时间删除)
|
||||
const maxAutoSnapshots = 50;
|
||||
let finalSnapshots = existing;
|
||||
const autoSnapshots = existing.filter(s => s.type === 'auto');
|
||||
if (autoSnapshots.length > maxAutoSnapshots) {
|
||||
const toRemove = autoSnapshots.slice(maxAutoSnapshots).map(s => s.id);
|
||||
finalSnapshots = existing.filter(s => !toRemove.includes(s.id));
|
||||
}
|
||||
|
||||
await set(SNAPSHOTS_KEY, finalSnapshots);
|
||||
return snapshot;
|
||||
} catch (e) {
|
||||
console.error('Failed to create snapshot:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复到指定快照
|
||||
* @param {string} snapshotId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function restoreSnapshot(snapshotId) {
|
||||
try {
|
||||
const snapshots = await getSnapshots();
|
||||
const target = snapshots.find(s => s.id === snapshotId);
|
||||
if (!target) throw new Error('Snapshot not found');
|
||||
|
||||
// 发起静默的当前状态备份,以防后悔
|
||||
await createSnapshot('恢复前的备份', 'auto');
|
||||
|
||||
// 覆盖现有数据
|
||||
await saveChapters(target.data.chapters || []);
|
||||
await saveSettingsNodes(target.data.settingsNodes || []);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to restore snapshot:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定快照
|
||||
*/
|
||||
export async function deleteSnapshot(snapshotId) {
|
||||
const snapshots = await getSnapshots();
|
||||
const remaining = snapshots.filter(s => s.id !== snapshotId);
|
||||
await set(SNAPSHOTS_KEY, remaining);
|
||||
return remaining;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 本地存储工具 - 使用 IndexedDB 管理核心数据 (章节、摘要)
|
||||
import { get, set } from 'idb-keyval';
|
||||
|
||||
const STORAGE_KEY = 'author-chapters';
|
||||
|
||||
// 生成唯一ID
|
||||
export function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// 获取所有章节 (Async)
|
||||
export async function getChapters() {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
let chapters = await get(STORAGE_KEY);
|
||||
if (!chapters) {
|
||||
// Fallback: 第一次迁移,从 localStorage 拿
|
||||
const legacyData = localStorage.getItem(STORAGE_KEY);
|
||||
if (legacyData) {
|
||||
chapters = JSON.parse(legacyData);
|
||||
await set(STORAGE_KEY, chapters);
|
||||
} else {
|
||||
chapters = [];
|
||||
}
|
||||
}
|
||||
return chapters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 保存所有章节 (Async)
|
||||
export async function saveChapters(chapters) {
|
||||
if (typeof window === 'undefined') return;
|
||||
await set(STORAGE_KEY, chapters);
|
||||
}
|
||||
|
||||
// 创建新章节 (Async)
|
||||
export async function createChapter(title = '未命名章节') {
|
||||
const chapters = await getChapters();
|
||||
const newChapter = {
|
||||
id: generateId(),
|
||||
title,
|
||||
content: '',
|
||||
wordCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
chapters.push(newChapter);
|
||||
await saveChapters(chapters);
|
||||
return newChapter;
|
||||
}
|
||||
|
||||
// 更新章节 (Async)
|
||||
export async function updateChapter(id, updates) {
|
||||
const chapters = await getChapters();
|
||||
const index = chapters.findIndex(ch => ch.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
chapters[index] = {
|
||||
...chapters[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveChapters(chapters);
|
||||
return chapters[index];
|
||||
}
|
||||
|
||||
// 删除章节 (Async)
|
||||
export async function deleteChapter(id) {
|
||||
const chapters = await getChapters();
|
||||
const newChapters = chapters.filter(ch => ch.id !== id);
|
||||
await saveChapters(newChapters);
|
||||
return newChapters;
|
||||
}
|
||||
|
||||
// 获取单个章节 (Async)
|
||||
export async function getChapter(id) {
|
||||
const chapters = await getChapters();
|
||||
return chapters.find(ch => ch.id === id) || null;
|
||||
}
|
||||
|
||||
// 导出为 Markdown
|
||||
export function exportToMarkdown(chapter) {
|
||||
const md = `# ${chapter.title}\n\n${chapter.content.replace(/<[^>]*>/g, '').replace(/ /g, ' ')}`;
|
||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${chapter.title || '未命名'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// 导出所有章节
|
||||
export function exportAllToMarkdown(chapters) {
|
||||
const md = chapters.map(ch => {
|
||||
const text = ch.content.replace(/<[^>]*>/g, '').replace(/ /g, ' ');
|
||||
return `# ${ch.title}\n\n${text}`;
|
||||
}).join('\n\n---\n\n');
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '全部章节.md';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ==================== 章节摘要缓存 ====================
|
||||
|
||||
const SUMMARY_PREFIX = 'author-chapter-summary-';
|
||||
|
||||
// 获取章节摘要 (Async)
|
||||
export async function getChapterSummary(id) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
let summary = await get(SUMMARY_PREFIX + id);
|
||||
if (!summary) {
|
||||
// Fallback
|
||||
summary = localStorage.getItem(SUMMARY_PREFIX + id);
|
||||
if (summary) {
|
||||
await set(SUMMARY_PREFIX + id, summary);
|
||||
}
|
||||
}
|
||||
return summary || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存章节摘要 (Async)
|
||||
export async function saveChapterSummary(id, summary) {
|
||||
if (typeof window === 'undefined') return;
|
||||
await set(SUMMARY_PREFIX + id, summary);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import en from '../locales/en.json';
|
||||
import zh from '../locales/zh.json';
|
||||
import ru from '../locales/ru.json';
|
||||
|
||||
const translations = { en, zh, ru };
|
||||
|
||||
export function useI18n() {
|
||||
// Default to 'zh' if language is null initially
|
||||
const language = useAppStore(state => state.language) || 'zh';
|
||||
|
||||
const t = (path) => {
|
||||
const keys = path.split('.');
|
||||
let current = translations[language] || translations['zh'];
|
||||
|
||||
for (const key of keys) {
|
||||
if (current === undefined || current[key] === undefined) {
|
||||
console.warn(`Translation missing for key: ${path} in lang: ${language}`);
|
||||
// Fallback to Chinese
|
||||
if (language !== 'zh' && translations['zh']) {
|
||||
let fallback = translations['zh'];
|
||||
for (const k of keys) {
|
||||
if (fallback[k] === undefined) return path;
|
||||
fallback = fallback[k];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
return { t, language };
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "Welcome to Author",
|
||||
"subtitle": "Your premium workspace for distraction-free creation.",
|
||||
"selectLang": "Select Language",
|
||||
"selectTheme": "Choose Your Workspace Theme",
|
||||
"startBtn": "Enter Workspace",
|
||||
"themeWarm": {
|
||||
"name": "Warm Classic",
|
||||
"desc": "A cozy, paper-like aesthetic that reduces eye strain."
|
||||
},
|
||||
"themeModern": {
|
||||
"name": "Modern Glass",
|
||||
"desc": "Sleek, translucent glassmorphism for a crisp focus."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings Hub",
|
||||
"subtitle": "AI contextual reference",
|
||||
"tabSettings": "📚 Project Settings",
|
||||
"tabApi": "🔑 API Config",
|
||||
"tabPreferences": "Preferences",
|
||||
"newWork": "+ New Project",
|
||||
"searchPlaceholder": "Search settings...",
|
||||
"workLabel": "📕 Project:",
|
||||
"confirmBtn": "Confirm",
|
||||
"newItem": "New Item",
|
||||
"workNamePlaceholder": "Project Name...",
|
||||
"deleteWorkPrompt": "Are you sure you want to delete the project '{name}' and all its settings? This cannot be undone.",
|
||||
"deleteWorkAlert": "At least one project must remain.",
|
||||
"deleteNodePrompt": "Are you sure you want to delete '{name}'? If it's a folder, all its contents will be deleted.",
|
||||
"statsTitle": "Statistics by Category",
|
||||
"categories": {
|
||||
"character": "Character",
|
||||
"location": "Location",
|
||||
"world": "World",
|
||||
"object": "Item",
|
||||
"plot": "Plot",
|
||||
"rules": "Rules"
|
||||
}
|
||||
},
|
||||
"settingsTree": {
|
||||
"disabledHint": "Disabled (Ignored in AI context)",
|
||||
"enableHint": "Click to enable",
|
||||
"disableHint": "Click to disable",
|
||||
"add": "Add"
|
||||
},
|
||||
"settingsEditor": {
|
||||
"aiFill": "AI Auto-Fill",
|
||||
"aiExtraFields": "AI Generated Extra Fields",
|
||||
"unnamedChar": "Unnamed Character",
|
||||
"charRole": "Role",
|
||||
"roles": {
|
||||
"protagonist": "Protagonist",
|
||||
"antagonist": "Antagonist",
|
||||
"supporting": "Supporting",
|
||||
"minor": "Minor",
|
||||
"proLabel": "🌟 Protagonist",
|
||||
"antLabel": "🔥 Antagonist",
|
||||
"supLabel": "👥 Supporting",
|
||||
"minLabel": "🚶 Minor"
|
||||
},
|
||||
"infoGender": "Gender",
|
||||
"infoAge": "Age",
|
||||
"infoPersonality": "Personality",
|
||||
"tabBasic": "Basic Info",
|
||||
"tabAppearance": "Appearance & Personality",
|
||||
"tabBackground": "Background Story",
|
||||
"tabSkills": "Skills & Relationships",
|
||||
"tabNotes": "Notes",
|
||||
"tabSensory": "Sensory Details",
|
||||
"tabMood": "Mood / Atmosphere",
|
||||
"tabStats": "Detailed Attributes",
|
||||
"dangerSafe": "🟢 Safe",
|
||||
"dangerCaution": "🟡 Caution",
|
||||
"dangerHigh": "🔴 Danger",
|
||||
"emptyTitle": "No content yet",
|
||||
"emptyDesc": "Click the button below to add your first setting item.",
|
||||
"addBtn": "+ Add New Item",
|
||||
"selectTitle": "Select an Item",
|
||||
"selectDesc": "Select an item from the tree navigation on the left to view and edit its details.",
|
||||
"statusPlanned": "📌 Planned",
|
||||
"statusWriting": "✏️ Writing",
|
||||
"statusDone": "✅ Done",
|
||||
"charAppearance": "Appearance",
|
||||
"charAppearancePlaceholder": "Describe the character's appearance…",
|
||||
"charPersonality": "Personality Traits",
|
||||
"charPersonalityPlaceholder": "Describe the character's personality…",
|
||||
"charSpeechStyle": "Speech Style",
|
||||
"charSpeechStylePlaceholder": "Catchphrases, tone characteristics…",
|
||||
"charBackground": "Background",
|
||||
"charBackgroundPlaceholder": "Character's origin and past…",
|
||||
"charMotivation": "Motivation / Goal",
|
||||
"charMotivationPlaceholder": "What does the character pursue?",
|
||||
"charArc": "Character Arc",
|
||||
"charArcPlaceholder": "How will the character change?",
|
||||
"charSkills": "Abilities / Skills",
|
||||
"charSkillsPlaceholder": "Martial arts, magic, special talents…",
|
||||
"charRelationships": "Relationships",
|
||||
"charRelationshipsPlaceholder": "Relationships with other characters…",
|
||||
"charNotes": "Other Notes",
|
||||
"charNotesPlaceholder": "Any additional information…",
|
||||
"charGenderPlaceholder": "Male / Female / Other",
|
||||
"charAgePlaceholder": "18 / Unknown",
|
||||
"locDescription": "Location Description",
|
||||
"locDescriptionPlaceholder": "Describe this location…",
|
||||
"locSlugline": "Scene Heading",
|
||||
"locSluglinePlaceholder": "E.g.: INT. PALACE HALL - NIGHT",
|
||||
"locVisual": "Visual",
|
||||
"locVisualPlaceholder": "What can be seen…",
|
||||
"locAudio": "Audio",
|
||||
"locAudioPlaceholder": "What can be heard…",
|
||||
"locSmell": "Smell / Touch",
|
||||
"locSmellPlaceholder": "What can be smelled / felt…",
|
||||
"locMood": "Atmosphere",
|
||||
"locMoodPlaceholder": "Oppressive / Warm / Mysterious…",
|
||||
"locDangerLevel": "Danger Level",
|
||||
"objDescription": "Description",
|
||||
"objDescriptionPlaceholder": "Describe this item…",
|
||||
"objType": "Type",
|
||||
"objTypePlaceholder": "Weapon / Artifact / Potion…",
|
||||
"objRank": "Rank / Tier",
|
||||
"objRankPlaceholder": "Common / Rare / Legendary…",
|
||||
"objHolder": "Current Holder",
|
||||
"objHolderPlaceholder": "Who owns it?",
|
||||
"objStats": "Numeric Stats",
|
||||
"objStatsPlaceholder": "Attack +100…",
|
||||
"objSymbolism": "Symbolism",
|
||||
"objSymbolismPlaceholder": "What does it represent?",
|
||||
"worldDescription": "World Setting Description",
|
||||
"worldDescriptionPlaceholder": "Describe this worldbuilding detail…",
|
||||
"worldNotes": "Notes",
|
||||
"worldNotesPlaceholder": "Additional notes…",
|
||||
"plotDescription": "Plot Description",
|
||||
"plotDescriptionPlaceholder": "Describe this plot point…",
|
||||
"plotStatus": "Status",
|
||||
"plotNotes": "Notes",
|
||||
"plotNotesPlaceholder": "Additional notes…",
|
||||
"rulesDescription": "Rule Description",
|
||||
"rulesDescriptionPlaceholder": "Describe this writing rule; AI will strictly follow it…",
|
||||
"genericDescription": "Description",
|
||||
"genericDescriptionPlaceholder": "Enter description…",
|
||||
"genericNotes": "Notes",
|
||||
"genericNotesPlaceholder": "Additional notes…",
|
||||
"folderSubfolders": "{count} subfolder(s) · ",
|
||||
"folderItems": "{count} item(s)"
|
||||
},
|
||||
"aiSidebar": {
|
||||
"title": "✦ AI Assistant",
|
||||
"tabChat": "💬 Chat",
|
||||
"tabArchive": "📋 Archive",
|
||||
"tabReference": "📚 Reference",
|
||||
"btnSessionList": "Session List",
|
||||
"btnNewSession": "New Chat",
|
||||
"btnClose": "Close",
|
||||
"historyCount": "Chat History ({count})",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"slidingWindow": "Sliding Window",
|
||||
"summarize": "📝 Summarize",
|
||||
"summarizeTitle": "Summarize checked chat history into a digest",
|
||||
"clearChat": "🗑",
|
||||
"clearChatTitle": "Clear all chats",
|
||||
"editSummary": "Edit Summary (replaces checked history upon confirmation)",
|
||||
"cancel": "Cancel",
|
||||
"confirmReplace": "✓ Replace",
|
||||
"emptyChatIcon": "✦",
|
||||
"emptyChatTitle": "Start chatting with AI",
|
||||
"emptyChatHint": "Ask any questions about writing",
|
||||
"roleYou": "You",
|
||||
"roleSummary": "📝 Summary",
|
||||
"roleAi": "AI",
|
||||
"edited": "Edited",
|
||||
"btnEdit": "✏️",
|
||||
"btnResend": "🔄",
|
||||
"btnRegenerate": "🔄",
|
||||
"btnBranch": "🔀",
|
||||
"thinkingChain": "💡 Chain of Thought",
|
||||
"save": "✓ Save",
|
||||
"actionsApply": "✅ Apply",
|
||||
"actionsApplied": "✓ Applied",
|
||||
"inputPlaceholder": "Type message... (Enter to send, Shift+Enter for newline)",
|
||||
"searchArchive": "Search generations...",
|
||||
"emptyArchiveIcon": "📋",
|
||||
"emptyArchiveTitle": "No generations yet",
|
||||
"emptyArchiveHint": "AI generated content will be archived here automatically",
|
||||
"insertEditor": "↓ Insert into Editor",
|
||||
"copy": "📋 Copy",
|
||||
"tokenUsage": "Token Usage",
|
||||
"searchContext": "🔍 Search items...",
|
||||
"emptyContextIcon": "📚",
|
||||
"emptyContextTitle": "No context items",
|
||||
"emptyContextHint1": "No matched items",
|
||||
"emptyContextHint2": "Please add items in settings first",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"reset": "Reset",
|
||||
"settings": "⚙️ Settings",
|
||||
"categories": {
|
||||
"character": "👤 Character",
|
||||
"world": "🌍 World",
|
||||
"location": "🗺️ Location",
|
||||
"object": "🔮 Object",
|
||||
"plot": "📋 Plot",
|
||||
"rules": "📐 Rules",
|
||||
"custom": "⚙️ Custom"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add",
|
||||
"update": "Update",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"modes": {
|
||||
"continue": "Continue",
|
||||
"rewrite": "Rewrite",
|
||||
"expand": "Expand",
|
||||
"condense": "Condense",
|
||||
"dialogue": "Dialogue",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"statuses": {
|
||||
"accepted": "✓ Accepted",
|
||||
"rejected": "✗ Rejected",
|
||||
"pending": "⏳ Pending"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"chat": "💬 Chat",
|
||||
"archive": "📋 Archive",
|
||||
"reference": "📚 Ref",
|
||||
"inputPlaceholder": "Type your message... (Enter to send, Shift+Enter for new line)",
|
||||
"newChapter": "+ New Chapter",
|
||||
"chapterList": "Chapter List",
|
||||
"totalWords": "Total Words",
|
||||
"exportCurrent": "Export Current",
|
||||
"exportAll": "Export All",
|
||||
"tooltipSettings": "Settings Hub",
|
||||
"tooltipTimeMachine": "Time Machine (History)",
|
||||
"tooltipThemeLight": "Switch to Light Mode",
|
||||
"tooltipThemeDark": "Switch to Dark Mode",
|
||||
"btnSave": "💾 Save",
|
||||
"btnLoad": "📂 Load",
|
||||
"contextRename": "✎ Rename",
|
||||
"contextExport": "↓ Export Markdown",
|
||||
"contextDelete": "✕ Delete Chapter",
|
||||
"deleteConfirm": "Delete chapter",
|
||||
"alertRetainOne": "At least one chapter must remain",
|
||||
"collapseSidebar": "Collapse Sidebar",
|
||||
"expandSidebar": "Expand Sidebar",
|
||||
"chapterCreated": "Created \"{title}\"",
|
||||
"chapterDeleted": "Deleted \"{title}\"",
|
||||
"wordUnit": " words",
|
||||
"defaultChapterTitle": "Chapter {num}",
|
||||
"modeLabel": "{mode} Mode",
|
||||
"clickToSwitchMode": "Click to switch writing mode",
|
||||
"exportedChapter": "Current chapter exported",
|
||||
"exportedAll": "All chapters exported",
|
||||
"importSuccess": "Page will reload automatically."
|
||||
},
|
||||
"page": {
|
||||
"noChapterHint": "Select or create a chapter to start writing",
|
||||
"expandSidebar": "Expand Sidebar",
|
||||
"openAiAssistant": "Open AI Assistant",
|
||||
"helpAndGuide": "Help & User Guide",
|
||||
"toastInserted": "Inserted into editor",
|
||||
"toastRequestFailed": "Request failed",
|
||||
"toastStopped": "Generation stopped",
|
||||
"toastNetworkError": "Network connection failed",
|
||||
"dialogueUser": "User",
|
||||
"dialogueHistory": "💬 Chat History",
|
||||
"autoSnapshot": "Auto Snapshot",
|
||||
"autoSnapshotFail": "Auto snapshot failed:",
|
||||
"firstChapterTitle": "Chapter 1"
|
||||
},
|
||||
"snapshot": {
|
||||
"title": "🕒 Time Machine (Version Snapshots)",
|
||||
"subtitle": "Automatically save and manage your project's version history",
|
||||
"createBtn": "+ New Manual Snapshot",
|
||||
"creating": "Saving...",
|
||||
"loading": "Loading...",
|
||||
"empty": "No snapshots yet",
|
||||
"deleteBtn": "Delete",
|
||||
"createdAt": "Created at:",
|
||||
"autoLabel": "(Auto Snapshot)",
|
||||
"chapterCount": "Chapters",
|
||||
"totalWords": "Total Words",
|
||||
"settingCount": "Settings",
|
||||
"includedChapters": "Included chapters:",
|
||||
"andMore": "... and more",
|
||||
"restoreBtn": "⚠️ Restore to This Version",
|
||||
"restoring": "Restoring...",
|
||||
"restoreWarning": "After restoring, all unsaved changes to the current project will be overwritten. A snapshot will be created automatically before overwriting just in case.",
|
||||
"selectHint": "Select a version on the left to view details",
|
||||
"promptLabel": "Enter a description for this snapshot (e.g.: Backup before major revision):",
|
||||
"promptDefault": "Manual Snapshot",
|
||||
"createFailed": "Failed to create snapshot: ",
|
||||
"confirmRestore": "Warning: This will overwrite all chapters and settings with the state from \"{label}\" ({date}). Current state will be backed up automatically. Confirm rollback?",
|
||||
"restoreSuccess": "Rollback successful! The page will reload to load the previous state.",
|
||||
"restoreFailed": "Rollback failed: ",
|
||||
"confirmDelete": "Are you sure you want to delete this snapshot? This cannot be undone.",
|
||||
"deleteFailed": "Deletion failed",
|
||||
"countChapters": "{count} ch.",
|
||||
"countWords": "{count}k words",
|
||||
"countSettings": "{count} settings"
|
||||
},
|
||||
"tour": {
|
||||
"step1Title": "📖 Create Your Story",
|
||||
"step1Content": "Click the \"+ New\" button in the left sidebar to create a new chapter. All content is saved locally in your browser in real time.",
|
||||
"step2Title": "✍️ Immersive Writing",
|
||||
"step2Content": "This is your main writing area. Supports Markdown syntax and auto-rendering, providing a paginated paper-like experience. Press Ctrl+J at the cursor to invoke inline AI assistance!",
|
||||
"step3Title": "🤖 Your Personal AI Editor",
|
||||
"step3Content": "Click here to open the AI sidebar, where you can discuss plot with AI or have it help refine your settings and provide writing suggestions.",
|
||||
"step4Title": "⚙️ Settings & API Config",
|
||||
"step4Content": "This is the brain of your workspace. Make sure to configure your AI model API Key here, otherwise the AI assistant won't work. You can also change language, theme, and build your world settings.",
|
||||
"step5Title": "💡 Always Here to Help",
|
||||
"step5Content": "If you skipped the tour or want to review it again, click the 📖 Help button. In the top-right corner of the help panel, you can restart the tour anytime.",
|
||||
"introTitle": "Welcome to Author",
|
||||
"introSubtitle": "Your AI-assisted novel writing platform.",
|
||||
"introHint": "For a better writing experience, would you like a 1-minute tour of the core features?",
|
||||
"btnSkip": "Skip, I'll explore myself",
|
||||
"btnStart": "Start Quick Tour",
|
||||
"btnPrev": "Previous",
|
||||
"btnNext": "Next",
|
||||
"btnFinish": "Got it, let's write!",
|
||||
"btnEndTour": "End Tour"
|
||||
},
|
||||
"help": {
|
||||
"title": "📖 Help Documentation",
|
||||
"btnRetour": "✨ Replay Tour"
|
||||
},
|
||||
"apiConfig": {
|
||||
"savedProfiles": "📁 Saved Profiles",
|
||||
"loadProfile": "Load: {provider} | {model}\nSaved on {date}",
|
||||
"deleteProfile": "Delete this profile",
|
||||
"intro": "🔑 Configure your AI service provider. Your API Key is stored only in your browser and never uploaded to any server.",
|
||||
"provider": "Provider",
|
||||
"geminiNativeHint": "🔷 Calls the Gemini API directly in native format, supporting all native features. Authentication uses a URL parameter (?key=), different from the OpenAI format.",
|
||||
"apiKeyPlaceholder": "Paste your API Key...",
|
||||
"apiKeyConfigured": "✓ Configured",
|
||||
"apiAddress": "API Address",
|
||||
"apiAddressAuto": "API Address (auto-filled)",
|
||||
"apiAddressPlaceholder": "https://api.example.com/v1",
|
||||
"model": "Model",
|
||||
"modelName": "Model Name",
|
||||
"fetchModels": "🔄 Fetch model list from API",
|
||||
"fetching": "Fetching…",
|
||||
"fetchedCount": "Fetched {count} models from API",
|
||||
"fetchedCountClick": "Fetched {count} models, click to select:",
|
||||
"embedTitle": "Separate Embedding (Vector) API",
|
||||
"embedDesc": "When enabled, you can configure a dedicated text embedding model separately from the chat model (the core technology for solving long-text memory issues).",
|
||||
"embedProvider": "Vector Provider",
|
||||
"embedApiKeyPlaceholder": "Leave blank if using the same key as the chat API",
|
||||
"embedApiAddress": "Embedding API Address",
|
||||
"embedApiAddressAuto": "Embedding API Address (auto-filled)",
|
||||
"embedModel": "Embedding Model Name",
|
||||
"fetchEmbedModels": "🔄 Fetch model list from API",
|
||||
"testLoading": "⏳ Testing…",
|
||||
"testBtn": "🔌 Test Connection",
|
||||
"testReply": "Reply: ",
|
||||
"saveProfileBtn": "💾 Save Current Config",
|
||||
"saveProfilePlaceholder": "Profile name",
|
||||
"saveBtn": "Save",
|
||||
"howToGetKey": "💡 How to get an API Key?",
|
||||
"keyGuide": "• ZhipuAI: Visit open.bigmodel.cn to register and create a key\n• DeepSeek: Visit platform.deepseek.com\n• Google Gemini: Visit aistudio.google.com/apikey for a free key\n• SiliconFlow: Visit siliconflow.cn (credits for new users)\n• Moonshot: Visit platform.moonshot.cn",
|
||||
"networkError": "Network connection failed",
|
||||
"fetchModelsFailed": "Failed to fetch model list",
|
||||
"fetchEmbedModelsFailed": "Failed to fetch embedding model list",
|
||||
"embedApiPrefix": "Embedding API: "
|
||||
},
|
||||
"preferences": {
|
||||
"intro": "You can change your language and visual theme preferences here at any time. All changes take effect immediately and are saved locally.",
|
||||
"langLabel": "Interface Language",
|
||||
"themeLabel": "Visual Theme",
|
||||
"themeWarm": "Warm Classic",
|
||||
"themeWarmDesc": "Eye-friendly retro paper texture, focused immersion",
|
||||
"themeModern": "Modern Glass",
|
||||
"themeModernDesc": "Minimal white-cool grey with premium frosted glass experience"
|
||||
},
|
||||
"bookInfo": {
|
||||
"intro": "💡 This information helps AI understand your work's direction and style. Even filling in just one or two fields will significantly improve AI quality.",
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "E.g.: The Daily Life of a Cultivator",
|
||||
"genre": "Genre",
|
||||
"genrePlaceholder": "E.g.: Xianxia / Urban / Mystery / Romance / Sci-Fi / Fantasy / Historical...",
|
||||
"synopsis": "Synopsis",
|
||||
"synopsisPlaceholder": "Summarize the core of the story in a few sentences",
|
||||
"style": "Writing Style",
|
||||
"stylePlaceholder": "E.g.: Light-hearted, serious, poetic, hard-boiled, light novel style...",
|
||||
"tone": "Overall Tone",
|
||||
"tonePlaceholder": "E.g.: Warm and healing, dark and oppressive, intense and passionate...",
|
||||
"pov": "Point of View",
|
||||
"povPlaceholder": "E.g.: First person (protagonist's POV), limited third person, omniscient...",
|
||||
"targetAudience": "Target Audience",
|
||||
"targetAudiencePlaceholder": "E.g.: Male web novel readers aged 18-30, female romance readers..."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"allCheck": "Select All",
|
||||
"allUncheck": "Clear All",
|
||||
"reset": "Reset"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в Author",
|
||||
"subtitle": "Ваше премиальное пространство для творчества без отвлекающих факторов.",
|
||||
"selectLang": "Выберите язык",
|
||||
"selectTheme": "Выберите тему рабочей области",
|
||||
"startBtn": "Войти в рабочую область",
|
||||
"themeWarm": {
|
||||
"name": "Теплая классика (Warm Classic)",
|
||||
"desc": "Уютная эстетика бумаги, снижающая нагрузку на глаза."
|
||||
},
|
||||
"themeModern": {
|
||||
"name": "Современное стекло (Modern Glass)",
|
||||
"desc": "Изящный полупрозрачный дизайн для максимальной концентрации."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Центр настроек",
|
||||
"subtitle": "Модуль контекста ИИ",
|
||||
"tabSettings": "📚 Настройки проекта",
|
||||
"tabApi": "🔑 API-конфигурация",
|
||||
"tabPreferences": "Предпочтения",
|
||||
"newWork": "+ Новый проект",
|
||||
"searchPlaceholder": "Поиск настроек...",
|
||||
"workLabel": "📕 Проект:",
|
||||
"confirmBtn": "Подтвердить",
|
||||
"newItem": "Новый элемент",
|
||||
"workNamePlaceholder": "Название проекта...",
|
||||
"deleteWorkPrompt": "Вы уверены, что хотите удалить проект '{name}' и все его настройки? Это действие нельзя отменить.",
|
||||
"deleteWorkAlert": "Должен остаться хотя бы один проект.",
|
||||
"deleteNodePrompt": "Вы уверены, что хотите удалить '{name}'? Если это папка, все ее содержимое будет удалено.",
|
||||
"statsTitle": "Статистика по категориям",
|
||||
"categories": {
|
||||
"character": "Персонаж",
|
||||
"location": "Локация",
|
||||
"world": "Мир",
|
||||
"object": "Предмет",
|
||||
"plot": "Сюжет",
|
||||
"rules": "Правила"
|
||||
}
|
||||
},
|
||||
"settingsTree": {
|
||||
"disabledHint": "Отключено (Игнорируется контекстом ИИ)",
|
||||
"enableHint": "Нажмите, чтобы включить",
|
||||
"disableHint": "Нажмите, чтобы отключить",
|
||||
"add": "Добавить"
|
||||
},
|
||||
"settingsEditor": {
|
||||
"aiFill": "Автозаполнение ИИ",
|
||||
"aiExtraFields": "Дополнительные поля ИИ",
|
||||
"unnamedChar": "Безымянный персонаж",
|
||||
"charRole": "Роль",
|
||||
"roles": {
|
||||
"protagonist": "Протагонист",
|
||||
"antagonist": "Антагонист",
|
||||
"supporting": "Второстепенный",
|
||||
"minor": "Эпизодический",
|
||||
"proLabel": "🌟 Протагонист",
|
||||
"antLabel": "🔥 Антагонист",
|
||||
"supLabel": "👥 Второстепенный",
|
||||
"minLabel": "🚶 Эпизодический"
|
||||
},
|
||||
"infoGender": "Пол",
|
||||
"infoAge": "Возраст",
|
||||
"infoPersonality": "Характер",
|
||||
"tabBasic": "Базовая информация",
|
||||
"tabAppearance": "Внешность и личность",
|
||||
"tabBackground": "Предыстория",
|
||||
"tabSkills": "Навыки и связи",
|
||||
"tabNotes": "Заметки",
|
||||
"tabSensory": "Сенсорные детали",
|
||||
"tabMood": "Атмосфера",
|
||||
"tabStats": "Подробные атрибуты",
|
||||
"dangerSafe": "🟢 Безопасно",
|
||||
"dangerCaution": "🟡 Осторожно",
|
||||
"dangerHigh": "🔴 Опасно",
|
||||
"emptyTitle": "Пока нет содержимого",
|
||||
"emptyDesc": "Нажмите кнопку ниже, чтобы добавить первый элемент настройки.",
|
||||
"addBtn": "+ Добавить новый элемент",
|
||||
"selectTitle": "Выберите элемент",
|
||||
"selectDesc": "Выберите элемент из дерева навигации слева, чтобы просмотреть и отредактировать его детали.",
|
||||
"statusPlanned": "📌 Запланировано",
|
||||
"statusWriting": "✏️ В процессе написания",
|
||||
"statusDone": "✅ Готово",
|
||||
"charAppearance": "Внешность",
|
||||
"charAppearancePlaceholder": "Опишите внешность персонажа…",
|
||||
"charPersonality": "Черты личности",
|
||||
"charPersonalityPlaceholder": "Опишите характер персонажа…",
|
||||
"charSpeechStyle": "Стиль речи",
|
||||
"charSpeechStylePlaceholder": "Коронные фразы, особенности тона…",
|
||||
"charBackground": "Предыстория",
|
||||
"charBackgroundPlaceholder": "Происхождение и прошлое персонажа…",
|
||||
"charMotivation": "Мотивация / Цель",
|
||||
"charMotivationPlaceholder": "К чему стремится персонаж?",
|
||||
"charArc": "Дуга персонажа",
|
||||
"charArcPlaceholder": "Как изменится персонаж?",
|
||||
"charSkills": "Способности / Навыки",
|
||||
"charSkillsPlaceholder": "Боевые искусства, магия, особые таланты…",
|
||||
"charRelationships": "Отношения",
|
||||
"charRelationshipsPlaceholder": "Отношения с другими персонажами…",
|
||||
"charNotes": "Прочие заметки",
|
||||
"charNotesPlaceholder": "Любая дополнительная информация…",
|
||||
"charGenderPlaceholder": "Мужской / Женский / Другой",
|
||||
"charAgePlaceholder": "18 / Неизвестно",
|
||||
"locDescription": "Описание локации",
|
||||
"locDescriptionPlaceholder": "Опишите эту локацию…",
|
||||
"locSlugline": "Заголовок сцены",
|
||||
"locSluglinePlaceholder": "Напр.: ИНТ. ТРОННЫЙ ЗАЛ - НОЧЬ",
|
||||
"locVisual": "Визуальное",
|
||||
"locVisualPlaceholder": "Что видно…",
|
||||
"locAudio": "Звуковое",
|
||||
"locAudioPlaceholder": "Что слышно…",
|
||||
"locSmell": "Запах / Тактильное",
|
||||
"locSmellPlaceholder": "Что чувствуется…",
|
||||
"locMood": "Атмосфера",
|
||||
"locMoodPlaceholder": "Мрачная / Тёплая / Таинственная…",
|
||||
"locDangerLevel": "Уровень опасности",
|
||||
"objDescription": "Описание",
|
||||
"objDescriptionPlaceholder": "Опишите этот предмет…",
|
||||
"objType": "Тип",
|
||||
"objTypePlaceholder": "Оружие / Артефакт / Зелье…",
|
||||
"objRank": "Ранг / Уровень",
|
||||
"objRankPlaceholder": "Обычный / Редкий / Легендарный…",
|
||||
"objHolder": "Текущий владелец",
|
||||
"objHolderPlaceholder": "Кому принадлежит?",
|
||||
"objStats": "Числовые характеристики",
|
||||
"objStatsPlaceholder": "Атака +100…",
|
||||
"objSymbolism": "Символизм",
|
||||
"objSymbolismPlaceholder": "Что это символизирует?",
|
||||
"worldDescription": "Описание мира",
|
||||
"worldDescriptionPlaceholder": "Опишите эту деталь мира…",
|
||||
"worldNotes": "Заметки",
|
||||
"worldNotesPlaceholder": "Дополнительные заметки…",
|
||||
"plotDescription": "Описание сюжета",
|
||||
"plotDescriptionPlaceholder": "Опишите этот сюжетный момент…",
|
||||
"plotStatus": "Статус",
|
||||
"plotNotes": "Заметки",
|
||||
"plotNotesPlaceholder": "Дополнительные заметки…",
|
||||
"rulesDescription": "Описание правила",
|
||||
"rulesDescriptionPlaceholder": "Опишите это правило писательства; ИИ будет строго его соблюдать…",
|
||||
"genericDescription": "Описание",
|
||||
"genericDescriptionPlaceholder": "Введите описание…",
|
||||
"genericNotes": "Заметки",
|
||||
"genericNotesPlaceholder": "Дополнительные заметки…",
|
||||
"folderSubfolders": "{count} подпапка(ок) · ",
|
||||
"folderItems": "{count} элемент(ов)"
|
||||
},
|
||||
"aiSidebar": {
|
||||
"title": "✦ ИИ-ассистент",
|
||||
"tabChat": "💬 Чат",
|
||||
"tabArchive": "📋 Архив",
|
||||
"tabReference": "📚 Справочник",
|
||||
"btnSessionList": "Список сессий",
|
||||
"btnNewSession": "Новый чат",
|
||||
"btnClose": "Закрыть",
|
||||
"historyCount": "История чата ({count})",
|
||||
"rename": "Переименовать",
|
||||
"delete": "Удалить",
|
||||
"slidingWindow": "Скользящее окно",
|
||||
"summarize": "📝 Резюмировать",
|
||||
"summarizeTitle": "Резюмировать отмеченную историю чата в дайджест",
|
||||
"clearChat": "🗑",
|
||||
"clearChatTitle": "Очистить все чаты",
|
||||
"editSummary": "Редактировать резюме (заменит отмеченную историю после подтверждения)",
|
||||
"cancel": "Отмена",
|
||||
"confirmReplace": "✓ Заменить",
|
||||
"emptyChatIcon": "✦",
|
||||
"emptyChatTitle": "Начните чат с ИИ",
|
||||
"emptyChatHint": "Задавайте любые вопросы о писательстве",
|
||||
"roleYou": "Вы",
|
||||
"roleSummary": "📝 Резюме",
|
||||
"roleAi": "ИИ",
|
||||
"edited": "Изменено",
|
||||
"btnEdit": "✏️",
|
||||
"btnResend": "🔄",
|
||||
"btnRegenerate": "🔄",
|
||||
"btnBranch": "🔀",
|
||||
"thinkingChain": "💡 Цепь рассуждений",
|
||||
"save": "✓ Сохранить",
|
||||
"actionsApply": "✅ Применить",
|
||||
"actionsApplied": "✓ Применено",
|
||||
"inputPlaceholder": "Введите сообщение... (Enter для отправки, Shift+Enter для новой строки)",
|
||||
"searchArchive": "Поиск по генерациям...",
|
||||
"emptyArchiveIcon": "📋",
|
||||
"emptyArchiveTitle": "Пока нет генераций",
|
||||
"emptyArchiveHint": "Сгенерированный ИИ контент будет автоматически сохраняться здесь",
|
||||
"insertEditor": "↓ Вставить в редактор",
|
||||
"copy": "📋 Копировать",
|
||||
"tokenUsage": "Использование токенов",
|
||||
"searchContext": "🔍 Поиск элементов...",
|
||||
"emptyContextIcon": "📚",
|
||||
"emptyContextTitle": "Нет элементов контекста",
|
||||
"emptyContextHint1": "Нет совпадений",
|
||||
"emptyContextHint2": "Пожалуйста, сначала добавьте настройки",
|
||||
"selectAll": "Выбрать все",
|
||||
"selectNone": "Снять выделение",
|
||||
"reset": "Сброс",
|
||||
"settings": "⚙️ Настройки",
|
||||
"categories": {
|
||||
"character": "👤 Персонаж",
|
||||
"world": "🌍 Мир",
|
||||
"location": "🗺️ Локация",
|
||||
"object": "🔮 Предмет",
|
||||
"plot": "📋 Сюжет",
|
||||
"rules": "📐 Правила",
|
||||
"custom": "⚙️ Пользовательский"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Добавить",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"modes": {
|
||||
"continue": "Продолжить",
|
||||
"rewrite": "Переписать",
|
||||
"expand": "Расширить",
|
||||
"condense": "Сократить",
|
||||
"dialogue": "Диалог",
|
||||
"chat": "Чат"
|
||||
},
|
||||
"statuses": {
|
||||
"accepted": "✓ Принято",
|
||||
"rejected": "✗ Отклонено",
|
||||
"pending": "⏳ Ожидание"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"chat": "💬 Чат",
|
||||
"archive": "📋 Архив",
|
||||
"reference": "📚 Справка",
|
||||
"inputPlaceholder": "Введите сообщение... (Enter - отправить, Shift+Enter - перенос строки)",
|
||||
"newChapter": "+ Новая глава",
|
||||
"chapterList": "Список глав",
|
||||
"totalWords": "Всего слов",
|
||||
"exportCurrent": "Экспорт текущей",
|
||||
"exportAll": "Экспорт всех",
|
||||
"tooltipSettings": "Управление настройками",
|
||||
"tooltipTimeMachine": "Машина времени (История версий)",
|
||||
"tooltipThemeLight": "Светлая тема",
|
||||
"tooltipThemeDark": "Темная тема",
|
||||
"btnSave": "💾 Сохранить",
|
||||
"btnLoad": "📂 Загрузить",
|
||||
"contextRename": "✎ Переименовать",
|
||||
"contextExport": "↓ Экспорт Markdown",
|
||||
"contextDelete": "✕ Удалить главу",
|
||||
"deleteConfirm": "Удалить главу",
|
||||
"alertRetainOne": "Должна остаться хотя бы одна глава",
|
||||
"collapseSidebar": "Свернуть панель",
|
||||
"expandSidebar": "Развернуть панель",
|
||||
"chapterCreated": "Создано \"{title}\"",
|
||||
"chapterDeleted": "Удалено \"{title}\"",
|
||||
"wordUnit": " сл.",
|
||||
"defaultChapterTitle": "Глава {num}",
|
||||
"modeLabel": "Режим {mode}",
|
||||
"clickToSwitchMode": "Нажмите для смены режима",
|
||||
"exportedChapter": "Текущая глава экспортирована",
|
||||
"exportedAll": "Все главы экспортированы",
|
||||
"importSuccess": "Страница перезагрузится автоматически."
|
||||
},
|
||||
"page": {
|
||||
"noChapterHint": "Выберите или создайте главу, чтобы начать писать",
|
||||
"expandSidebar": "Развернуть панель",
|
||||
"openAiAssistant": "Открыть ИИ-ассистент",
|
||||
"helpAndGuide": "Справка и руководство",
|
||||
"toastInserted": "Вставлено в редактор",
|
||||
"toastRequestFailed": "Запрос не выполнен",
|
||||
"toastStopped": "Генерация остановлена",
|
||||
"toastNetworkError": "Ошибка сетевого соединения",
|
||||
"dialogueUser": "Пользователь",
|
||||
"dialogueHistory": "💬 История чата",
|
||||
"autoSnapshot": "Автосохранение",
|
||||
"autoSnapshotFail": "Ошибка автосохранения:",
|
||||
"firstChapterTitle": "Глава 1"
|
||||
},
|
||||
"snapshot": {
|
||||
"title": "🕒 Машина времени (Снимки версий)",
|
||||
"subtitle": "Автоматически сохраняйте историю версий вашего проекта и управляйте ею",
|
||||
"createBtn": "+ Новый ручной снимок",
|
||||
"creating": "Сохранение...",
|
||||
"loading": "Загрузка...",
|
||||
"empty": "Снимков пока нет",
|
||||
"deleteBtn": "Удалить",
|
||||
"createdAt": "Создан:",
|
||||
"autoLabel": "(Автоснимок)",
|
||||
"chapterCount": "Глав",
|
||||
"totalWords": "Всего слов",
|
||||
"settingCount": "Настроек",
|
||||
"includedChapters": "Включённые главы:",
|
||||
"andMore": "... и ещё",
|
||||
"restoreBtn": "⚠️ Восстановить до этой версии",
|
||||
"restoring": "Восстановление...",
|
||||
"restoreWarning": "После восстановления все несохранённые изменения текущего проекта будут перезаписаны. Снимок будет создан автоматически перед перезаписью, на всякий случай.",
|
||||
"selectHint": "Выберите версию слева для просмотра деталей",
|
||||
"promptLabel": "Введите описание снимка (напр.: Резервная копия перед крупными правками):",
|
||||
"promptDefault": "Ручной снимок",
|
||||
"createFailed": "Не удалось создать снимок: ",
|
||||
"confirmRestore": "Предупреждение: все главы и настройки будут перезаписаны состоянием из «{label}» ({date}). Текущее состояние будет автоматически сохранено. Подтвердить откат?",
|
||||
"restoreSuccess": "Откат выполнен! Страница перезагрузится для загрузки предыдущего состояния.",
|
||||
"restoreFailed": "Откат не выполнен: ",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить этот снимок? Это действие нельзя отменить.",
|
||||
"deleteFailed": "Удаление не выполнено",
|
||||
"countChapters": "{count} гл.",
|
||||
"countWords": "{count}k сл.",
|
||||
"countSettings": "{count} настр."
|
||||
},
|
||||
"tour": {
|
||||
"step1Title": "📖 Создайте свою историю",
|
||||
"step1Content": "Нажмите кнопку «+ Новая» на левой панели, чтобы создать новую главу. Всё содержимое сохраняется локально в вашем браузере в режиме реального времени.",
|
||||
"step2Title": "✍️ Захватывающее написание",
|
||||
"step2Content": "Это ваше основное поле для написания. Поддерживает синтаксис Markdown и автоматический рендеринг, предоставляя постраничный опыт, похожий на бумагу. Нажмите Ctrl+J в месте курсора для вызова встроенного ИИ-помощника!",
|
||||
"step3Title": "🤖 Ваш персональный ИИ-редактор",
|
||||
"step3Content": "Нажмите здесь, чтобы открыть боковую панель ИИ, где вы можете обсуждать сюжет с ИИ или попросить его уточнить настройки и дать советы по написанию.",
|
||||
"step4Title": "⚙️ Настройки и конфигурация API",
|
||||
"step4Content": "Это мозг вашего рабочего пространства. Обязательно настройте здесь API Key вашей языковой модели, иначе ИИ-ассистент не будет работать. Вы также можете изменить язык, тему и создать настройки мира.",
|
||||
"step5Title": "💡 Всегда готов помочь",
|
||||
"step5Content": "Если вы пропустили тур или хотите просмотреть его снова, нажмите кнопку 📖 Справка. В правом верхнем углу панели справки вы можете в любое время перезапустить тур.",
|
||||
"introTitle": "Добро пожаловать в Author",
|
||||
"introSubtitle": "Ваша платформа для написания романов с помощью ИИ.",
|
||||
"introHint": "Для лучшего опыта написания, хотите ли вы пройти 1-минутный тур по основным функциям?",
|
||||
"btnSkip": "Пропустить, я разберусь сам",
|
||||
"btnStart": "Начать быстрый тур",
|
||||
"btnPrev": "Назад",
|
||||
"btnNext": "Далее",
|
||||
"btnFinish": "Понял, начнём писать!",
|
||||
"btnEndTour": "Завершить тур"
|
||||
},
|
||||
"help": {
|
||||
"title": "📖 Справочная документация",
|
||||
"btnRetour": "✨ Повторить тур"
|
||||
},
|
||||
"apiConfig": {
|
||||
"savedProfiles": "📁 Сохранённые профили",
|
||||
"loadProfile": "Загрузить: {provider} | {model}\nСохранено {date}",
|
||||
"deleteProfile": "Удалить этот профиль",
|
||||
"intro": "🔑 Настройте вашего провайдера услуг ИИ. Ваш API Key хранится только в вашем браузере и никогда не загружается ни на один сервер.",
|
||||
"provider": "Провайдер",
|
||||
"geminiNativeHint": "🔷 Вызывает Gemini API напрямую в нативном формате, поддерживая все нативные функции. Аутентификация использует параметр URL (?key=), отличаясь от формата OpenAI.",
|
||||
"apiKeyPlaceholder": "Вставьте ваш API Key...",
|
||||
"apiKeyConfigured": "✓ Настроен",
|
||||
"apiAddress": "API Адрес",
|
||||
"apiAddressAuto": "API Адрес (автозаполнение)",
|
||||
"apiAddressPlaceholder": "https://api.example.com/v1",
|
||||
"model": "Модель",
|
||||
"modelName": "Название модели",
|
||||
"fetchModels": "🔄 Получить список моделей из API",
|
||||
"fetching": "Получение…",
|
||||
"fetchedCount": "Получено {count} моделей из API",
|
||||
"fetchedCountClick": "Получено {count} моделей, нажмите для выбора:",
|
||||
"embedTitle": "Отдельный API Embedding (Векторный)",
|
||||
"embedDesc": "При включении вы можете настроить специальную модель векторного встраивания текста отдельно от модели чата (ключевая технология для решения проблем долговременной памяти).",
|
||||
"embedProvider": "Векторный провайдер",
|
||||
"embedApiKeyPlaceholder": "Оставьте пустым, если используете тот же ключ, что и для чата",
|
||||
"embedApiAddress": "Адрес Embedding API",
|
||||
"embedApiAddressAuto": "Адрес Embedding API (автозаполнение)",
|
||||
"embedModel": "Название Embedding модели",
|
||||
"fetchEmbedModels": "🔄 Получить список моделей из API",
|
||||
"testLoading": "⏳ Тестирование…",
|
||||
"testBtn": "🔌 Проверить подключение",
|
||||
"testReply": "Ответ: ",
|
||||
"saveProfileBtn": "💾 Сохранить текущую конфигурацию",
|
||||
"saveProfilePlaceholder": "Название профиля",
|
||||
"saveBtn": "Сохранить",
|
||||
"howToGetKey": "💡 Как получить API Key?",
|
||||
"keyGuide": "• ZhipuAI: Посетите open.bigmodel.cn для регистрации\n• DeepSeek: Посетите platform.deepseek.com\n• Google Gemini: Посетите aistudio.google.com/apikey\n• SiliconFlow: Посетите siliconflow.cn\n• Moonshot: Посетите platform.moonshot.cn",
|
||||
"networkError": "Ошибка сетевого соединения",
|
||||
"fetchModelsFailed": "Не удалось получить список моделей",
|
||||
"fetchEmbedModelsFailed": "Не удалось получить список моделей Embedding",
|
||||
"embedApiPrefix": "Embedding API: "
|
||||
},
|
||||
"preferences": {
|
||||
"intro": "Здесь вы можете в любое время изменить язык и визуальную тему. Все изменения вступают в силу немедленно и сохраняются локально.",
|
||||
"langLabel": "Язык интерфейса",
|
||||
"themeLabel": "Визуальная тема",
|
||||
"themeWarm": "Тёплая классика (Warm Classic)",
|
||||
"themeWarmDesc": "Бережная для глаз ретро-бумажная текстура, сосредоточенное погружение",
|
||||
"themeModern": "Современное стекло (Modern Glass)",
|
||||
"themeModernDesc": "Минималистичный белый/холодно-серый с премиальным матовым стеклом"
|
||||
},
|
||||
"bookInfo": {
|
||||
"intro": "💡 Эта информация помогает ИИ понять направление и стиль вашего произведения. Даже заполнение одного-двух полей значительно улучшит качество ИИ.",
|
||||
"title": "Название произведения",
|
||||
"titlePlaceholder": "Напр.: Повседневная жизнь культиватора",
|
||||
"genre": "Жанр",
|
||||
"genrePlaceholder": "Напр.: Сянься / Городская проза / Детектив / Романс / Фантастика...",
|
||||
"synopsis": "Синопсис",
|
||||
"synopsisPlaceholder": "Опишите суть истории в нескольких предложениях",
|
||||
"style": "Стиль написания",
|
||||
"stylePlaceholder": "Напр.: Лёгкий и юмористический, серьёзный, поэтический...",
|
||||
"tone": "Общий тон",
|
||||
"tonePlaceholder": "Напр.: Тёплый и исцеляющий, мрачный, интенсивный...",
|
||||
"pov": "Точка зрения",
|
||||
"povPlaceholder": "Напр.: Первое лицо, ограниченное третье лицо, всезнающий...",
|
||||
"targetAudience": "Целевая аудитория",
|
||||
"targetAudiencePlaceholder": "Напр.: Мужская аудитория 18-30 лет, читатели романтики..."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"delete": "Удалить",
|
||||
"rename": "Переименовать",
|
||||
"allCheck": "Выбрать все",
|
||||
"allUncheck": "Очистить все",
|
||||
"reset": "Сброс"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "欢迎来到 Author",
|
||||
"subtitle": "您的尊享级无干扰创作空间。",
|
||||
"selectLang": "选择语言",
|
||||
"selectTheme": "选择您的工作台主题",
|
||||
"startBtn": "进入工作台",
|
||||
"themeWarm": {
|
||||
"name": "经典护眼 (Warm Classic)",
|
||||
"desc": "温润如纸的复古色调,久看书写不疲劳。"
|
||||
},
|
||||
"themeModern": {
|
||||
"name": "现代通透 (Modern Glass)",
|
||||
"desc": "通透干净的磨砂质感,极致专注的悬浮体验。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "设定集管理",
|
||||
"subtitle": "AI写作时会自动参考这些信息",
|
||||
"tabSettings": "📚 创作设定集",
|
||||
"tabApi": "🔑 API配置",
|
||||
"tabPreferences": "偏好设置",
|
||||
"newWork": "+ 新作品",
|
||||
"searchPlaceholder": "搜索设定…",
|
||||
"workLabel": "📕 作品:",
|
||||
"confirmBtn": "确定",
|
||||
"newItem": "新条目",
|
||||
"workNamePlaceholder": "作品名称…",
|
||||
"deleteWorkPrompt": "确定删除作品「{name}」及其所有设定?此操作不可撤销。",
|
||||
"deleteWorkAlert": "至少需要保留一个作品",
|
||||
"deleteNodePrompt": "确定删除「{name}」?如果是文件夹,其下所有内容都会被删除。",
|
||||
"statsTitle": "按设定类别统计",
|
||||
"categories": {
|
||||
"character": "人物",
|
||||
"location": "地点",
|
||||
"world": "世界观",
|
||||
"object": "物品",
|
||||
"plot": "大纲",
|
||||
"rules": "规则"
|
||||
}
|
||||
},
|
||||
"settingsTree": {
|
||||
"disabledHint": "已禁用(不参与AI上下文)",
|
||||
"enableHint": "点击启用",
|
||||
"disableHint": "点击禁用",
|
||||
"add": "添加"
|
||||
},
|
||||
"settingsEditor": {
|
||||
"aiFill": "AI 智能填写",
|
||||
"aiExtraFields": "AI 生成的额外字段",
|
||||
"unnamedChar": "未命名角色",
|
||||
"charRole": "角色",
|
||||
"roles": {
|
||||
"protagonist": "主角",
|
||||
"antagonist": "反派",
|
||||
"supporting": "配角",
|
||||
"minor": "路人",
|
||||
"proLabel": "🌟 主角",
|
||||
"antLabel": "🔥 反派",
|
||||
"supLabel": "👥 配角",
|
||||
"minLabel": "🚶 路人"
|
||||
},
|
||||
"infoGender": "性别",
|
||||
"infoAge": "年龄",
|
||||
"infoPersonality": "性格",
|
||||
"tabBasic": "基础信息",
|
||||
"tabAppearance": "外貌与性格",
|
||||
"tabBackground": "背景故事",
|
||||
"tabSkills": "能力与关系",
|
||||
"tabNotes": "备注",
|
||||
"tabSensory": "感官细节",
|
||||
"tabMood": "氛围",
|
||||
"tabStats": "详细属性",
|
||||
"dangerSafe": "🟢 安全",
|
||||
"dangerCaution": "🟡 小心",
|
||||
"dangerHigh": "🔴 危险",
|
||||
"emptyTitle": "还没有内容",
|
||||
"emptyDesc": "点击下方按钮添加你的第一个设定项",
|
||||
"addBtn": "+ 添加新设定项",
|
||||
"selectTitle": "选择一个设定项",
|
||||
"selectDesc": "从左侧树形导航中选择一个项目来查看和编辑它的详细信息",
|
||||
"statusPlanned": "📌 计划中",
|
||||
"statusWriting": "✏️ 写作中",
|
||||
"statusDone": "✅ 已完成",
|
||||
"charAppearance": "外貌描写",
|
||||
"charAppearancePlaceholder": "描述角色的外貌特征…",
|
||||
"charPersonality": "性格特征",
|
||||
"charPersonalityPlaceholder": "描述角色的性格…",
|
||||
"charSpeechStyle": "说话风格",
|
||||
"charSpeechStylePlaceholder": "口头禅、语气特点…",
|
||||
"charBackground": "背景经历",
|
||||
"charBackgroundPlaceholder": "角色的来历和过往…",
|
||||
"charMotivation": "动机/目标",
|
||||
"charMotivationPlaceholder": "角色追求什么?",
|
||||
"charArc": "成长弧线",
|
||||
"charArcPlaceholder": "角色将经历怎样的转变?",
|
||||
"charSkills": "能力/技能",
|
||||
"charSkillsPlaceholder": "武功、法术、特长…",
|
||||
"charRelationships": "人物关系",
|
||||
"charRelationshipsPlaceholder": "与其他角色的关系…",
|
||||
"charNotes": "其他备注",
|
||||
"charNotesPlaceholder": "任何补充信息…",
|
||||
"charGenderPlaceholder": "男/女/其他",
|
||||
"charAgePlaceholder": "18岁 / 不详",
|
||||
"locDescription": "地点描述",
|
||||
"locDescriptionPlaceholder": "描述这个地点…",
|
||||
"locSlugline": "场景标题",
|
||||
"locSluglinePlaceholder": "如:内景 - 皇宫大殿 - 夜",
|
||||
"locVisual": "视觉",
|
||||
"locVisualPlaceholder": "看到什么…",
|
||||
"locAudio": "听觉",
|
||||
"locAudioPlaceholder": "听到什么…",
|
||||
"locSmell": "嗅觉/触觉",
|
||||
"locSmellPlaceholder": "闻到/摸到什么…",
|
||||
"locMood": "整体氛围",
|
||||
"locMoodPlaceholder": "压抑/温馨/神秘…",
|
||||
"locDangerLevel": "危险等级",
|
||||
"objDescription": "描述",
|
||||
"objDescriptionPlaceholder": "描述这个物品…",
|
||||
"objType": "类型",
|
||||
"objTypePlaceholder": "武器/法宝/药品…",
|
||||
"objRank": "品阶",
|
||||
"objRankPlaceholder": "凡品/灵品/仙品…",
|
||||
"objHolder": "当前持有者",
|
||||
"objHolderPlaceholder": "谁拥有它?",
|
||||
"objStats": "数值属性",
|
||||
"objStatsPlaceholder": "攻击力+100…",
|
||||
"objSymbolism": "象征意义",
|
||||
"objSymbolismPlaceholder": "它象征着什么?",
|
||||
"worldDescription": "设定描述",
|
||||
"worldDescriptionPlaceholder": "描述这条世界观设定…",
|
||||
"worldNotes": "备注",
|
||||
"worldNotesPlaceholder": "补充说明…",
|
||||
"plotDescription": "剧情描述",
|
||||
"plotDescriptionPlaceholder": "描述这段剧情…",
|
||||
"plotStatus": "状态",
|
||||
"plotNotes": "备注",
|
||||
"plotNotesPlaceholder": "补充说明…",
|
||||
"rulesDescription": "规则描述",
|
||||
"rulesDescriptionPlaceholder": "描述这条写作规则,AI 生成内容时将严格遵守…",
|
||||
"genericDescription": "描述",
|
||||
"genericDescriptionPlaceholder": "输入描述内容…",
|
||||
"genericNotes": "备注",
|
||||
"genericNotesPlaceholder": "补充说明…",
|
||||
"folderSubfolders": "{count} 个子文件夹 · ",
|
||||
"folderItems": "{count} 个设定项"
|
||||
},
|
||||
"aiSidebar": {
|
||||
"title": "✦ AI 助手",
|
||||
"tabChat": "💬 对话",
|
||||
"tabArchive": "📋 存档",
|
||||
"tabReference": "📚 参考",
|
||||
"btnSessionList": "会话列表",
|
||||
"btnNewSession": "新建对话",
|
||||
"btnClose": "关闭",
|
||||
"historyCount": "对话历史 ({count})",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"slidingWindow": "滑动窗口",
|
||||
"summarize": "📝 总结",
|
||||
"summarizeTitle": "将勾选的对话历史总结为一条摘要",
|
||||
"clearChat": "🗑",
|
||||
"clearChatTitle": "清空全部对话",
|
||||
"editSummary": "编辑摘要(确认后将替代勾选的历史消息)",
|
||||
"cancel": "取消",
|
||||
"confirmReplace": "✓ 确认替代",
|
||||
"emptyChatIcon": "✦",
|
||||
"emptyChatTitle": "开始与 AI 对话",
|
||||
"emptyChatHint": "可以问任何关于创作的问题",
|
||||
"roleYou": "你",
|
||||
"roleSummary": "📝 摘要",
|
||||
"roleAi": "AI",
|
||||
"edited": "已编辑",
|
||||
"btnEdit": "✏️",
|
||||
"btnResend": "🔄",
|
||||
"btnRegenerate": "🔄",
|
||||
"btnBranch": "🔀",
|
||||
"thinkingChain": "💡 思维链",
|
||||
"save": "✓ 保存",
|
||||
"actionsApply": "✅ 应用",
|
||||
"actionsApplied": "✓ 已应用",
|
||||
"inputPlaceholder": "输入消息…(Enter 发送,Shift+Enter 换行)",
|
||||
"searchArchive": "搜索生成内容…",
|
||||
"emptyArchiveIcon": "📋",
|
||||
"emptyArchiveTitle": "暂无生成记录",
|
||||
"emptyArchiveHint": "AI 生成的内容会自动归档到这里",
|
||||
"insertEditor": "↓ 插入编辑器",
|
||||
"copy": "📋 复制",
|
||||
"tokenUsage": "Token 用量",
|
||||
"searchContext": "🔍 搜索条目…",
|
||||
"emptyContextIcon": "📚",
|
||||
"emptyContextTitle": "暂无上下文条目",
|
||||
"emptyContextHint1": "没有匹配的条目",
|
||||
"emptyContextHint2": "请先在设定集中添加设定",
|
||||
"selectAll": "全选",
|
||||
"selectNone": "全不选",
|
||||
"reset": "重置",
|
||||
"settings": "⚙️ 设定集",
|
||||
"categories": {
|
||||
"character": "👤 角色",
|
||||
"world": "🌍 世界观",
|
||||
"location": "🗺️ 地点",
|
||||
"object": "🔮 物品",
|
||||
"plot": "📋 大纲",
|
||||
"rules": "📐 规则",
|
||||
"custom": "⚙️ 自定义"
|
||||
},
|
||||
"actions": {
|
||||
"add": "新增",
|
||||
"update": "更新",
|
||||
"delete": "删除"
|
||||
},
|
||||
"modes": {
|
||||
"continue": "续写",
|
||||
"rewrite": "改写",
|
||||
"expand": "扩写",
|
||||
"condense": "精简",
|
||||
"dialogue": "对话",
|
||||
"chat": "对话"
|
||||
},
|
||||
"statuses": {
|
||||
"accepted": "✓ 已接受",
|
||||
"rejected": "✗ 已拒绝",
|
||||
"pending": "⏳ 待确认"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"chat": "💬 对话",
|
||||
"archive": "📋 存档",
|
||||
"reference": "📚 参考",
|
||||
"inputPlaceholder": "输入消息…(Enter 发送,Shift+Enter 换行)",
|
||||
"newChapter": "+ 新建章节",
|
||||
"chapterList": "章节列表",
|
||||
"totalWords": "全书总字数",
|
||||
"exportCurrent": "导出本章",
|
||||
"exportAll": "导出全部",
|
||||
"tooltipSettings": "设定集管理",
|
||||
"tooltipTimeMachine": "时光机(版本历史)",
|
||||
"tooltipThemeLight": "切换浅色模式",
|
||||
"tooltipThemeDark": "切换深色模式",
|
||||
"btnSave": "💾 存档",
|
||||
"btnLoad": "📂 读档",
|
||||
"contextRename": "✎ 重命名",
|
||||
"contextExport": "↓ 导出Markdown",
|
||||
"contextDelete": "✕ 删除章节",
|
||||
"deleteConfirm": "确定删除章节",
|
||||
"alertRetainOne": "至少保留一个章节",
|
||||
"collapseSidebar": "收起侧栏",
|
||||
"expandSidebar": "展开侧栏",
|
||||
"chapterCreated": "已创建「{title}」",
|
||||
"chapterDeleted": "已删除「{title}」",
|
||||
"wordUnit": "字",
|
||||
"defaultChapterTitle": "第{num}章",
|
||||
"modeLabel": "{mode}模式",
|
||||
"clickToSwitchMode": "点击切换写作模式",
|
||||
"exportedChapter": "已导出当前章节",
|
||||
"exportedAll": "已导出全部章节",
|
||||
"importSuccess": "页面将自动刷新。"
|
||||
},
|
||||
"page": {
|
||||
"noChapterHint": "请选择或创建一个章节开始写作",
|
||||
"expandSidebar": "展开侧栏",
|
||||
"openAiAssistant": "打开 AI 助手",
|
||||
"helpAndGuide": "帮助与操作指南",
|
||||
"toastInserted": "已插入到编辑器",
|
||||
"toastRequestFailed": "请求失败",
|
||||
"toastStopped": "已停止生成",
|
||||
"toastNetworkError": "网络连接失败",
|
||||
"dialogueUser": "用户",
|
||||
"dialogueHistory": "💬 对话历史",
|
||||
"autoSnapshot": "系统自动存档",
|
||||
"autoSnapshotFail": "自动存档失败:",
|
||||
"firstChapterTitle": "第一章"
|
||||
},
|
||||
"snapshot": {
|
||||
"title": "🕒 时光机 (版本快照)",
|
||||
"subtitle": "自动保存和管理你的项目历史版本",
|
||||
"createBtn": "+ 新建手动存档",
|
||||
"creating": "存档中...",
|
||||
"loading": "加载中...",
|
||||
"empty": "暂无历史存档",
|
||||
"deleteBtn": "删除",
|
||||
"createdAt": "创建于:",
|
||||
"autoLabel": "(系统自动存档)",
|
||||
"chapterCount": "章节数",
|
||||
"totalWords": "总字数",
|
||||
"settingCount": "设定项",
|
||||
"includedChapters": "包含的章节片段:",
|
||||
"andMore": "... 等等",
|
||||
"restoreBtn": "⚠️ 恢复到此版本",
|
||||
"restoring": "回滚中...",
|
||||
"restoreWarning": "恢复后,当前项目的所有未保存修改将被覆盖。但在覆盖前,系统会自动创建一次快照以防万一。",
|
||||
"selectHint": "请选择左侧的历史版本查看详情",
|
||||
"promptLabel": "请输入手动存档说明(例如:大改前备份):",
|
||||
"promptDefault": "手动存档",
|
||||
"createFailed": "创建存档失败:",
|
||||
"confirmRestore": "警告:这会将所有章节和设定集覆盖为「{label}」({date}) 的状态。当前状态会自动备份。确认回滚吗?",
|
||||
"restoreSuccess": "回滚成功!页面将刷新以加载上一个状态。",
|
||||
"restoreFailed": "回滚失败:",
|
||||
"confirmDelete": "确定删除此存档吗?不可恢复。",
|
||||
"deleteFailed": "删除失败",
|
||||
"countChapters": "{count}章",
|
||||
"countWords": "{count}k字",
|
||||
"countSettings": "{count}项设定"
|
||||
},
|
||||
"tour": {
|
||||
"step1Title": "📖 创建你的故事",
|
||||
"step1Content": "点击左侧边栏的「+ 新建」可以创建新章节。所有内容都会实时保存在你的浏览器本地。",
|
||||
"step2Title": "✍️ 沉浸式创作",
|
||||
"step2Content": "这是你的写作主场。支持 Markdown 语法与自动渲染,提供分页的类纸张体验。在光标处按下 Ctrl+J 即可唤起内联 AI 协助续写!",
|
||||
"step3Title": "🤖 你的专属 AI 编辑",
|
||||
"step3Content": "点击这里打开 AI 侧栏,可以与 AI 讨论剧情,也可以让它帮你修改设定集、提供写作建议。",
|
||||
"step4Title": "⚙️ 设定集与 API 配置",
|
||||
"step4Content": "这是工作台的大脑中枢。请务必在此配置你的大模型 API Key,否则 AI 助理无法工作。另外,你还可以在这里随时更改语言、主题、并建立属于你的世界观设定。",
|
||||
"step5Title": "💡 随时准备为您服务",
|
||||
"step5Content": "如果您跳过了引导,或希望重新回顾,都可以点击这个 📖 帮助按钮。在帮助面板的右上角,您可以随时重新开启向导。",
|
||||
"introTitle": "欢迎来到 Author",
|
||||
"introSubtitle": "你的 AI 辅助小说创作平台。",
|
||||
"introHint": "为了更好的创作体验,需要花 1 分钟为您介绍核心功能吗?",
|
||||
"btnSkip": "跳过,我自己探索",
|
||||
"btnStart": "开启快速向导",
|
||||
"btnPrev": "上一步",
|
||||
"btnNext": "下一步",
|
||||
"btnFinish": "明白,开始创作",
|
||||
"btnEndTour": "直接结束引导"
|
||||
},
|
||||
"help": {
|
||||
"title": "📖 帮助文档",
|
||||
"btnRetour": "✨ 重新查看向导"
|
||||
},
|
||||
"apiConfig": {
|
||||
"savedProfiles": "📁 已保存的配置方案",
|
||||
"loadProfile": "加载:{provider} | {model}\n保存于 {date}",
|
||||
"deleteProfile": "删除此方案",
|
||||
"intro": "🔑 配置你的AI服务提供商。API Key 仅存储在你的浏览器本地,不会上传到任何服务器。",
|
||||
"provider": "服务提供商",
|
||||
"geminiNativeHint": "🔷 原生格式直接调用 Gemini API,支持全部原生功能。认证方式使用 URL 参数(?key=),与 OpenAI 格式不同。",
|
||||
"apiKeyPlaceholder": "粘贴你的 API Key...",
|
||||
"apiKeyConfigured": "✓ 已配置",
|
||||
"apiAddress": "API 地址",
|
||||
"apiAddressAuto": "API 地址(已自动填入)",
|
||||
"apiAddressPlaceholder": "https://api.example.com/v1",
|
||||
"model": "模型",
|
||||
"modelName": "模型名称",
|
||||
"fetchModels": "🔄 从API拉取模型列表",
|
||||
"fetching": "拉取中…",
|
||||
"fetchedCount": "已从 API 拉取到 {count} 个模型",
|
||||
"fetchedCountClick": "已拉取到 {count} 个模型,点击选择:",
|
||||
"embedTitle": "独立配置 Embedding (向量) API",
|
||||
"embedDesc": "开启后,您可以将普通对话模型与专门的文本向量模型(解决长文本记忆的核心技术)分开配置。",
|
||||
"embedProvider": "向量服务提供商",
|
||||
"embedApiKeyPlaceholder": "如果你和对话接口用的是同一个 Key,可留空",
|
||||
"embedApiAddress": "Embedding API 地址",
|
||||
"embedApiAddressAuto": "Embedding API 地址(已自动填入)",
|
||||
"embedModel": "Embedding 模型名称",
|
||||
"fetchEmbedModels": "🔄 从API拉取模型列表",
|
||||
"testLoading": "⏳ 测试中…",
|
||||
"testBtn": "🔌 测试连接",
|
||||
"testReply": "回复:",
|
||||
"saveProfileBtn": "💾 保存当前配置",
|
||||
"saveProfilePlaceholder": "方案名称",
|
||||
"saveBtn": "保存",
|
||||
"howToGetKey": "💡 如何获取 API Key?",
|
||||
"keyGuide": "• 智谱AI:访问 open.bigmodel.cn 注册并创建 Key\n• DeepSeek:访问 platform.deepseek.com\n• Google Gemini:访问 aistudio.google.com/apikey 获取免费 Key\n• SiliconFlow:访问 siliconflow.cn(新用户送额度)\n• Moonshot:访问 platform.moonshot.cn",
|
||||
"networkError": "网络连接失败",
|
||||
"fetchModelsFailed": "拉取模型列表失败",
|
||||
"fetchEmbedModelsFailed": "拉取向量模型列表失败",
|
||||
"embedApiPrefix": "向量 API: "
|
||||
},
|
||||
"preferences": {
|
||||
"intro": "在这里可以随时更改应用程序的语言和视觉主题偏好。所有的更改都会实时生效并保存在本地。",
|
||||
"langLabel": "界面语言 (Language)",
|
||||
"themeLabel": "视觉主题 (Theme)",
|
||||
"themeWarm": "经典纸张 (Warm Classic)",
|
||||
"themeWarmDesc": "护眼的复古纸张质感,专注沉浸",
|
||||
"themeModern": "现代通透 (Modern Glass)",
|
||||
"themeModernDesc": "极简白冷灰搭配高级毛玻璃体验"
|
||||
},
|
||||
"bookInfo": {
|
||||
"intro": "💡 这些信息帮助AI理解你的作品定位和风格,即使只填一两项也会大幅提升AI质量。",
|
||||
"title": "作品名称",
|
||||
"titlePlaceholder": "例:《修仙世界的日常生活》",
|
||||
"genre": "题材类型",
|
||||
"genrePlaceholder": "例:仙侠/都市/悬疑/言情/科幻/奇幻/历史...",
|
||||
"synopsis": "故事简介",
|
||||
"synopsisPlaceholder": "用几句话概括整个故事的核心",
|
||||
"style": "写作风格",
|
||||
"stylePlaceholder": "例:轻松幽默、严肃沉重、诗意抒情、硬汉派、轻小说风格...",
|
||||
"tone": "整体基调",
|
||||
"tonePlaceholder": "例:温暖治愈、黑暗压抑、热血燃向、悬疑诡谲...",
|
||||
"pov": "叙事视角",
|
||||
"povPlaceholder": "例:第一人称(主角视角)、第三人称有限视角、全知视角...",
|
||||
"targetAudience": "目标读者",
|
||||
"targetAudiencePlaceholder": "例:18-30岁男性网文读者、女性言情读者..."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确定",
|
||||
"delete": "删除",
|
||||
"rename": "重命名",
|
||||
"allCheck": "全选",
|
||||
"allUncheck": "全不选",
|
||||
"reset": "重置"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { useI18n } from './lib/useI18n';
|
||||
import {
|
||||
getChapters,
|
||||
createChapter,
|
||||
updateChapter,
|
||||
deleteChapter,
|
||||
exportToMarkdown,
|
||||
exportAllToMarkdown,
|
||||
} from './lib/storage';
|
||||
import { buildContext, compileSystemPrompt, compileUserPrompt, OUTPUT_TOKEN_BUDGET, getContextItems, estimateTokens } from './lib/context-engine';
|
||||
import { getProjectSettings, WRITING_MODES, getWritingMode, addSettingsNode, updateSettingsNode, deleteSettingsNode, getSettingsNodes, getActiveWorkId } from './lib/settings';
|
||||
import {
|
||||
loadSessionStore, createSession, getActiveSession,
|
||||
} from './lib/chat-sessions';
|
||||
import { exportProject, importProject } from './lib/project-io';
|
||||
import { createSnapshot } from './lib/snapshots';
|
||||
// 动态导入编辑器和设定集面板及侧边栏(避免 SSR 问题)
|
||||
const Sidebar = dynamic(() => import('./components/Sidebar'), { ssr: false });
|
||||
const Editor = dynamic(() => import('./components/Editor'), { ssr: false });
|
||||
const SettingsPanel = dynamic(() => import('./components/SettingsPanel'), { ssr: false });
|
||||
const HelpPanel = dynamic(() => import('./components/HelpPanel'), { ssr: false });
|
||||
const TourOverlay = dynamic(() => import('./components/TourOverlay'), { ssr: false });
|
||||
const AiSidebar = dynamic(() => import('./components/AiSidebar'), { ssr: false });
|
||||
const SnapshotManager = dynamic(() => import('./components/SnapshotManager'), { ssr: false });
|
||||
const WelcomeModal = dynamic(() => import('./components/WelcomeModal'), { ssr: false });
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
chapters, setChapters, addChapter, updateChapter: updateChapterStore,
|
||||
activeChapterId, setActiveChapterId,
|
||||
sidebarOpen, setSidebarOpen, toggleSidebar,
|
||||
aiSidebarOpen, setAiSidebarOpen, toggleAiSidebar,
|
||||
showSettings, setShowSettings,
|
||||
showSnapshots, setShowSnapshots,
|
||||
theme, setTheme,
|
||||
writingMode, setWritingMode,
|
||||
toast, showToast,
|
||||
contextSelection, setContextSelection,
|
||||
contextItems, setContextItems,
|
||||
settingsVersion, incrementSettingsVersion,
|
||||
sessionStore, setSessionStore,
|
||||
generationArchive, setGenerationArchive,
|
||||
chatStreaming, setChatStreaming
|
||||
} = useAppStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// 派生:当前活动会话和消息列表
|
||||
const activeSession = useMemo(() => getActiveSession(sessionStore), [sessionStore]);
|
||||
const chatHistory = useMemo(() => activeSession?.messages || [], [activeSession]);
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
const initData = async () => {
|
||||
const saved = await getChapters();
|
||||
if (saved.length === 0) {
|
||||
const first = await createChapter(t('page.firstChapterTitle'));
|
||||
setChapters([first]);
|
||||
setActiveChapterId(first.id);
|
||||
} else {
|
||||
setChapters(saved);
|
||||
setActiveChapterId(saved[0].id);
|
||||
}
|
||||
const savedTheme = localStorage.getItem('author-theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
setWritingMode(getWritingMode());
|
||||
|
||||
// 加载会话数据
|
||||
let store = await loadSessionStore();
|
||||
if (store.sessions.length === 0) {
|
||||
// 首次使用:创建一个空会话
|
||||
store = createSession(store);
|
||||
}
|
||||
setSessionStore(store);
|
||||
};
|
||||
initData();
|
||||
}, []);
|
||||
|
||||
// 初始化上下文条目和勾选状态(设定集 + 章节 + 对话历史)
|
||||
useEffect(() => {
|
||||
if (!activeChapterId) return;
|
||||
|
||||
const loadContext = async () => {
|
||||
const baseItems = await getContextItems(activeChapterId);
|
||||
|
||||
// 追加对话历史条目 — 逐条生成,供参考面板单独勾选
|
||||
const chatItems = chatHistory.map((m, i) => {
|
||||
const label = m.role === 'user' ? t('page.dialogueUser') : m.isSummary ? t('aiSidebar.roleSummary') : 'AI';
|
||||
const preview = m.content.slice(0, 25) + (m.content.length > 25 ? '…' : '');
|
||||
return {
|
||||
id: `dialogue-${m.id}`,
|
||||
group: t('page.dialogueHistory'),
|
||||
name: `${label}: ${preview}`,
|
||||
tokens: estimateTokens(m.content),
|
||||
category: 'dialogue',
|
||||
enabled: true,
|
||||
_msgId: m.id,
|
||||
};
|
||||
});
|
||||
|
||||
const allItems = [...baseItems, ...chatItems];
|
||||
setContextItems(allItems);
|
||||
|
||||
// 默认全选启用的条目(仅首次)
|
||||
setContextSelection(prev => {
|
||||
if (prev.size === 0) {
|
||||
return new Set(allItems.filter(it => it.enabled).map(it => it.id));
|
||||
}
|
||||
// 自动加入新出现的条目
|
||||
const next = new Set(prev);
|
||||
for (const item of allItems) {
|
||||
if (item.enabled && !prev.has(item.id)) {
|
||||
next.add(item.id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
loadContext();
|
||||
}, [activeChapterId, settingsVersion, chatHistory.length]);
|
||||
|
||||
// 定时自动存档 (每 15 分钟)
|
||||
useEffect(() => {
|
||||
// 首次加载后延迟 5 分钟做一次初始存档,之后每 15 分钟做一次
|
||||
const initialTimer = setTimeout(() => {
|
||||
createSnapshot(t('page.autoSnapshot'), 'auto').catch(e => console.error(t('page.autoSnapshotFail'), e));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const intervalTimer = setInterval(() => {
|
||||
createSnapshot(t('page.autoSnapshot'), 'auto').catch(e => console.error(t('page.autoSnapshotFail'), e));
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimer);
|
||||
clearInterval(intervalTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 当前活跃章节
|
||||
const activeChapter = chapters.find(ch => ch.id === activeChapterId);
|
||||
|
||||
const handleEditorUpdate = useCallback(async ({ html, wordCount }) => {
|
||||
if (!activeChapterId) return;
|
||||
const updated = await updateChapter(activeChapterId, {
|
||||
content: html,
|
||||
wordCount,
|
||||
});
|
||||
if (updated) {
|
||||
updateChapterStore(activeChapterId, { content: html, wordCount });
|
||||
}
|
||||
}, [activeChapterId, updateChapterStore]);
|
||||
|
||||
// Inline AI 回调:编辑器调用此函数发起 AI 请求
|
||||
const handleInlineAiRequest = useCallback(async ({ mode, text, instruction, signal, onChunk }) => {
|
||||
try {
|
||||
// 使用上下文引擎收集项目信息
|
||||
const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null);
|
||||
const systemPrompt = compileSystemPrompt(context, mode);
|
||||
const userPrompt = compileUserPrompt(mode, text, instruction);
|
||||
|
||||
const { apiConfig } = getProjectSettings();
|
||||
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini' : '/api/ai';
|
||||
|
||||
const res = await fetch(apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ systemPrompt, userPrompt, apiConfig, maxTokens: OUTPUT_TOKEN_BUDGET }),
|
||||
signal,
|
||||
});
|
||||
|
||||
// 错误响应(JSON)
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
showToast(data.error || t('page.toastRequestFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取 SSE 流
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() || '';
|
||||
|
||||
for (const event of events) {
|
||||
const trimmed = event.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed.slice(6));
|
||||
if (json.text) onChunk(json.text);
|
||||
} catch {
|
||||
// 解析失败跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
showToast(t('page.toastStopped'), 'info');
|
||||
} else {
|
||||
showToast(t('page.toastNetworkError'), 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}, [activeChapterId, contextSelection, showToast]);
|
||||
|
||||
// AI 生成存档 — Editor 的 ghost text 操作会调用此函数
|
||||
const handleArchiveGeneration = useCallback((entry) => {
|
||||
const record = {
|
||||
id: `gen-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: Date.now(),
|
||||
chapterId: activeChapterId,
|
||||
...entry,
|
||||
};
|
||||
useAppStore.getState().addGenerationArchive(record);
|
||||
}, [activeChapterId]);
|
||||
|
||||
|
||||
|
||||
// 从存档插入文本到编辑器
|
||||
const handleInsertFromArchive = useCallback((text) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.insertText?.(text);
|
||||
showToast(t('page.toastInserted'), 'success');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* ===== 侧边栏 ===== */}
|
||||
<Sidebar />
|
||||
|
||||
{/* ===== 主内容 ===== */}
|
||||
<main className="main-content">
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
className="btn btn-ghost btn-icon"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
title={t('page.expandSidebar')}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeChapter ? (
|
||||
<Editor
|
||||
id="tour-editor"
|
||||
ref={editorRef}
|
||||
key={activeChapterId}
|
||||
content={activeChapter.content}
|
||||
onUpdate={handleEditorUpdate}
|
||||
onAiRequest={handleInlineAiRequest}
|
||||
onArchiveGeneration={handleArchiveGeneration}
|
||||
contextItems={contextItems}
|
||||
contextSelection={contextSelection}
|
||||
setContextSelection={setContextSelection}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '16px',
|
||||
}}>
|
||||
{t('page.noChapterHint')}
|
||||
</div>
|
||||
)}
|
||||
{/* AI 侧栏浮动开关 */}
|
||||
{!aiSidebarOpen && (
|
||||
<button
|
||||
id="tour-ai-btn"
|
||||
className="ai-sidebar-toggle"
|
||||
onClick={() => setAiSidebarOpen(true)}
|
||||
title={t('page.openAiAssistant')}
|
||||
>
|
||||
✦
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 帮助与向导按钮 */}
|
||||
<button
|
||||
id="tour-help"
|
||||
className="btn btn-secondary btn-icon"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
zIndex: 40,
|
||||
borderRadius: '50%',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
fontSize: '18px',
|
||||
transition: 'all 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: 0.8
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.8'}
|
||||
onClick={() => setShowHelp(true)}
|
||||
title={t('page.helpAndGuide')}
|
||||
>
|
||||
📖
|
||||
</button>
|
||||
</main>
|
||||
|
||||
{/* ===== AI 对话侧栏 ===== */}
|
||||
<AiSidebar onInsertText={handleInsertFromArchive} />
|
||||
|
||||
|
||||
{/* ===== Toast 通知 ===== */}
|
||||
{toast && (
|
||||
<div className="toast-container">
|
||||
<div className={`toast ${toast.type}`}>
|
||||
{toast.type === 'success' && '✓ '}
|
||||
{toast.type === 'error' && '✗ '}
|
||||
{toast.type === 'info' && 'ℹ '}
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 设定库弹窗 ===== */}
|
||||
<SettingsPanel />
|
||||
<SnapshotManager />
|
||||
|
||||
{/* ===== 帮助文档 ===== */}
|
||||
<HelpPanel open={showHelp} onClose={() => setShowHelp(false)} />
|
||||
|
||||
{/* ===== 首次引导 ===== */}
|
||||
<TourOverlay onOpenHelp={() => setShowHelp(true)} />
|
||||
<WelcomeModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export const useAppStore = create((set, get) => ({
|
||||
// --- Chapter State ---
|
||||
chapters: [],
|
||||
activeChapterId: null,
|
||||
setChapters: (chapters) => set({ chapters }),
|
||||
setActiveChapterId: (id) => set({ activeChapterId: id }),
|
||||
addChapter: (chapter) => set((state) => ({ chapters: [...state.chapters, chapter] })),
|
||||
deleteChapter: (id) => set((state) => ({ chapters: state.chapters.filter((ch) => ch.id !== id) })),
|
||||
updateChapter: (id, updates) => set((state) => ({
|
||||
chapters: state.chapters.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch))
|
||||
})),
|
||||
|
||||
// --- UI State ---
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
aiSidebarOpen: false,
|
||||
setAiSidebarOpen: (open) => set({ aiSidebarOpen: open }),
|
||||
toggleAiSidebar: () => set((state) => ({ aiSidebarOpen: !state.aiSidebarOpen })),
|
||||
|
||||
showSettings: false,
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
|
||||
showSnapshots: false,
|
||||
setShowSnapshots: (show) => set({ showSnapshots: show }),
|
||||
|
||||
theme: 'light',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
writingMode: 'webnovel',
|
||||
setWritingMode: (mode) => set({ writingMode: mode }),
|
||||
|
||||
// --- Localization & Theming ---
|
||||
language: typeof window !== 'undefined' ? localStorage.getItem('author-lang') || null : null,
|
||||
setLanguage: (lang) => set(() => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('author-lang', lang);
|
||||
return { language: lang };
|
||||
}),
|
||||
|
||||
visualTheme: typeof window !== 'undefined' ? localStorage.getItem('author-visual') || null : null,
|
||||
setVisualTheme: (vTheme) => set(() => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('author-visual', vTheme);
|
||||
return { visualTheme: vTheme };
|
||||
}),
|
||||
|
||||
startTour: false,
|
||||
setStartTour: (val) => set({ startTour: val }),
|
||||
|
||||
// --- Toast ---
|
||||
toast: null,
|
||||
setToast: (toast) => {
|
||||
set({ toast });
|
||||
if (toast) {
|
||||
setTimeout(() => set({ toast: null }), 3000);
|
||||
}
|
||||
},
|
||||
showToast: (message, type = 'info') => get().setToast({ message, type }),
|
||||
|
||||
// --- Context & Settings (RAG Preparation) ---
|
||||
contextSelection: typeof window !== 'undefined' && localStorage.getItem('author-context-selection')
|
||||
? new Set(JSON.parse(localStorage.getItem('author-context-selection')))
|
||||
: new Set(),
|
||||
setContextSelection: (selection) => set((state) => {
|
||||
const newSelection = typeof selection === 'function' ? selection(state.contextSelection) : selection;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('author-context-selection', JSON.stringify(Array.from(newSelection)));
|
||||
}
|
||||
return { contextSelection: newSelection };
|
||||
}),
|
||||
|
||||
contextItems: [],
|
||||
setContextItems: (items) => set({ contextItems: items }),
|
||||
|
||||
settingsVersion: 0,
|
||||
incrementSettingsVersion: () => set((state) => ({ settingsVersion: state.settingsVersion + 1 })),
|
||||
|
||||
// --- AI Chat & Generation State ---
|
||||
sessionStore: { activeSessionId: null, sessions: [] },
|
||||
setSessionStore: (action) => set((state) => ({ sessionStore: typeof action === 'function' ? action(state.sessionStore) : action })),
|
||||
|
||||
chatStreaming: false,
|
||||
setChatStreaming: (streaming) => set({ chatStreaming: streaming }),
|
||||
|
||||
generationArchive: [],
|
||||
setGenerationArchive: (archive) => set({ generationArchive: typeof archive === 'function' ? archive(get().generationArchive) : archive }),
|
||||
addGenerationArchive: (record) => set((state) => ({ generationArchive: [...state.generationArchive, record] })),
|
||||
}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "author-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^3.19.0",
|
||||
"@tiptap/extension-color": "^3.19.0",
|
||||
"@tiptap/extension-font-family": "^3.19.0",
|
||||
"@tiptap/extension-highlight": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/extension-subscript": "^3.19.0",
|
||||
"@tiptap/extension-superscript": "^3.19.0",
|
||||
"@tiptap/extension-task-item": "^3.19.0",
|
||||
"@tiptap/extension-task-list": "^3.19.0",
|
||||
"@tiptap/extension-text-align": "^3.19.0",
|
||||
"@tiptap/extension-text-style": "^3.19.0",
|
||||
"@tiptap/extension-underline": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"katex": "^0.16.28",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"tailwindcss": "^4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |