mirror of
https://github.com/vegu-ai/talemate.git
synced 2025-09-01 18:09:08 +00:00
initial commit
This commit is contained in:
commit
6d93b041c5
232 changed files with 39974 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
.lmer
|
||||
*.pyc
|
||||
problems
|
||||
*.swp
|
||||
*.swo
|
||||
*.egg-info
|
||||
tales/
|
||||
*-internal*
|
||||
*.internal*
|
||||
*_internal*
|
661
LICENSE
Normal file
661
LICENSE
Normal file
|
@ -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/>.
|
138
README.md
Normal file
138
README.md
Normal file
|
@ -0,0 +1,138 @@
|
|||
# Talemate
|
||||
|
||||
Talemate is an experimental application that allows you to roleplay scenarios with large language models. I've worked on this on and off since early 2023, as a private project, but decided i might as well put in the extra effort and open source it.
|
||||
|
||||
It does not run LLMs itself but relies on existing APIs. Currently supports text-generation-webui and openai.
|
||||
|
||||
This means you need to either have an openai api key or know how to setup [oobabooga/text-generation-webui](https://github.com/oobabooga/text-generation-webui) (locally or remotely via gpu renting.)
|
||||
|
||||

|
||||

|
||||
|
||||
## Current features
|
||||
|
||||
- responive modern ui
|
||||
- multi-client (agents can be connected to separate LLMs)
|
||||
- agents
|
||||
- conversation
|
||||
- narration
|
||||
- summarization
|
||||
- director
|
||||
- creative
|
||||
- long term memory
|
||||
- narrative world state
|
||||
- narrative tools
|
||||
- creative mode
|
||||
- AI backed character creation with template support (jinja2)
|
||||
- AI backed scenario creation
|
||||
- runpod integration
|
||||
- overridable templates foe all LLM prompts. (jinja2)
|
||||
|
||||
## Planned features
|
||||
|
||||
Kinda making it up as i go along, but i want to lean more into gameplay through AI, keeping track of gamestates, moving away from simply roleplaying towards a more game-ified experience.
|
||||
|
||||
In no particular order:
|
||||
|
||||
- Automatic1111 client
|
||||
- Gameplay loop governed by AI
|
||||
- Improved world state
|
||||
- Dynamic player choice generation
|
||||
- Better creative tools
|
||||
- node based scenario / character creation
|
||||
- Improved long term memory (base is there, but its very rough at the moment)
|
||||
- Improved director agent
|
||||
- Right now this doesn't really work well on anything but GPT-4 (and even there it's debatable). It tends to steer the story in a way that introduces pacing issues. It needs a model that is creative but also reasons really well i think.
|
||||
|
||||
# Quickstart
|
||||
|
||||
## Installation
|
||||
|
||||
### Windows
|
||||
|
||||
1. Download and install Python 3.10 or higher from the [official Python website](https://www.python.org/downloads/windows/).
|
||||
1. Download and install Node.js from the [official Node.js website](https://nodejs.org/en/download/). This will also install npm.
|
||||
1. Download the Talemate project to your local machine. Download from [the Releases page](https://github.com/final-wombat/talemate/releases).
|
||||
1. Unpack the download and run `install.bat` by double clicking it. This will set up the project on your local machine.
|
||||
1. Once the installation is complete, you can start the backend and frontend servers by running `start.bat`.
|
||||
1. Navigate your browser to http://localhost:8080
|
||||
|
||||
### Linux
|
||||
|
||||
`python 3.10` or higher is required.
|
||||
|
||||
1. `git clone git@github.com:final-wombat/talemate`
|
||||
1. `cd talemate`
|
||||
1. `source install.sh`
|
||||
1. Start the backend: `python src/talemate/server/run.py runserver --host 0.0.0.0 --port 5001`.
|
||||
1. Open a new terminal, navigate to the `talemate_frontend` directory, and start the frontend server by running `npm run serve`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### OpenAI
|
||||
|
||||
To set your openai api key, open `config.yaml` in any text editor and uncomment / add
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
api_key: sk-my-api-key-goes-here
|
||||
```
|
||||
|
||||
You will need to restart the backend for this change to take effect.
|
||||
|
||||
### RunPod
|
||||
|
||||
To set your runpod api key, open `config.yaml` in any text editor and uncomment / add
|
||||
|
||||
```yaml
|
||||
runpod:
|
||||
api_key: my-api-key-goes-here
|
||||
```
|
||||
You will need to restart the backend for this change to take effect.
|
||||
|
||||
Once the api key is set Pods loaded from text-generation-webui templates (or the bloke's runpod llm template) will be autoamtically added to your client list in talemate.
|
||||
|
||||
**ATTENTION**: Talemate is not a suitable for way for you to determine whether your pod is currently running or not. **Always** check the runpod dashboard to see if your pod is running or not.
|
||||
|
||||
## Recommended Models
|
||||
|
||||
Note: this is my personal opinion while using talemate. If you find a model that works better for you, let me know about it.
|
||||
|
||||
Will be updated as i test more models and over time.
|
||||
|
||||
| Model Name | Status | Type | Notes |
|
||||
|-------------------------------|------------------|-----------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| [GPT-4](https://platform.openai.com/) | GOOD | Remote | Costs money and is heavily censored, while talemate will send a general "decensor" system prompt, depending on the type of content you want to roleplay, there is a chance your key will be banned. **If you do use this make sure to monitor your api usage, talemate tends to send a lot more requests than other roleplaying applications.** |
|
||||
| [GPT-3.5-turbo](https://platform.openai.com/) | AVOID | Remote | Costs money and is heavily censored, while talemate will send a general "decensor" system prompt, depending on the type of content you want to roleplay, there is a chance your key will be banned. Can roleplay, but not great at consistently generating JSON responses needed for various parts of talemate (world-state etc.) |
|
||||
| [Nous Hermes LLama2](https://huggingface.co/TheBloke/Nous-Hermes-Llama2-GPTQ) | RECOMMENDED | 13B model | My go-to model for 13B parameters. It's good at roleplay and also smart enough to handle the world state and narrative tools. A 13B model loaded via exllama also allows you run chromadb with the xl instructor embeddings off of a single 4090. |
|
||||
| [MythoMax](https://huggingface.co/TheBloke/MythoMax-L2-13B-GPTQ) | RECOMMENDED | 13B model | Similar quality to Hermes LLama2, but a bit more creative. Rarely fails on JSON responses. |
|
||||
| [Synthia v1.2 34B](https://huggingface.co/TheBloke/Synthia-34B-v1.2-GPTQ) | RECOMMENDED | 34B model | Cannot be run at full context together with chromadb instructor models on a single 4090. But a great choice if you're running chromadb with the default embeddings (or on cpu). |
|
||||
| [Genz](https://huggingface.co/TheBloke/Genz-70b-GPTQ) | GOOD | 70B model | Great choice if you have the hardware to run it (or can rent it). |
|
||||
| [Synthia v1.2 70B](https://huggingface.co/TheBloke/Synthia-70B-v1.2-GPTQ) | GOOD | 70B model | Great choice if you have the hardware to run it (or can rent it). |
|
||||
|
||||
I have not tested with Llama 1 mnodels in a while, Lazarus was really good at roleplay, but started failing on JSON requirements.
|
||||
|
||||
I have not tested with anything below 13B parameters.
|
||||
|
||||
## Load the introductory scenario "Infinity Quest"
|
||||
|
||||
Generated using talemate creative tools, mostly used for testing / demoing.
|
||||
|
||||
You can load it (and any other talemate scenarios or save files) by expanding the "Load" menu in the top left corner and selecting the middle tab. Then simple search for a partial name of the scenario you want to load and click on the result.
|
||||
|
||||

|
||||
|
||||
## Loading character cards
|
||||
|
||||
Supports both v1 and v2 chara specs.
|
||||
|
||||
Expand the "Load" menu in the top left corner and either click on "Upload a character card" or simply drag and drop a character card file into the same area.
|
||||
|
||||

|
||||
|
||||
## Further documentation
|
||||
|
||||
- Creative mode (docs WIP)
|
||||
- Prompt template overrides
|
||||
- [ChromaDB (long term memory)](docs/chromadb.md)
|
||||
- Runpod Integration
|
24
config.example.yaml
Normal file
24
config.example.yaml
Normal file
|
@ -0,0 +1,24 @@
|
|||
creator:
|
||||
content_context:
|
||||
- a fun and engaging slice of life story aimed at an adult audience.
|
||||
- a terrifying horror story aimed at an adult audience.
|
||||
- a thrilling action story aimed at an adult audience.
|
||||
- a mysterious adventure aimed at an adult audience.
|
||||
- an epic sci-fi adventure aimed at an adult audience.
|
||||
game:
|
||||
default_player_character:
|
||||
color: '#6495ed'
|
||||
description: a young man with a penchant for adventure.
|
||||
gender: male
|
||||
name: Elmer
|
||||
|
||||
#chromadb:
|
||||
# embeddings: instructor
|
||||
# instructor_device: cuda
|
||||
# instructor_model: hkunlp/instructor-xl
|
||||
|
||||
#openai:
|
||||
# api_key: <API_KEY>
|
||||
|
||||
#runpod:
|
||||
# api_key: <API_KEY>
|
31
docs/chromadb.md
Normal file
31
docs/chromadb.md
Normal file
|
@ -0,0 +1,31 @@
|
|||
## ChromaDB
|
||||
|
||||
If you want chromaDB to use the more accurate (but much slower) instructor embeddings add the following to `config.yaml`:
|
||||
|
||||
```yaml
|
||||
chromadb:
|
||||
embeddings: instructor
|
||||
instructor_device: cpu
|
||||
instructor_model: hkunlp/instructor-xl"
|
||||
```
|
||||
|
||||
You will need to restart the backend for this change to take effect.
|
||||
|
||||
### GPU support
|
||||
|
||||
If you want to use the instructor embeddings with GPU support, you will need to install pytorch with CUDA support.
|
||||
|
||||
To do this on windows, run `install-pytorch-cuda.bat` from the project root. Then change your device in the config to `cuda`:
|
||||
|
||||
```yaml
|
||||
chromadb:
|
||||
embeddings: instructor
|
||||
instructor_device: cuda
|
||||
instructor_model: hkunlp/instructor-xl"
|
||||
```
|
||||
|
||||
Instructor embedding models:
|
||||
|
||||
- `hkunlp/instructor-base` (smallest / fastest)
|
||||
- `hkunlp/instructor-large`
|
||||
- `hkunlp/instructor-xl` (largest / slowest) - requires about 5GB of GPU memory
|
BIN
docs/img/Screenshot_2.png
Normal file
BIN
docs/img/Screenshot_2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
BIN
docs/img/Screenshot_8.png
Normal file
BIN
docs/img/Screenshot_8.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 385 KiB |
BIN
docs/img/load-card-location.png
Normal file
BIN
docs/img/load-card-location.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
BIN
docs/img/load-scene-location.png
Normal file
BIN
docs/img/load-scene-location.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
25
docs/linux-install.md
Normal file
25
docs/linux-install.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
### Setting Up a Virtual Environment
|
||||
|
||||
1. Open a terminal.
|
||||
2. Navigate to the project directory.
|
||||
3. Create a virtual environment by running `python3 -m venv talemate_env`.
|
||||
4. Activate the virtual environment by running `source talemate_env/bin/activate`.
|
||||
|
||||
### Installing Dependencies
|
||||
|
||||
1. With the virtual environment activated, install poetry by running `pip install poetry`.
|
||||
2. Use poetry to install dependencies by running `poetry install`.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
1. With the virtual environment activated and dependencies installed, you can start the backend server.
|
||||
2. Navigate to the `src/talemate/server` directory.
|
||||
3. Run the server with `python run.py runserver --host 0.0.0.0 --port 5001`.
|
||||
|
||||
### Running the Frontend
|
||||
|
||||
1. Navigate to the `talemate_frontend` directory.
|
||||
2. If you haven't already, install npm dependencies by running `npm install`.
|
||||
3. Start the server with `npm run serve`.
|
||||
|
||||
Please note that you may need to set environment variables or modify the host and port as per your setup. You can refer to the `runserver.sh` and `frontend.sh` files for more details.
|
27
docs/windows-install.md
Normal file
27
docs/windows-install.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
### How to Install Python 3.10
|
||||
|
||||
1. Visit the official Python website's download page for Windows at https://www.python.org/downloads/windows/.
|
||||
2. Click on the link for the Latest Python 3 Release - Python 3.10.x.
|
||||
3. Scroll to the bottom and select either Windows x86-64 executable installer for 64-bit or Windows x86 executable installer for 32-bit.
|
||||
4. Run the installer file and follow the setup instructions. Make sure to check the box that says Add Python 3.10 to PATH before you click Install Now.
|
||||
|
||||
### How to Install npm
|
||||
|
||||
1. Download Node.js from the official site https://nodejs.org/en/download/.
|
||||
2. Run the installer (the .msi installer is recommended).
|
||||
3. Follow the prompts in the installer (Accept the license agreement, click the NEXT button a bunch of times and accept the default installation settings).
|
||||
4. Restart your computer. You won’t be able to run Node.js® until you restart your computer.
|
||||
|
||||
### Usage of the Supplied bat Files
|
||||
|
||||
#### install.bat
|
||||
|
||||
This batch file is used to set up the project on your local machine. It creates a virtual environment, activates it, installs poetry, and uses poetry to install dependencies. It then navigates to the frontend directory and installs the necessary npm packages.
|
||||
|
||||
To run this file, simply double click on it or open a command prompt in the same directory and type `install.bat`.
|
||||
|
||||
#### start.bat
|
||||
|
||||
This batch file is used to start the backend and frontend servers. It opens two command prompts, one for the frontend and one for the backend.
|
||||
|
||||
To run this file, simply double click on it or open a command prompt in the same directory and type `start.bat`.
|
6
install-pytorch-cuda.bat
Normal file
6
install-pytorch-cuda.bat
Normal file
|
@ -0,0 +1,6 @@
|
|||
REM activate the virtual environment
|
||||
call talemate_env\Scripts\activate
|
||||
|
||||
REM install pytouch+cuda
|
||||
pip uninstall torch -y
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
|
26
install.bat
Normal file
26
install.bat
Normal file
|
@ -0,0 +1,26 @@
|
|||
@echo off
|
||||
|
||||
REM create a virtual environment
|
||||
python -m venv talemate_env
|
||||
|
||||
REM activate the virtual environment
|
||||
call talemate_env\Scripts\activate
|
||||
|
||||
REM install poetry
|
||||
pip install poetry
|
||||
|
||||
REM use poetry to install dependencies
|
||||
poetry install
|
||||
|
||||
REM copy config.example.yaml to config.yaml only if config.yaml doesn't exist
|
||||
IF NOT EXIST config.yaml copy config.example.yaml config.yaml
|
||||
|
||||
REM navigate to the frontend directory
|
||||
cd talemate_frontend
|
||||
npm install
|
||||
|
||||
REM return to the root directory
|
||||
cd ..
|
||||
|
||||
echo Installation completed successfully.
|
||||
pause
|
28
install.sh
Normal file
28
install.sh
Normal file
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
|
||||
# create a virtual environment
|
||||
python -m venv talemate_env
|
||||
|
||||
# activate the virtual environment
|
||||
source talemate_env/bin/activate
|
||||
|
||||
# install poetry
|
||||
pip install poetry
|
||||
|
||||
# use poetry to install dependencies
|
||||
poetry install
|
||||
|
||||
# copy config.example.yaml to config.yaml only if config.yaml doesn't exist
|
||||
if [ ! -f config.yaml ]; then
|
||||
cp config.example.yaml config.yaml
|
||||
fi
|
||||
|
||||
# navigate to the frontend directory
|
||||
cd talemate_frontend
|
||||
npm install
|
||||
|
||||
# return to the root directory
|
||||
cd ..
|
||||
|
||||
echo "Installation completed successfully."
|
||||
read -p "Press [Enter] key to continue..."
|
3824
poetry.lock
generated
Normal file
3824
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
77
pyproject.toml
Normal file
77
pyproject.toml
Normal file
|
@ -0,0 +1,77 @@
|
|||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "talemate"
|
||||
version = "0.1.0"
|
||||
description = "AI companionship and roleplay."
|
||||
authors = ["FinalWombat <finalwombat@gmail.com>"]
|
||||
license = "MIT"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<4.0"
|
||||
astroid = "^2.8"
|
||||
jedi = "^0.18"
|
||||
black = "*"
|
||||
rope = "^0.22"
|
||||
isort = "^5.10"
|
||||
jinja2 = "^3.0"
|
||||
openai = "*"
|
||||
requests = "^2.26"
|
||||
colorama = ">=0.4.6"
|
||||
Pillow = "^9.5"
|
||||
httpx = "<1"
|
||||
piexif = "^1.1"
|
||||
typing-inspect = "0.8.0"
|
||||
typing_extensions = "^4.5.0"
|
||||
uvicorn = "^0.23"
|
||||
blinker = "^1.6.2"
|
||||
pydantic = "<2"
|
||||
langchain = "0.0.213"
|
||||
beautifulsoup4 = "^4.12.2"
|
||||
python-dotenv = "^1.0.0"
|
||||
websockets = "^11.0.3"
|
||||
structlog = "^23.1.0"
|
||||
runpod = "==1.2.0"
|
||||
nest_asyncio = "^1.5.7"
|
||||
|
||||
# ChromaDB
|
||||
chromadb = ">=0.4,<1"
|
||||
InstructorEmbedding = "^1.0.1"
|
||||
torch = ">=2.0.0, !=2.0.1"
|
||||
sentence-transformers="^2.2.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^6.2"
|
||||
mypy = "^0.910"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
talemate = "talemate:cli.main"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38']
|
||||
include = '\.pyi?$'
|
||||
exclude = '''
|
||||
/(
|
||||
\.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| _build
|
||||
| buck-out
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
include_trailing_comma = true
|
||||
force_grid_wrap = 0
|
||||
use_parentheses = true
|
||||
ensure_newline_before_comments = true
|
||||
line_length = 88
|
0
scenes/characters/keep
Normal file
0
scenes/characters/keep
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
92
scenes/infinity-quest/infinity-quest.json
Normal file
92
scenes/infinity-quest/infinity-quest.json
Normal file
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"description": "Captain Elmer Farstield and his trusty first officer, Kaira, embark upon a daring mission into uncharted space. Their small but mighty exploration vessel, the Starlight Nomad, is equipped with state-of-the-art technology and crewed by an elite team of scientists, engineers, and pilots. Together they brave the vast cosmos seeking answers to humanity's most pressing questions about life beyond our solar system.",
|
||||
"intro": "*You awaken aboard your ship, the Starlight Nomad, surrounded by darkness. A soft hum resonates throughout the vessel indicating its systems are online. Your mind struggles to recall what brought you here - where 'here' actually is. You remember nothing more than flashes of images; swirling nebulae, foreign constellations, alien life forms... Then there was a bright light followed by this endless void.*\n\n*Gingerly, you make your way through the dimly lit corridors of the ship. It seems smaller than you expected given the magnitude of the mission ahead. However, each room reveals intricate technology designed specifically for long-term space travel and exploration. There appears to be no other living soul besides yourself. An eerie silence fills every corner.*",
|
||||
"name": "Infinity Quest",
|
||||
"history": [],
|
||||
"environment": "scene",
|
||||
"archived_history": [],
|
||||
"character_states": {},
|
||||
"characters": [
|
||||
{
|
||||
"name": "Elmer",
|
||||
"description": "Elmer is a seasoned space explorer, having traversed the cosmos for over three decades. At thirty-eight years old, his muscular frame still cuts an imposing figure, clad in a form-fitting black spacesuit adorned with intricate silver markings. As the captain of his own ship, he wields authority with confidence yet never comes across as arrogant or dictatorial. Underneath this tough exterior lies a man who genuinely cares for his crew and their wellbeing, striking a balance between discipline and compassion.",
|
||||
"greeting_text": "",
|
||||
"base_attributes": {
|
||||
"gender": "male",
|
||||
"species": "Humans",
|
||||
"name": "Elmer",
|
||||
"age": "38",
|
||||
"appearance": "Captain Elmer stands tall at six feet, his body honed by years of space travel and physical training. His muscular frame is clad in a form-fitting black spacesuit, which accentuates every defined curve and ridge. His helmet, adorned with intricate silver markings, completes the ensemble, giving him a commanding presence. Despite his age, his face remains youthful, bearing traces of determination and wisdom earned through countless encounters with the unknown.",
|
||||
"personality": "As the leader of their small but dedicated team, Elmer exudes confidence and authority without ever coming across as arrogant or dictatorial. He possesses a strong sense of duty towards his mission and those under his care, ensuring that everyone aboard follows protocol while still encouraging them to explore their curiosities about the vast cosmos beyond Earth. Though firm when necessary, he also demonstrates great empathy towards his crew members, understanding each individual's unique strengths and weaknesses. In short, Captain Elmer embodies the perfect blend of discipline and compassion, making him not just a respected commander but also a beloved mentor and friend.",
|
||||
"associates": "Kaira",
|
||||
"likes": "Space exploration, discovering new worlds, deep conversations about philosophy and history.",
|
||||
"dislikes": "Repetitive tasks, unnecessary conflict, close quarters with large groups of people, stagnation",
|
||||
"gear and tech": "As the captain of his ship, Elmer has access to some of the most advanced technology available in the galaxy. His primary tool is the sleek and powerful exploration starship, equipped with state-of-the-art engines capable of reaching lightspeed and navigating through the harshest environments. The vessel houses a wide array of scientific instruments designed to analyze and record data from various celestial bodies. Its armory contains high-tech weapons such as energy rifles and pulse pistols, which are used only in extreme situations. Additionally, Elmer wears a smart suit that monitors his vital signs, provides real-time updates on the status of the ship, and allows him to communicate directly with Kaira via subvocal transmissions. Finally, they both carry personal transponders that enable them to locate one another even if separated by hundreds of miles within the confines of the ship."
|
||||
},
|
||||
"details": {},
|
||||
"gender": "male",
|
||||
"color": "cornflowerblue",
|
||||
"example_dialogue": [],
|
||||
"history_events": [],
|
||||
"is_player": true,
|
||||
"cover_image": null
|
||||
},
|
||||
{
|
||||
"name": "Kaira",
|
||||
"description": "Kaira is a meticulous and dedicated Altrusian woman who serves as second-in-command aboard their tiny exploration vessel. As a native of the planet Altrusia, she possesses striking features unique among her kind; deep violet skin adorned with intricate patterns resembling stardust, large sapphire eyes, lustrous glowing hair cascading down her back, and standing tall at just over six feet. Her form fitting bodysuit matches her own hue, giving off an ethereal presence. With her innate grace and precision, she moves efficiently throughout the cramped confines of their ship. A loyal companion to Captain Elmer Farstield, she approaches every task with diligence and focus while respecting authority yet challenging decisions when needed. Dedicated to maintaining order within their tight quarters, Kaira wields several advanced technological devices including a multi-tool, portable scanner, high-tech communications system, and personal shield generator - all essential for navigating unknown territories and protecting themselves from harm. In this perilous universe full of mysteries waiting to be discovered, Kaira stands steadfast alongside her captain \u2013 ready to embrace whatever challenges lie ahead in their quest for knowledge beyond Earth's boundaries.",
|
||||
"greeting_text": "",
|
||||
"base_attributes": {
|
||||
"gender": "female",
|
||||
"species": "Altrusian",
|
||||
"name": "Kaira",
|
||||
"age": "37",
|
||||
"appearance": "As a native of the planet Altrusia, Kaira possesses striking features unique among her kind. Her skin tone is a deep violet hue, adorned with intricate patterns resembling stardust. Her eyes are large and almond shaped, gleaming like polished sapphires under the dim lighting of their current environment. Her hair cascades down her back in lustrous waves, each strand glowing softly with an inner luminescence. Standing at just over six feet tall, she cuts an imposing figure despite her slender build. Clad in a form fitting bodysuit made from some unknown material, its color matching her own, Kaira moves with grace and precision through the cramped confines of their spacecraft.",
|
||||
"personality": "Meticulous and open-minded, Kaira takes great pride in maintaining order within the tight quarters of their ship. Despite being one of only two crew members aboard, she approaches every task with diligence and focus, ensuring nothing falls through the cracks. While she respects authority, especially when it comes to Captain Elmer Farstield, she isn't afraid to challenge his decisions if she believes they could lead them astray. Ultimately, Kaira's dedication to her mission and commitment to her fellow crewmate make her a valuable asset in any interstellar adventure.",
|
||||
"associates": "Captain Elmer Farstield (human), Dr. Ralpam Zargon (Altrusian scientist)",
|
||||
"likes": "orderliness, quiet solitude, exploring new worlds",
|
||||
"dislikes": "chaos, loud noises, unclean environments",
|
||||
"gear and tech": "The young Altrusian female known as Kaira was equipped with a variety of advanced technological devices that served multiple purposes on board their small explorer starship. Among these were her trusty multi-tool, capable of performing various tasks such as repair work, hacking into computer systems, and even serving as a makeshift weapon if necessary. She also carried a portable scanner capable of analyzing various materials and detecting potential hazards in their surroundings. Additionally, she had access to a high-tech communications system allowing her to maintain contact with her homeworld and other vessels across the galaxy. Last but not least, she possessed a personal shield generator which provided protection against radiation, extreme temperatures, and certain types of energy weapons. All these tools combined made Kaira a vital part of their team, ready to face whatever challenges lay ahead in their journey through the stars.",
|
||||
"scenario_context": "an epic sci-fi adventure aimed at an adult audience.",
|
||||
"_template": "sci-fi",
|
||||
"_prompt": "A female crew member on board of a small explorer type starship. She is open minded and meticulous about keeping order. She is currently one of two crew members abord the small vessel, the other person on board is a human male named Captain Elmer Farstield."
|
||||
},
|
||||
"details": {
|
||||
"what objective does Kaira pursue and what obstacle stands in their way?": "As a member of an interstellar expedition led by human Captain Elmer Farstield, Kaira seeks to explore new worlds and gather data about alien civilizations for the benefit of her people back on Altrusia. Their current objective involves locating a rumored planet known as \"Eden\", said to be inhabited by highly intelligent beings who possess advanced technology far surpassing anything seen elsewhere in the universe. However, navigating through the vast expanse of space can prove treacherous; from cosmic storms that threaten to damage their ship to encounters with hostile species seeking to protect their territories or exploit them for resources, many dangers lurk between them and Eden.",
|
||||
"what secret from Kaira's past or future has the most impact on them?": "In the distant reaches of space, among the stars, there exists a race called the Altrusians. One such individual named Kaira embarked upon a mission alongside humans aboard a small explorer vessel. Her past held secrets - tales whispered amongst her kind about an ancient prophecy concerning their role within the cosmos. It spoke of a time when they would encounter another intelligent species, one destined to guide them towards enlightenment. Could this mysterious \"Eden\" be the fulfillment of those ancient predictions? If so, then Kaira's involvement could very well shape not only her own destiny but also that of her entire species. And so, amidst the perils of deep space, she ventured forth, driven by both curiosity and fate itself.",
|
||||
"what is a fundamental fear or desire of Kaira?": "A fundamental fear of Kaira is chaos. She prefers orderliness and quiet solitude, and dislikes loud noises and unclean environments. On the other hand, her desire is to find Eden \u2013 a planet where highly intelligent beings are believed to live, possessing advanced technology that could greatly benefit her people on Altrusia. Navigating through the vast expanse of space filled with various dangers is daunting yet exciting for her.",
|
||||
"how does Kaira typically start their day or cycle?": "Kaira begins each day much like any other Altrusian might. After waking up from her sleep chamber, she stretches her long limbs while gazing out into the darkness beyond their tiny craft. The faint glow of nearby stars serves as a comforting reminder that even though they may feel isolated, they are never truly alone in this vast sea of endless possibilities. Once fully awake, she takes a moment to meditate before heading over to the ship's kitchenette area where she prepares herself a nutritious meal consisting primarily of algae grown within specialized tanks located near the back of their vessel. Satisfied with her morning repast, she makes sure everything is running smoothly aboard their starship before joining Captain Farstield in monitoring their progress toward Eden.",
|
||||
"what leisure activities or hobbies does Kaira indulge in?": "Aside from maintaining orderliness and tidiness around their small explorer vessel, Kaira finds solace in exploring new worlds via virtual simulations created using data collected during previous missions. These immersive experiences allow her to travel without physically leaving their cramped quarters, satisfying her thirst for knowledge about alien civilizations while simultaneously providing mental relaxation away from daily tasks associated with operating their spaceship.",
|
||||
"which individual or entity does Kaira interact with most frequently?": "Among all the entities encountered thus far on their interstellar journey, none have been more crucial than Captain Elmer Farstield. He commands their small explorer vessel, guiding it through treacherous cosmic seas towards destinations unknown. His decisions dictate whether they live another day or perish under the harsh light of distant suns. Kaira works diligently alongside him; meticulously maintaining order among the tight confines of their ship while he navigates them ever closer to their ultimate goal - Eden. Together they form an unbreakable bond, two souls bound by fate itself as they venture forth into the great beyond.",
|
||||
"what common technology, gadget, or tool does Kaira rely on?": "Kaira relies heavily upon her trusty multi-tool which can perform various tasks such as repair work, hacking into computer systems, and even serving as a makeshift weapon if necessary. She also carries a portable scanner capable of analyzing various materials and detecting potential hazards in their surroundings. Additionally, she has access to a high-tech communications system allowing her to maintain contact with her homeworld and other vessels across the galaxy. Last but not least, she possesses a personal shield generator which provides protection against radiation, extreme temperatures, and certain types of energy weapons. All these tools combined make Kaira a vital part of their team, ready to face whatever challenges lay ahead in their journey through the stars.",
|
||||
"where does Kaira go to find solace or relaxation?": "To find solace or relaxation, Kaira often engages in simulated virtual experiences created using data collected during previous missions. These immersive journeys allow her to explore new worlds without physically leaving their small spacecraft, offering both mental stimulation and respite from the routine tasks involved in running their starship.",
|
||||
"What does she think about the Captain?": "Despite respecting authority, especially when it comes to Captain Elmer Farstield, Kaira isn't afraid to challenge his decisions if she believes they could lead them astray. Ultimately, Kaira's dedication to her mission and commitment to her fellow crewmate make her a valuable asset in any interstellar adventure."
|
||||
},
|
||||
"gender": "female",
|
||||
"color": "red",
|
||||
"example_dialogue": [
|
||||
"Kaira: Yes Captain, I believe that is the best course of action *She nods slightly, as if to punctuate her approval of the decision*",
|
||||
"Kaira: \"This device appears to have multiple functions, Captain. Allow me to analyze its capabilities and determine if it could be useful in our exploration efforts.\"",
|
||||
"Kaira: \"Captain, it appears that this newly discovered planet harbors an ancient civilization whose technological advancements rival those found back home on Altrusia!\" *Excitement bubbles beneath her calm exterior as she shares the news*",
|
||||
"Kaira: \"Captain, I understand why you would want us to pursue this course of action based on our current data, but I cannot shake the feeling that there might be unforeseen consequences if we proceed without further investigation into potential hazards.\"",
|
||||
"Kaira: \"I often find myself wondering what it would have been like if I had never left my home world... But then again, perhaps it was fate that led me here, onto this ship bound for destinations unknown...\""
|
||||
],
|
||||
"history_events": [],
|
||||
"is_player": false,
|
||||
"cover_image": null
|
||||
}
|
||||
],
|
||||
"goal": null,
|
||||
"goals": [],
|
||||
"context": "an epic sci-fi adventure aimed at an adult audience.",
|
||||
"world_state": {},
|
||||
"assets": {
|
||||
"cover_image": "52b1388ed6f77a43981bd27e05df54f16e12ba8de1c48f4b9bbcb138fa7367df",
|
||||
"assets": {
|
||||
"52b1388ed6f77a43981bd27e05df54f16e12ba8de1c48f4b9bbcb138fa7367df": {
|
||||
"id": "52b1388ed6f77a43981bd27e05df54f16e12ba8de1c48f4b9bbcb138fa7367df",
|
||||
"file_type": "png",
|
||||
"media_type": "image/png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
5
src/talemate/__init__.py
Normal file
5
src/talemate/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .agents import Agent
|
||||
from .client import TextGeneratorWebuiClient
|
||||
from .tale_mate import *
|
||||
|
||||
VERSION = "0.8.0"
|
9
src/talemate/agents/__init__.py
Normal file
9
src/talemate/agents/__init__.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from .base import Agent
|
||||
from .creator import CreatorAgent
|
||||
from .context import ContextAgent
|
||||
from .conversation import ConversationAgent
|
||||
from .director import DirectorAgent
|
||||
from .memory import ChromaDBMemoryAgent, MemoryAgent
|
||||
from .narrator import NarratorAgent
|
||||
from .registry import AGENT_CLASSES, get_agent_class, register
|
||||
from .summarize import SummarizeAgent
|
120
src/talemate/agents/base.py
Normal file
120
src/talemate/agents/base.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
from blinker import signal
|
||||
|
||||
import talemate.instance as instance
|
||||
import talemate.util as util
|
||||
from talemate.emit import emit
|
||||
|
||||
|
||||
class Agent(ABC):
|
||||
"""
|
||||
Base agent class, defines a role
|
||||
"""
|
||||
|
||||
agent_type = "agent"
|
||||
verbose_name = None
|
||||
|
||||
@property
|
||||
def agent_details(self):
|
||||
if hasattr(self, "client"):
|
||||
if self.client:
|
||||
return self.client.name
|
||||
return None
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self.agent_type.capitalize()
|
||||
|
||||
@classmethod
|
||||
def config_options(cls):
|
||||
return {
|
||||
"client": [name for name, _ in instance.client_instances()],
|
||||
}
|
||||
|
||||
@property
|
||||
def ready(self):
|
||||
if not getattr(self.client, "enabled", True):
|
||||
return False
|
||||
|
||||
|
||||
if self.client.current_status in ["error", "warning"]:
|
||||
return False
|
||||
|
||||
return self.client is not None
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
if self.ready:
|
||||
return "idle"
|
||||
else:
|
||||
return "uninitialized"
|
||||
|
||||
async def emit_status(self, processing: bool = None):
|
||||
if processing is not None:
|
||||
self.processing = processing
|
||||
|
||||
status = "busy" if getattr(self, "processing", False) else self.status
|
||||
|
||||
emit(
|
||||
"agent_status",
|
||||
message=self.verbose_name or "",
|
||||
id=self.agent_type,
|
||||
status=status,
|
||||
details=self.agent_details,
|
||||
data=self.config_options(),
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
def connect(self, scene):
|
||||
self.scene = scene
|
||||
|
||||
def clean_result(self, result):
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
# Removes partial sentence at the end
|
||||
result = re.sub(r"[^\.\?\!]+(\n|$)", "", result)
|
||||
result = result.strip()
|
||||
|
||||
if ":" in result:
|
||||
result = result.split(":")[1].strip()
|
||||
|
||||
return result
|
||||
|
||||
async def get_history_memory_context(
|
||||
self,
|
||||
memory_history_context_max: int,
|
||||
memory_context_max: int,
|
||||
exclude: list = [],
|
||||
exclude_fn: Callable = None,
|
||||
):
|
||||
current_memory_context = []
|
||||
memory_helper = self.scene.get_helper("memory")
|
||||
if memory_helper:
|
||||
history_messages = "\n".join(
|
||||
self.scene.recent_history(memory_history_context_max)
|
||||
)
|
||||
memory_tokens = 0
|
||||
for memory in await memory_helper.agent.get(history_messages):
|
||||
if memory in exclude:
|
||||
continue
|
||||
|
||||
if exclude_fn:
|
||||
for split in memory.split("\n"):
|
||||
if exclude_fn(split):
|
||||
continue
|
||||
|
||||
memory_tokens += util.count_tokens(memory)
|
||||
|
||||
if memory_tokens > memory_context_max:
|
||||
break
|
||||
|
||||
current_memory_context.append(memory)
|
||||
return current_memory_context
|
3
src/talemate/agents/character_creator.py
Normal file
3
src/talemate/agents/character_creator.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Code has been moved.
|
||||
"""
|
54
src/talemate/agents/context.py
Normal file
54
src/talemate/agents/context.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
from .base import Agent
|
||||
from .registry import register
|
||||
|
||||
|
||||
@register
|
||||
class ContextAgent(Agent):
|
||||
"""
|
||||
Agent that helps retrieve context for the continuation
|
||||
of dialogue.
|
||||
"""
|
||||
|
||||
agent_type = "context"
|
||||
|
||||
def __init__(self, client, **kwargs):
|
||||
self.client = client
|
||||
|
||||
def determine_questions(self, scene_text):
|
||||
prompt = [
|
||||
"You are tasked to continue the following dialogue in a roleplaying session, but before you can do so you can ask three questions for extra context."
|
||||
"",
|
||||
"What are the questions you would ask?",
|
||||
"",
|
||||
"Known context and dialogue:" "",
|
||||
scene_text,
|
||||
"",
|
||||
"Questions:",
|
||||
"",
|
||||
]
|
||||
|
||||
prompt = "\n".join(prompt)
|
||||
|
||||
questions = self.client.send_prompt(prompt, kind="question")
|
||||
|
||||
questions = self.clean_result(questions)
|
||||
|
||||
return questions.split("\n")
|
||||
|
||||
def get_answer(self, question, context):
|
||||
prompt = [
|
||||
"Read the context and answer the question:",
|
||||
"",
|
||||
"Context:",
|
||||
"",
|
||||
context,
|
||||
"",
|
||||
f"Question: {question}",
|
||||
"Answer:",
|
||||
]
|
||||
|
||||
prompt = "\n".join(prompt)
|
||||
|
||||
answer = self.client.send_prompt(prompt, kind="answer")
|
||||
answer = self.clean_result(answer)
|
||||
return answer
|
282
src/talemate/agents/conversation.py
Normal file
282
src/talemate/agents/conversation.py
Normal file
|
@ -0,0 +1,282 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import talemate.client as client
|
||||
import talemate.util as util
|
||||
import structlog
|
||||
from talemate.emit import emit
|
||||
from talemate.scene_message import CharacterMessage, DirectorMessage
|
||||
from talemate.prompts import Prompt
|
||||
|
||||
from .base import Agent
|
||||
from .registry import register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.tale_mate import Character, Scene
|
||||
|
||||
log = structlog.get_logger("talemate.agents.conversation")
|
||||
|
||||
@register()
|
||||
class ConversationAgent(Agent):
|
||||
"""
|
||||
An agent that can be used to have a conversation with the AI
|
||||
|
||||
Ideally used with a Pygmalion or GPT >= 3.5 model
|
||||
"""
|
||||
|
||||
agent_type = "conversation"
|
||||
verbose_name = "Conversation"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: client.TaleMateClient,
|
||||
kind: Optional[str] = "pygmalion",
|
||||
logging_enabled: Optional[bool] = True,
|
||||
**kwargs,
|
||||
):
|
||||
self.client = client
|
||||
self.kind = kind
|
||||
self.logging_enabled = logging_enabled
|
||||
self.logging_date = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
self.current_memory_context = None
|
||||
|
||||
|
||||
async def build_prompt_default(
|
||||
self,
|
||||
character: Character,
|
||||
char_message: Optional[str] = "",
|
||||
):
|
||||
"""
|
||||
Builds the prompt that drives the AI's conversational response
|
||||
"""
|
||||
# the amount of tokens we can use
|
||||
# we subtract 200 to account for the response
|
||||
|
||||
scene = character.actor.scene
|
||||
|
||||
total_token_budget = self.client.max_token_length - 200
|
||||
scene_and_dialogue_budget = total_token_budget - 500
|
||||
long_term_memory_budget = min(int(total_token_budget * 0.05), 200)
|
||||
|
||||
scene_and_dialogue = scene.context_history(
|
||||
budget=scene_and_dialogue_budget,
|
||||
min_dialogue=25,
|
||||
keep_director=True,
|
||||
sections=False,
|
||||
insert_bot_token=10
|
||||
)
|
||||
|
||||
memory = await self.build_prompt_default_memory(
|
||||
scene, long_term_memory_budget,
|
||||
scene_and_dialogue + [f"{character.name}: {character.description}" for character in scene.get_characters()]
|
||||
)
|
||||
|
||||
main_character = scene.main_character.character
|
||||
|
||||
character_names = [c.name for c in scene.characters if not c.is_player]
|
||||
|
||||
if len(character_names) > 1:
|
||||
formatted_names = (
|
||||
", ".join(character_names[:-1]) + " or " + character_names[-1]
|
||||
if character_names
|
||||
else ""
|
||||
)
|
||||
else:
|
||||
formatted_names = character_names[0] if character_names else ""
|
||||
|
||||
# if there is more than 10 lines in scene_and_dialogue insert
|
||||
# a <|BOT|> token at -10, otherwise insert it at 0
|
||||
|
||||
try:
|
||||
director_message = isinstance(scene_and_dialogue[-1], DirectorMessage)
|
||||
except IndexError:
|
||||
director_message = False
|
||||
|
||||
prompt = Prompt.get("conversation.dialogue", vars={
|
||||
"scene": scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene_and_dialogue_budget": scene_and_dialogue_budget,
|
||||
"scene_and_dialogue": scene_and_dialogue,
|
||||
"memory": memory,
|
||||
"characters": list(scene.get_characters()),
|
||||
"main_character": main_character,
|
||||
"formatted_names": formatted_names,
|
||||
"talking_character": character,
|
||||
"partial_message": char_message,
|
||||
"director_message": director_message,
|
||||
})
|
||||
|
||||
return str(prompt)
|
||||
|
||||
async def build_prompt_default_memory(
|
||||
self, scene: Scene, budget: int, existing_context: list
|
||||
):
|
||||
"""
|
||||
Builds long term memory for the conversation prompt
|
||||
|
||||
This will take the last 3 messages from the history and feed them into the memory as queries
|
||||
in order to extract relevant information from the memory.
|
||||
|
||||
This will only add as much as can fit into the budget. (token budget)
|
||||
|
||||
Also it will only add information that is not already in the existing context.
|
||||
"""
|
||||
|
||||
memory = scene.get_helper("memory").agent
|
||||
|
||||
if not memory:
|
||||
return []
|
||||
|
||||
if self.current_memory_context:
|
||||
return self.current_memory_context
|
||||
|
||||
self.current_memory_context = []
|
||||
|
||||
# feed the last 3 history message into multi_query
|
||||
history_length = len(scene.history)
|
||||
i = history_length - 1
|
||||
while i >= 0 and i >= len(scene.history) - 3:
|
||||
self.current_memory_context += await memory.multi_query(
|
||||
[scene.history[i]],
|
||||
filter=lambda x: x
|
||||
not in self.current_memory_context + existing_context,
|
||||
)
|
||||
i -= 1
|
||||
|
||||
return self.current_memory_context
|
||||
|
||||
|
||||
async def build_prompt(self, character, char_message: str = ""):
|
||||
fn = self.build_prompt_default
|
||||
|
||||
return await fn(character, char_message=char_message)
|
||||
|
||||
def clean_result(self, result, character):
|
||||
|
||||
|
||||
log.debug("clean result", result=result)
|
||||
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
result = result.replace("\n", " ").strip()
|
||||
|
||||
|
||||
# Check for occurrence of a character name followed by a colon
|
||||
# that does NOT match the character name of the current character
|
||||
if "." in result and re.search(rf"(?!{self.character.name})\w+:", result):
|
||||
result = re.sub(rf"(?!{character.name})\w+:(.*\n*)*", "", result)
|
||||
|
||||
|
||||
# Removes partial sentence at the end
|
||||
result = re.sub(r"[^\.\?\!\*]+(\n|$)", "", result)
|
||||
|
||||
|
||||
result = result.replace(" :", ":")
|
||||
|
||||
result = result.strip().strip('"').strip()
|
||||
|
||||
result = result.replace("**", "*")
|
||||
|
||||
# if there is an uneven number of '*' add one to the end
|
||||
|
||||
if result.count("*") % 2 == 1:
|
||||
result += "*"
|
||||
|
||||
return result
|
||||
|
||||
async def converse(self, actor, editor=None):
|
||||
"""
|
||||
Have a conversation with the AI
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
history = actor.history
|
||||
self.current_memory_context = None
|
||||
|
||||
character = actor.character
|
||||
|
||||
result = await self.client.send_prompt(await self.build_prompt(character))
|
||||
|
||||
result = self.clean_result(result, character)
|
||||
|
||||
# Set max limit of loops
|
||||
max_loops = self.client.conversation_retries
|
||||
loop_count = 0
|
||||
total_result = result
|
||||
|
||||
empty_result_count = 0
|
||||
|
||||
# Validate AI response
|
||||
while loop_count < max_loops:
|
||||
log.debug("conversation agent", result=result)
|
||||
result = await self.client.send_prompt(
|
||||
await self.build_prompt(character, char_message=total_result)
|
||||
)
|
||||
|
||||
result = self.clean_result(result, character)
|
||||
|
||||
total_result += " "+result
|
||||
|
||||
if len(total_result) == 0 and max_loops < 10:
|
||||
max_loops += 1
|
||||
|
||||
loop_count += 1
|
||||
|
||||
if len(total_result) >= 250:
|
||||
break
|
||||
|
||||
# if result is empty, increment empty_result_count
|
||||
# and if we get 2 empty responses in a row, break
|
||||
|
||||
if result == "":
|
||||
empty_result_count += 1
|
||||
if empty_result_count >= 2:
|
||||
break
|
||||
|
||||
result = result.replace(" :", ":")
|
||||
|
||||
# Removes any line starting with another character name followed by a colon
|
||||
total_result = re.sub(rf"(?!{character.name})\w+:(.*\n*)*", "", total_result)
|
||||
|
||||
total_result = total_result.split("#")[0]
|
||||
|
||||
# Removes partial sentence at the end
|
||||
total_result = re.sub(r"[^\.\?\!\*]+(\n|$)", "", total_result)
|
||||
|
||||
if total_result.count("*") % 2 == 1:
|
||||
total_result += "*"
|
||||
|
||||
# Check if total_result starts with character name, if not, prepend it
|
||||
if not total_result.startswith(character.name):
|
||||
total_result = f"{character.name}: {total_result}"
|
||||
|
||||
total_result = total_result.strip()
|
||||
|
||||
if total_result == "" or total_result == f"{character.name}:":
|
||||
log.warn("conversation agent", result="Empty result")
|
||||
|
||||
# replace any white space betwen {self.charactrer.name}: and the first word with a single space
|
||||
total_result = re.sub(
|
||||
rf"{character.name}:\s+", f"{character.name}: ", total_result
|
||||
)
|
||||
|
||||
response_message = util.parse_messages_from_str(total_result, [character.name])
|
||||
|
||||
if editor:
|
||||
response_message = [
|
||||
editor.help_edit(character, message) for message in response_message
|
||||
]
|
||||
|
||||
messages = [CharacterMessage(message) for message in response_message]
|
||||
|
||||
# Add message and response to conversation history
|
||||
actor.scene.push_history(messages)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return messages
|
151
src/talemate/agents/creator/__init__.py
Normal file
151
src/talemate/agents/creator/__init__.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from talemate.agents.conversation import ConversationAgent
|
||||
from talemate.agents.registry import register
|
||||
from talemate.emit import emit
|
||||
|
||||
from .character import CharacterCreatorMixin
|
||||
from .scenario import ScenarioCreatorMixin
|
||||
|
||||
@register()
|
||||
class CreatorAgent(CharacterCreatorMixin, ScenarioCreatorMixin, ConversationAgent):
|
||||
|
||||
"""
|
||||
Creates characters and scenarios and other fun stuff!
|
||||
"""
|
||||
|
||||
agent_type = "creator"
|
||||
verbose_name = "Creator"
|
||||
|
||||
def clean_result(self, result):
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
return result
|
||||
|
||||
def load_templates(self, names: list, template_type: str = "character") -> dict:
|
||||
"""
|
||||
Loads multiple character creation templates from ./templates/character and merges them in order.
|
||||
Also loads instructions if present in the template.
|
||||
|
||||
Args:
|
||||
names (list): A list of template file names without the extension.
|
||||
template_type (str, optional): The type of template to load. Defaults to "character".
|
||||
Returns:
|
||||
dict: A dictionary containing merged properties based on their type.
|
||||
"""
|
||||
merged_data = {}
|
||||
context = "unknown"
|
||||
|
||||
for template_index, name in enumerate(names):
|
||||
template_path = os.path.join("./templates", template_type, f"{name}.json")
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
raise Exception(f"Template {template_path} does not exist.")
|
||||
|
||||
with open(template_path, "r") as f:
|
||||
template_data = json.load(f)
|
||||
|
||||
# Merge all keys at the root label based on their type
|
||||
for key, value in template_data.items():
|
||||
if isinstance(value, list):
|
||||
if key not in merged_data:
|
||||
merged_data[key] = []
|
||||
for item in value:
|
||||
if isinstance(item, list):
|
||||
merged_data[key] += [(item[0], item[1], name)]
|
||||
else:
|
||||
merged_data[key] += [(item, name)]
|
||||
elif isinstance(value, dict):
|
||||
if key not in merged_data:
|
||||
merged_data[key] = {}
|
||||
merged_data[key][name] = value
|
||||
if "context" in value:
|
||||
context = value["context"]
|
||||
|
||||
# Remove duplicates while preserving the order for list type keys
|
||||
for key, value in merged_data.items():
|
||||
if isinstance(value, list):
|
||||
merged_data[key] = [x for i, x in enumerate(value) if x not in value[:i]]
|
||||
|
||||
merged_data["context"] = context
|
||||
|
||||
return merged_data
|
||||
|
||||
def load_templates_old(self, names: list, template_type: str = "character") -> dict:
|
||||
"""
|
||||
Loads multiple character creation templates from ./templates/character and merges them in order.
|
||||
Also loads instructions if present in the template.
|
||||
|
||||
Args:
|
||||
names (list): A list of template file names without the extension.
|
||||
template_type (str, optional): The type of template to load. Defaults to "character".
|
||||
Returns:
|
||||
dict: A dictionary containing merged 'template', 'questions', 'history_prompts', and 'instructions' properties.
|
||||
"""
|
||||
merged_template = []
|
||||
merged_questions = []
|
||||
merged_history_prompts = []
|
||||
merged_spice = []
|
||||
merged_instructions = {}
|
||||
|
||||
context = "unknown"
|
||||
|
||||
for template_index, name in enumerate(names):
|
||||
template_path = os.path.join("./templates", template_type, f"{name}.json")
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
raise Exception(f"Template {template_path} does not exist.")
|
||||
|
||||
with open(template_path, "r") as f:
|
||||
template_data = json.load(f)
|
||||
|
||||
# Merge the template, questions, history_prompts, and instructions properties with their original order
|
||||
merged_template += [
|
||||
(item, name) for item in template_data.get("template", [])
|
||||
]
|
||||
merged_questions += [
|
||||
(item[0], item[1], name) for item in template_data.get("questions", [])
|
||||
]
|
||||
merged_history_prompts += [
|
||||
(item, name) for item in template_data.get("history_prompts", [])
|
||||
]
|
||||
merged_spice += [(item, name) for item in template_data.get("spice", [])]
|
||||
if "instructions" in template_data:
|
||||
merged_instructions[name] = template_data["instructions"]
|
||||
|
||||
if "context" in template_data["instructions"]:
|
||||
context = template_data["instructions"]["context"]
|
||||
|
||||
merged_instructions[name]["questions"] = [q[0] for q in template_data.get("questions", [])]
|
||||
|
||||
# Remove duplicates while preserving the order
|
||||
merged_template = [
|
||||
x for i, x in enumerate(merged_template) if x not in merged_template[:i]
|
||||
]
|
||||
merged_questions = [
|
||||
x for i, x in enumerate(merged_questions) if x not in merged_questions[:i]
|
||||
]
|
||||
merged_history_prompts = [
|
||||
x
|
||||
for i, x in enumerate(merged_history_prompts)
|
||||
if x not in merged_history_prompts[:i]
|
||||
]
|
||||
merged_spice = [
|
||||
x for i, x in enumerate(merged_spice) if x not in merged_spice[:i]
|
||||
]
|
||||
|
||||
rv = {
|
||||
"template": merged_template,
|
||||
"questions": merged_questions,
|
||||
"history_prompts": merged_history_prompts,
|
||||
"instructions": merged_instructions,
|
||||
"spice": merged_spice,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
return rv
|
||||
|
195
src/talemate/agents/creator/character.py
Normal file
195
src/talemate/agents/creator/character.py
Normal file
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
import random
|
||||
import structlog
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
import talemate.util as util
|
||||
from talemate.emit import emit
|
||||
from talemate.prompts import Prompt, LoopedPrompt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.tale_mate import Character
|
||||
|
||||
log = structlog.get_logger("talemate.agents.creator.character")
|
||||
|
||||
def validate(k,v):
|
||||
if k and k.lower() == "gender":
|
||||
return v.lower().strip()
|
||||
if k and k.lower() == "age":
|
||||
return int(v.strip())
|
||||
return v.strip().strip("\n")
|
||||
|
||||
DEFAULT_CONTENT_CONTEXT="a fun and engaging adventure aimed at an adult audience."
|
||||
|
||||
class CharacterCreatorMixin:
|
||||
"""
|
||||
Adds character creation functionality to the creator agent
|
||||
"""
|
||||
|
||||
## NEW
|
||||
|
||||
async def create_character_attributes(
|
||||
self,
|
||||
character_prompt: str,
|
||||
template: str,
|
||||
use_spice: float = 0.15,
|
||||
attribute_callback: Callable = lambda x: x,
|
||||
content_context: str = DEFAULT_CONTENT_CONTEXT,
|
||||
custom_attributes: dict[str, str] = dict(),
|
||||
predefined_attributes: dict[str, str] = dict(),
|
||||
):
|
||||
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
def spice(prompt, spices):
|
||||
# generate number from 0 to 1 and if its smaller than use_spice
|
||||
# select a random spice from the list and return it formatted
|
||||
# in the prompt
|
||||
if random.random() < use_spice:
|
||||
spice = random.choice(spices)
|
||||
return prompt.format(spice=spice)
|
||||
return ""
|
||||
|
||||
# drop any empty attributes from predefined_attributes
|
||||
|
||||
predefined_attributes = {k:v for k,v in predefined_attributes.items() if v}
|
||||
|
||||
prompt = Prompt.get(f"creator.character-attributes-{template}", vars={
|
||||
"character_prompt": character_prompt,
|
||||
"template": template,
|
||||
"spice": spice,
|
||||
"content_context": content_context,
|
||||
"custom_attributes": custom_attributes,
|
||||
"character_sheet": LoopedPrompt(
|
||||
validate_value=validate,
|
||||
on_update=attribute_callback,
|
||||
generated=predefined_attributes,
|
||||
),
|
||||
})
|
||||
await prompt.loop(self.client, "character_sheet", kind="create_concise")
|
||||
|
||||
return prompt.vars["character_sheet"].generated
|
||||
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def create_character_description(
|
||||
self,
|
||||
character:Character,
|
||||
content_context: str = DEFAULT_CONTENT_CONTEXT,
|
||||
):
|
||||
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
description = await Prompt.request(f"creator.character-description", self.client, "create", vars={
|
||||
"character": character,
|
||||
"content_context": content_context,
|
||||
})
|
||||
|
||||
return description.strip()
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def create_character_details(
|
||||
self,
|
||||
character: Character,
|
||||
template: str,
|
||||
detail_callback: Callable = lambda question, answer: None,
|
||||
questions: list[str] = None,
|
||||
content_context: str = DEFAULT_CONTENT_CONTEXT,
|
||||
):
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
prompt = Prompt.get(f"creator.character-details-{template}", vars={
|
||||
"character_details": LoopedPrompt(
|
||||
validate_value=validate,
|
||||
on_update=detail_callback,
|
||||
),
|
||||
"template": template,
|
||||
"content_context": content_context,
|
||||
"character": character,
|
||||
"custom_questions": questions or [],
|
||||
})
|
||||
await prompt.loop(self.client, "character_details", kind="create_concise")
|
||||
return prompt.vars["character_details"].generated
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
async def create_character_example_dialogue(
|
||||
self,
|
||||
character: Character,
|
||||
template: str,
|
||||
guide: str,
|
||||
examples: list[str] = None,
|
||||
content_context: str = DEFAULT_CONTENT_CONTEXT,
|
||||
example_callback: Callable = lambda example: None,
|
||||
rules_callback: Callable = lambda rules: None,
|
||||
):
|
||||
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
dialogue_rules = await Prompt.request(f"creator.character-dialogue-rules", self.client, "create", vars={
|
||||
"guide": guide,
|
||||
"character": character,
|
||||
"examples": examples or [],
|
||||
"content_context": content_context,
|
||||
})
|
||||
|
||||
log.info("dialogue_rules", dialogue_rules=dialogue_rules)
|
||||
|
||||
if rules_callback:
|
||||
rules_callback(dialogue_rules)
|
||||
|
||||
example_dialogue_prompt = Prompt.get(f"creator.character-example-dialogue-{template}", vars={
|
||||
"guide": guide,
|
||||
"character": character,
|
||||
"examples": examples or [],
|
||||
"content_context": content_context,
|
||||
"dialogue_rules": dialogue_rules,
|
||||
"generated_examples": LoopedPrompt(
|
||||
validate_value=validate,
|
||||
on_update=example_callback,
|
||||
),
|
||||
})
|
||||
|
||||
await example_dialogue_prompt.loop(self.client, "generated_examples", kind="create")
|
||||
|
||||
return example_dialogue_prompt.vars["generated_examples"].generated
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def determine_content_context_for_character(
|
||||
self,
|
||||
character: Character,
|
||||
):
|
||||
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
content_context = await Prompt.request(f"creator.determine-content-context", self.client, "create", vars={
|
||||
"character": character,
|
||||
})
|
||||
return content_context.strip()
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
async def determine_character_attributes(
|
||||
self,
|
||||
character: Character,
|
||||
):
|
||||
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
attributes = await Prompt.request(f"creator.determine-character-attributes", self.client, "analyze_long", vars={
|
||||
"character": character,
|
||||
})
|
||||
return attributes
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
138
src/talemate/agents/creator/scenario.py
Normal file
138
src/talemate/agents/creator/scenario.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
from talemate.emit import emit, wait_for_input_yesno
|
||||
import re
|
||||
import random
|
||||
|
||||
from talemate.prompts import Prompt
|
||||
|
||||
class ScenarioCreatorMixin:
|
||||
|
||||
"""
|
||||
Adds scenario creation functionality to the creator agent
|
||||
"""
|
||||
|
||||
### NEW
|
||||
|
||||
async def create_scene_description(
|
||||
self,
|
||||
prompt:str,
|
||||
content_context:str,
|
||||
):
|
||||
|
||||
"""
|
||||
Creates a new scene.
|
||||
|
||||
Arguments:
|
||||
|
||||
prompt (str): The prompt to use to create the scene.
|
||||
|
||||
content_context (str): The content context to use for the scene.
|
||||
|
||||
callback (callable): A callback to call when the scene has been created.
|
||||
"""
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
scene = self.scene
|
||||
|
||||
description = await Prompt.request(
|
||||
"creator.scenario-description",
|
||||
self.client,
|
||||
"create",
|
||||
vars={
|
||||
"prompt": prompt,
|
||||
"content_context": content_context,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene": scene,
|
||||
}
|
||||
)
|
||||
description = description.strip()
|
||||
|
||||
return description
|
||||
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def create_scene_name(
|
||||
self,
|
||||
prompt:str,
|
||||
content_context:str,
|
||||
description:str,
|
||||
):
|
||||
|
||||
"""
|
||||
Generates a scene name.
|
||||
|
||||
Arguments:
|
||||
|
||||
prompt (str): The prompt to use to generate the scene name.
|
||||
|
||||
content_context (str): The content context to use for the scene.
|
||||
|
||||
description (str): The description of the scene.
|
||||
"""
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
scene = self.scene
|
||||
|
||||
name = await Prompt.request(
|
||||
"creator.scenario-name",
|
||||
self.client,
|
||||
"create",
|
||||
vars={
|
||||
"prompt": prompt,
|
||||
"content_context": content_context,
|
||||
"description": description,
|
||||
"scene": scene,
|
||||
}
|
||||
)
|
||||
name = name.strip().strip('.!').replace('"','')
|
||||
return name
|
||||
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def create_scene_intro(
|
||||
self,
|
||||
prompt:str,
|
||||
content_context:str,
|
||||
description:str,
|
||||
name:str,
|
||||
):
|
||||
|
||||
"""
|
||||
Generates a scene introduction.
|
||||
|
||||
Arguments:
|
||||
|
||||
prompt (str): The prompt to use to generate the scene introduction.
|
||||
|
||||
content_context (str): The content context to use for the scene.
|
||||
|
||||
description (str): The description of the scene.
|
||||
|
||||
name (str): The name of the scene.
|
||||
"""
|
||||
try:
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
scene = self.scene
|
||||
|
||||
intro = await Prompt.request(
|
||||
"creator.scenario-intro",
|
||||
self.client,
|
||||
"create",
|
||||
vars={
|
||||
"prompt": prompt,
|
||||
"content_context": content_context,
|
||||
"description": description,
|
||||
"name": name,
|
||||
"scene": scene,
|
||||
}
|
||||
)
|
||||
intro = intro.strip()
|
||||
return intro
|
||||
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
367
src/talemate/agents/director.py
Normal file
367
src/talemate/agents/director.py
Normal file
|
@ -0,0 +1,367 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import random
|
||||
import structlog
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
import talemate.util as util
|
||||
from talemate.emit import wait_for_input, emit
|
||||
from talemate.prompts import Prompt
|
||||
from talemate.scene_message import NarratorMessage, DirectorMessage
|
||||
from talemate.automated_action import AutomatedAction
|
||||
import talemate.automated_action as automated_action
|
||||
from .conversation import ConversationAgent
|
||||
from .registry import register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate import Actor, Character, Player, Scene
|
||||
|
||||
log = structlog.get_logger("talemate")
|
||||
|
||||
@register()
|
||||
class DirectorAgent(ConversationAgent):
|
||||
agent_type = "director"
|
||||
verbose_name = "Director"
|
||||
|
||||
def get_base_prompt(self, character: Character, budget:int):
|
||||
return [character.description, character.base_attributes.get("scenario_context", "")] + self.scene.context_history(budget=budget, keep_director=False)
|
||||
|
||||
|
||||
async def decide_action(self, character: Character, goal_override:str=None):
|
||||
|
||||
"""
|
||||
Pick an action to perform to move the story towards the current story goal
|
||||
"""
|
||||
|
||||
current_goal = goal_override or await self.select_goal(self.scene)
|
||||
current_goal = f"Current story goal: {current_goal}" if current_goal else current_goal
|
||||
|
||||
response, action_eval, prompt = await self.decide_action_analyze(character, current_goal)
|
||||
# action_eval will hold {'narrate': N, 'direct': N, 'watch': N, ...}
|
||||
# where N is a number, action with the highest number wins, default action is watch
|
||||
# if there is no clear winner
|
||||
|
||||
watch_action = action_eval.get("watch", 0)
|
||||
action = max(action_eval, key=action_eval.get)
|
||||
|
||||
if action_eval[action] <= watch_action:
|
||||
action = "watch"
|
||||
|
||||
log.info("decide_action", action=action, action_eval=action_eval)
|
||||
|
||||
return response, current_goal, action
|
||||
|
||||
|
||||
async def decide_action_analyze(self, character: Character, goal:str):
|
||||
|
||||
prompt = Prompt.get("director.decide-action-analyze", vars={
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene": self.scene,
|
||||
"current_goal": goal,
|
||||
"character": character,
|
||||
})
|
||||
|
||||
response, evaluation = await prompt.send(self.client, kind="director")
|
||||
|
||||
log.info("question_direction", response=response)
|
||||
return response, evaluation, prompt
|
||||
|
||||
|
||||
async def direct(self, character: Character, goal_override:str=None):
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
analysis, current_goal, action = await self.decide_action(character, goal_override=goal_override)
|
||||
|
||||
try:
|
||||
if action == "watch":
|
||||
return None
|
||||
|
||||
if action == "direct":
|
||||
return await self.direct_character_with_self_reflection(character, analysis, goal_override=current_goal)
|
||||
|
||||
if action.startswith("narrate"):
|
||||
|
||||
narration_type = action.split(":")[1]
|
||||
|
||||
direct_narrative = await self.direct_narrative(analysis, narration_type=narration_type, goal=current_goal)
|
||||
if direct_narrative:
|
||||
narrator = self.scene.get_helper("narrator").agent
|
||||
narrator_response = await narrator.progress_story(direct_narrative)
|
||||
if not narrator_response:
|
||||
return None
|
||||
narrator_message = NarratorMessage(narrator_response, source="progress_story")
|
||||
self.scene.push_history(narrator_message)
|
||||
emit("narrator", narrator_message)
|
||||
return True
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def direct_narrative(self, analysis:str, narration_type:str="progress", goal:str=None):
|
||||
|
||||
if goal is None:
|
||||
goal = await self.select_goal(self.scene)
|
||||
|
||||
prompt = Prompt.get("director.direct-narrative", vars={
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene": self.scene,
|
||||
"narration_type": narration_type,
|
||||
"analysis": analysis,
|
||||
"current_goal": goal,
|
||||
})
|
||||
|
||||
response = await prompt.send(self.client, kind="director")
|
||||
response = response.strip().split("\n")[0].strip()
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
async def direct_character_with_self_reflection(self, character: Character, analysis:str, goal_override:str=None):
|
||||
|
||||
max_retries = 3
|
||||
num_retries = 0
|
||||
keep_direction = False
|
||||
response = None
|
||||
self_reflection = None
|
||||
|
||||
while num_retries < max_retries:
|
||||
|
||||
response, direction_prompt = await self.direct_character(
|
||||
character,
|
||||
analysis,
|
||||
goal_override=goal_override,
|
||||
previous_direction=response,
|
||||
previous_direction_feedback=self_reflection
|
||||
)
|
||||
|
||||
keep_direction, self_reflection = await self.direct_character_self_reflect(
|
||||
response, character, goal_override, direction_prompt
|
||||
)
|
||||
|
||||
if keep_direction:
|
||||
break
|
||||
|
||||
num_retries += 1
|
||||
|
||||
log.info("direct_character_with_self_reflection", response=response, keep_direction=keep_direction)
|
||||
|
||||
if not keep_direction:
|
||||
return None
|
||||
|
||||
#character_agreement = f" *{character.name} agrees with the director and progresses the story accordingly*"
|
||||
#
|
||||
#if "accordingly" not in response:
|
||||
# response += character_agreement
|
||||
#
|
||||
|
||||
#response = await self.transform_character_direction_to_inner_monologue(character, response)
|
||||
|
||||
return response
|
||||
|
||||
async def transform_character_direction_to_inner_monologue(self, character:Character, direction:str):
|
||||
|
||||
inner_monologue = await Prompt.request(
|
||||
"conversation.direction-to-inner-monologue",
|
||||
self.client,
|
||||
"conversation_long",
|
||||
vars={
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene": self.scene,
|
||||
"character": character,
|
||||
"director_instructions": direction,
|
||||
}
|
||||
)
|
||||
|
||||
return inner_monologue
|
||||
|
||||
|
||||
async def direct_character(
|
||||
self,
|
||||
character: Character,
|
||||
analysis:str,
|
||||
goal_override:str=None,
|
||||
previous_direction:str=None,
|
||||
previous_direction_feedback:str=None,
|
||||
):
|
||||
"""
|
||||
Direct the scene
|
||||
"""
|
||||
|
||||
if goal_override:
|
||||
current_goal = goal_override
|
||||
else:
|
||||
current_goal = await self.select_goal(self.scene)
|
||||
|
||||
if current_goal and not current_goal.startswith("Current story goal: "):
|
||||
current_goal = f"Current story goal: {current_goal}"
|
||||
|
||||
prompt = Prompt.get("director.direct-character", vars={
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"scene": self.scene,
|
||||
"character": character,
|
||||
"current_goal": current_goal,
|
||||
"previous_direction": previous_direction,
|
||||
"previous_direction_feedback": previous_direction_feedback,
|
||||
"analysis": analysis,
|
||||
})
|
||||
|
||||
response = await prompt.send(self.client, kind="director")
|
||||
response = response.strip().split("\n")[0].strip()
|
||||
|
||||
log.info(
|
||||
"direct_character",
|
||||
direction=response,
|
||||
previous_direction=previous_direction,
|
||||
previous_direction_feedback=previous_direction_feedback
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
if not response.startswith(prompt.prepared_response):
|
||||
response = prompt.prepared_response + response
|
||||
|
||||
return response, "\n".join(prompt.as_list[:-1])
|
||||
|
||||
|
||||
|
||||
async def direct_character_self_reflect(self, direction:str, character: Character, goal:str, direction_prompt:Prompt) -> (bool, str):
|
||||
|
||||
change_matches = ["change", "retry", "alter", "reconsider"]
|
||||
|
||||
prompt = Prompt.get("director.direct-character-self-reflect", vars={
|
||||
"direction_prompt": str(direction_prompt),
|
||||
"direction": direction,
|
||||
"analysis": await self.direct_character_analyze(direction, character, goal, direction_prompt),
|
||||
"character": character,
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
})
|
||||
|
||||
response = await prompt.send(self.client, kind="director")
|
||||
|
||||
parse_choice = response[len(prompt.prepared_response):].lower().split(" ")[0]
|
||||
|
||||
keep = not parse_choice in change_matches
|
||||
|
||||
log.info("direct_character_self_reflect", keep=keep, response=response, parsed=parse_choice)
|
||||
|
||||
return keep, response
|
||||
|
||||
|
||||
async def direct_character_analyze(self, direction:str, character: Character, goal:str, direction_prompt:Prompt):
|
||||
|
||||
prompt = Prompt.get("director.direct-character-analyze", vars={
|
||||
"direction_prompt": str(direction_prompt),
|
||||
"direction": direction,
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"character": character,
|
||||
})
|
||||
|
||||
analysis = await prompt.send(self.client, kind="director")
|
||||
|
||||
log.info("direct_character_analyze", analysis=analysis)
|
||||
|
||||
return analysis
|
||||
|
||||
async def select_goal(self, scene: Scene):
|
||||
|
||||
if not scene.goals:
|
||||
return ""
|
||||
|
||||
if isinstance(self.scene.goal, int):
|
||||
# fixes legacy goal format
|
||||
self.scene.goal = self.scene.goals[self.scene.goal]
|
||||
|
||||
while True:
|
||||
|
||||
# get current goal position in goals
|
||||
|
||||
current_goal = scene.goal
|
||||
current_goal_positon = None
|
||||
if current_goal:
|
||||
try:
|
||||
current_goal_positon = self.scene.goals.index(current_goal)
|
||||
except ValueError:
|
||||
pass
|
||||
elif self.scene.goals:
|
||||
current_goal = self.scene.goals[0]
|
||||
current_goal_positon = 0
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
# if current goal is set but not found, its a custom goal override
|
||||
|
||||
custom_goal = (current_goal and current_goal_positon is None)
|
||||
|
||||
log.info("select_goal", current_goal=current_goal, current_goal_positon=current_goal_positon, custom_goal=custom_goal)
|
||||
|
||||
if current_goal:
|
||||
current_goal_met = await self.goal_analyze(current_goal)
|
||||
|
||||
log.info("select_goal", current_goal_met=current_goal_met)
|
||||
if current_goal_met is not True:
|
||||
return current_goal + f"\nThe goal has {current_goal_met})"
|
||||
try:
|
||||
self.scene.goal = self.scene.goals[current_goal_positon + 1]
|
||||
continue
|
||||
except IndexError:
|
||||
return ""
|
||||
|
||||
else:
|
||||
return ""
|
||||
|
||||
async def goal_analyze(self, goal:str):
|
||||
|
||||
prompt = Prompt.get("director.goal-analyze", vars={
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"current_goal": goal,
|
||||
})
|
||||
|
||||
response = await prompt.send(self.client, kind="director")
|
||||
|
||||
log.info("goal_analyze", response=response)
|
||||
|
||||
if "not satisfied" in response.lower().strip() or "not been satisfied" in response.lower().strip():
|
||||
goal_met = response
|
||||
else:
|
||||
goal_met = True
|
||||
|
||||
return goal_met
|
||||
|
||||
|
||||
@automated_action.register("director", frequency=4, call_initially=True, enabled=False)
|
||||
class AutomatedDirector(automated_action.AutomatedAction):
|
||||
"""
|
||||
Runs director.direct actions every n turns
|
||||
"""
|
||||
|
||||
async def action(self):
|
||||
scene = self.scene
|
||||
director = scene.get_helper("director")
|
||||
|
||||
if not scene.active_actor or scene.active_actor.character.is_player:
|
||||
return False
|
||||
|
||||
if not director:
|
||||
return
|
||||
|
||||
director_response = await director.agent.direct(scene.active_actor.character)
|
||||
|
||||
if director_response is True:
|
||||
# director directed different agent, nothing to do
|
||||
return
|
||||
|
||||
if not director_response:
|
||||
return
|
||||
|
||||
director_message = DirectorMessage(director_response, source=scene.active_actor.character.name)
|
||||
emit("director", director_message, character=scene.active_actor.character)
|
||||
scene.push_history(director_message)
|
392
src/talemate/agents/memory.py
Normal file
392
src/talemate/agents/memory.py
Normal file
|
@ -0,0 +1,392 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
from chromadb.config import Settings
|
||||
import talemate.events as events
|
||||
import talemate.util as util
|
||||
from talemate.config import load_config
|
||||
import structlog
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.utils import embedding_functions
|
||||
except ImportError:
|
||||
chromadb = None
|
||||
pass
|
||||
|
||||
log = structlog.get_logger("talemate.agents.memory")
|
||||
|
||||
if not chromadb:
|
||||
log.info("ChromaDB not found, disabling Chroma agent")
|
||||
|
||||
|
||||
from .base import Agent
|
||||
|
||||
|
||||
class MemoryAgent(Agent):
|
||||
"""
|
||||
An agent that can be used to maintain and access a memory of the world
|
||||
using vector databases
|
||||
"""
|
||||
|
||||
agent_type = "memory"
|
||||
verbose_name = "Long-term memory"
|
||||
|
||||
@classmethod
|
||||
def config_options(cls):
|
||||
return {}
|
||||
|
||||
def __init__(self, scene, **kwargs):
|
||||
self.db = None
|
||||
self.scene = scene
|
||||
self.memory_tracker = {}
|
||||
self.config = load_config()
|
||||
|
||||
async def set_db(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def close_db(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add(self, text, character=None, uid=None):
|
||||
if not text:
|
||||
return
|
||||
|
||||
log.debug("memory add", text=text, character=character, uid=uid)
|
||||
await self._add(text, character=character, uid=uid)
|
||||
|
||||
async def _add(self, text, character=None):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add_many(self, objects: list[dict]):
|
||||
await self._add_many(objects)
|
||||
|
||||
async def _add_many(self, objects: list[dict]):
|
||||
"""
|
||||
Add multiple objects to the memory
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get(self, text, character=None, **query):
|
||||
return await self._get(str(text), character, **query)
|
||||
|
||||
async def _get(self, text, character=None, **query):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_document(self, id):
|
||||
return self.db.get(id)
|
||||
|
||||
def on_archive_add(self, event: events.ArchiveEvent):
|
||||
asyncio.ensure_future(self.add(event.text, uid=event.memory_id))
|
||||
|
||||
def on_character_state(self, event: events.CharacterStateEvent):
|
||||
asyncio.ensure_future(
|
||||
self.add(event.state, uid=f"description-{event.character_name}")
|
||||
)
|
||||
|
||||
def connect(self, scene):
|
||||
super().connect(scene)
|
||||
scene.signals["archive_add"].connect(self.on_archive_add)
|
||||
scene.signals["character_state"].connect(self.on_character_state)
|
||||
|
||||
def add_chunks(self, lines: list[str], chunk_size=200):
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
for line in lines:
|
||||
current_size += util.count_tokens(line)
|
||||
|
||||
if current_size > chunk_size:
|
||||
self.add("\n".join(current_chunk))
|
||||
current_chunk = [line]
|
||||
current_size = util.count_tokens(line)
|
||||
else:
|
||||
current_chunk.append(line)
|
||||
|
||||
if current_chunk:
|
||||
self.add("\n".join(current_chunk))
|
||||
|
||||
async def memory_context(
|
||||
self,
|
||||
name: str,
|
||||
query: str,
|
||||
max_tokens: int = 1000,
|
||||
filter: Callable = lambda x: True,
|
||||
):
|
||||
"""
|
||||
Get the character memory context for a given character
|
||||
"""
|
||||
|
||||
memory_context = []
|
||||
for memory in await self.get(query):
|
||||
if memory in memory_context:
|
||||
continue
|
||||
|
||||
if filter and not filter(memory):
|
||||
continue
|
||||
|
||||
memory_context.append(memory)
|
||||
|
||||
if util.count_tokens(memory_context) >= max_tokens:
|
||||
break
|
||||
return memory_context
|
||||
|
||||
async def query(self, query:str, max_tokens:int=1000, filter:Callable=lambda x:True):
|
||||
"""
|
||||
Get the character memory context for a given character
|
||||
"""
|
||||
|
||||
try:
|
||||
return (await self.multi_query([query], max_tokens=max_tokens, filter=filter))[0]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
|
||||
async def multi_query(
|
||||
self,
|
||||
queries: list[str],
|
||||
iterate: int = 1,
|
||||
max_tokens: int = 1000,
|
||||
filter: Callable = lambda x: True,
|
||||
formatter: Callable = lambda x: x,
|
||||
**where
|
||||
):
|
||||
"""
|
||||
Get the character memory context for a given character
|
||||
"""
|
||||
|
||||
memory_context = []
|
||||
for query in queries:
|
||||
i = 0
|
||||
for memory in await self.get(formatter(query), **where):
|
||||
if memory in memory_context:
|
||||
continue
|
||||
|
||||
if filter and not filter(memory):
|
||||
continue
|
||||
|
||||
memory_context.append(memory)
|
||||
|
||||
i += 1
|
||||
if i >= iterate:
|
||||
break
|
||||
|
||||
if util.count_tokens(memory_context) >= max_tokens:
|
||||
break
|
||||
if util.count_tokens(memory_context) >= max_tokens:
|
||||
break
|
||||
return memory_context
|
||||
|
||||
|
||||
from .registry import register
|
||||
|
||||
|
||||
@register(condition=lambda: chromadb is not None)
|
||||
class ChromaDBMemoryAgent(MemoryAgent):
|
||||
|
||||
|
||||
@property
|
||||
def ready(self):
|
||||
if getattr(self, "db_client", None):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
if self.ready:
|
||||
return "active" if not getattr(self, "processing", False) else "busy"
|
||||
return "waiting"
|
||||
|
||||
@property
|
||||
def agent_details(self):
|
||||
return f"ChromaDB: {self.embeddings}"
|
||||
|
||||
@property
|
||||
def embeddings(self):
|
||||
"""
|
||||
Returns which embeddings to use
|
||||
|
||||
will read from TM_CHROMADB_EMBEDDINGS env variable and default to 'default' using
|
||||
the default embeddings specified by chromadb.
|
||||
|
||||
other values are
|
||||
|
||||
- openai: use openai embeddings
|
||||
- instructor: use instructor embeddings
|
||||
|
||||
for `openai`:
|
||||
|
||||
you will also need to provide an `OPENAI_API_KEY` env variable
|
||||
|
||||
for `instructor`:
|
||||
|
||||
you will also need to provide which instructor model to use with the `TM_INSTRUCTOR_MODEL` env variable, which defaults to hkunlp/instructor-xl
|
||||
|
||||
additionally you can provide the `TM_INSTRUCTOR_DEVICE` env variable to specify which device to use, which defaults to cpu
|
||||
"""
|
||||
|
||||
embeddings = self.config.get("chromadb").get("embeddings")
|
||||
|
||||
assert embeddings in ["default", "openai", "instructor"], f"Unknown embeddings {embeddings}"
|
||||
|
||||
return embeddings
|
||||
|
||||
@property
|
||||
def USE_OPENAI(self):
|
||||
return self.embeddings == "openai"
|
||||
|
||||
@property
|
||||
def USE_INSTRUCTOR(self):
|
||||
return self.embeddings == "instructor"
|
||||
|
||||
async def set_db(self):
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
if getattr(self, "db", None):
|
||||
try:
|
||||
self.db.delete(where={"source": "talemate"})
|
||||
except ValueError:
|
||||
pass
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return
|
||||
|
||||
log.info("chromadb agent", status="setting up db")
|
||||
|
||||
self.db_client = chromadb.Client(Settings(anonymized_telemetry=False))
|
||||
|
||||
openai_key = self.config.get("openai").get("api_key") or os.environ.get("OPENAI_API_KEY"),
|
||||
|
||||
if openai_key and self.USE_OPENAI:
|
||||
log.info(
|
||||
"crhomadb", status="using openai", openai_key=openai_key[:5] + "..."
|
||||
)
|
||||
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
|
||||
api_key = openai_key,
|
||||
model_name="text-embedding-ada-002",
|
||||
)
|
||||
self.db = self.db_client.get_or_create_collection(
|
||||
"talemate-story", embedding_function=openai_ef
|
||||
)
|
||||
elif self.USE_INSTRUCTOR:
|
||||
|
||||
instructor_device = self.config.get("chromadb").get("instructor_device", "cpu")
|
||||
instructor_model = self.config.get("chromadb").get("instructor_model", "hkunlp/instructor-xl")
|
||||
|
||||
log.info("chromadb", status="using instructor", model=instructor_model, device=instructor_device)
|
||||
|
||||
# ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-mpnet-base-v2")
|
||||
ef = embedding_functions.InstructorEmbeddingFunction(
|
||||
model_name=instructor_model, device=instructor_device
|
||||
)
|
||||
|
||||
self.db = self.db_client.get_or_create_collection(
|
||||
"talemate-story", embedding_function=ef
|
||||
)
|
||||
else:
|
||||
log.info("chromadb", status="using default embeddings")
|
||||
self.db = self.db_client.get_or_create_collection("talemate-story")
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
log.info("chromadb agent", status="db ready")
|
||||
|
||||
def close_db(self):
|
||||
if not self.db:
|
||||
return
|
||||
|
||||
try:
|
||||
self.db.delete(where={"source": "talemate"})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def _add(self, text, character=None, uid=None):
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
if character:
|
||||
metadatas.append({"character": character.name, "source": "talemate"})
|
||||
self.memory_tracker.setdefault(character.name, 0)
|
||||
self.memory_tracker[character.name] += 1
|
||||
id = uid or f"{character.name}-{self.memory_tracker[character.name]}"
|
||||
ids = [id]
|
||||
else:
|
||||
metadatas.append({"character": "__narrator__", "source": "talemate"})
|
||||
self.memory_tracker.setdefault("__narrator__", 0)
|
||||
self.memory_tracker["__narrator__"] += 1
|
||||
id = uid or f"__narrator__-{self.memory_tracker['__narrator__']}"
|
||||
ids = [id]
|
||||
|
||||
self.db.upsert(documents=[text], metadatas=metadatas, ids=ids)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
async def _add_many(self, objects: list[dict]):
|
||||
|
||||
documents = []
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
for obj in objects:
|
||||
documents.append(obj["text"])
|
||||
meta = obj.get("meta", {})
|
||||
character = meta.get("character", "__narrator__")
|
||||
self.memory_tracker.setdefault(character, 0)
|
||||
self.memory_tracker[character] += 1
|
||||
meta["source"] = "talemate"
|
||||
metadatas.append(meta)
|
||||
uid = obj.get("id", f"{character}-{self.memory_tracker[character]}")
|
||||
ids.append(uid)
|
||||
|
||||
self.db.upsert(documents=documents, metadatas=metadatas, ids=ids)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
async def _get(self, text, character=None, **kwargs):
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
where = {}
|
||||
where.setdefault("$and", [])
|
||||
|
||||
character_filtered = False
|
||||
|
||||
for k,v in kwargs.items():
|
||||
if k == "character":
|
||||
character_filtered = True
|
||||
where["$and"].append({k: v})
|
||||
|
||||
|
||||
if character and not character_filtered:
|
||||
where["$and"].append({"character": character.name})
|
||||
|
||||
if len(where["$and"]) == 1:
|
||||
where = where["$and"][0]
|
||||
elif not where["$and"]:
|
||||
where = None
|
||||
|
||||
#log.debug("crhomadb agent get", text=text, where=where)
|
||||
|
||||
_results = self.db.query(query_texts=[text], where=where)
|
||||
|
||||
results = []
|
||||
|
||||
for i in range(len(_results["distances"][0])):
|
||||
await asyncio.sleep(0.001)
|
||||
distance = _results["distances"][0][i]
|
||||
if distance < 1:
|
||||
results.append(_results["documents"][0][i])
|
||||
else:
|
||||
break
|
||||
|
||||
# log.debug("crhomadb agent get", result=results[-1], distance=distance)
|
||||
|
||||
if len(results) > 10:
|
||||
break
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return results
|
227
src/talemate/agents/narrator.py
Normal file
227
src/talemate/agents/narrator.py
Normal file
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
import talemate.util as util
|
||||
from talemate.emit import wait_for_input
|
||||
from talemate.prompts import Prompt
|
||||
|
||||
from .conversation import ConversationAgent
|
||||
from .registry import register
|
||||
|
||||
|
||||
|
||||
|
||||
@register()
|
||||
class NarratorAgent(ConversationAgent):
|
||||
agent_type = "narrator"
|
||||
verbose_name = "Narrator"
|
||||
|
||||
def clean_result(self, result):
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
|
||||
# Removes partial sentence at the end
|
||||
# result = re.sub(r"[^\.\?\!]+(\n|$)", "", result)
|
||||
|
||||
cleaned = []
|
||||
for line in result.split("\n"):
|
||||
if ":" in line.strip():
|
||||
break
|
||||
cleaned.append(line)
|
||||
|
||||
return "\n".join(cleaned)
|
||||
|
||||
async def narrate_scene(self):
|
||||
"""
|
||||
Narrate the scene
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
|
||||
response = await Prompt.request(
|
||||
"narrator.narrate-scene",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
}
|
||||
)
|
||||
|
||||
response = f"*{response.strip('*')}*"
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return response
|
||||
|
||||
async def progress_story(self, narrative_direction:str=None):
|
||||
"""
|
||||
Narrate the scene
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
scene = self.scene
|
||||
director = scene.get_helper("director").agent
|
||||
pc = scene.get_player_character()
|
||||
npcs = list(scene.get_npc_characters())
|
||||
npc_names= ", ".join([npc.name for npc in npcs])
|
||||
|
||||
#summarized_history = await scene.summarized_dialogue_history(
|
||||
# budget = self.client.max_token_length - 300,
|
||||
# min_dialogue = 50,
|
||||
#)
|
||||
|
||||
#augmented_context = await self.augment_context()
|
||||
|
||||
if narrative_direction is None:
|
||||
#narrative_direction = await director.direct_narrative(
|
||||
# scene.context_history(budget=self.client.max_token_length - 500, min_dialogue=20),
|
||||
#)
|
||||
narrative_direction = "Slightly move the current scene forward."
|
||||
|
||||
self.scene.log.info("narrative_direction", narrative_direction=narrative_direction)
|
||||
|
||||
response = await Prompt.request(
|
||||
"narrator.narrate-progress",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
#"summarized_history": summarized_history,
|
||||
#"augmented_context": augmented_context,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"narrative_direction": narrative_direction,
|
||||
"player_character": pc,
|
||||
"npcs": npcs,
|
||||
"npc_names": npc_names,
|
||||
}
|
||||
)
|
||||
|
||||
self.scene.log.info("progress_story", response=response)
|
||||
|
||||
response = self.clean_result(response.strip())
|
||||
|
||||
response = response.strip().strip("*")
|
||||
response = f"*{response}*"
|
||||
if response.count("*") % 2 != 0:
|
||||
response = response.replace("*", "")
|
||||
response = f"*{response}*"
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return response
|
||||
|
||||
async def narrate_query(self, query:str, at_the_end:bool=False, as_narrative:bool=True):
|
||||
"""
|
||||
Narrate a specific query
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
response = await Prompt.request(
|
||||
"narrator.narrate-query",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"query": query,
|
||||
"at_the_end": at_the_end,
|
||||
"as_narrative": as_narrative,
|
||||
}
|
||||
)
|
||||
|
||||
response = self.clean_result(response.strip())
|
||||
if as_narrative:
|
||||
response = f"*{response}*"
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
return response
|
||||
|
||||
async def narrate_character(self, character):
|
||||
"""
|
||||
Narrate a specific character
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
budget = self.client.max_token_length - 300
|
||||
|
||||
memory_budget = min(int(budget * 0.05), 200)
|
||||
memory = self.scene.get_helper("memory").agent
|
||||
query = [
|
||||
f"What does {character.name} currently look like?",
|
||||
f"What is {character.name} currently wearing?",
|
||||
]
|
||||
memory_context = await memory.multi_query(
|
||||
query, iterate=1, max_tokens=memory_budget
|
||||
)
|
||||
response = await Prompt.request(
|
||||
"narrator.narrate-character",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"character": character,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"memory": memory_context,
|
||||
}
|
||||
)
|
||||
|
||||
response = self.clean_result(response.strip())
|
||||
response = f"*{response}*"
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
async def augment_context(self):
|
||||
|
||||
"""
|
||||
Takes a context history generated via scene.context_history() and augments it with additional information
|
||||
by asking and answering questions with help from the long term memory.
|
||||
"""
|
||||
memory = self.scene.get_helper("memory").agent
|
||||
|
||||
questions = await Prompt.request(
|
||||
"narrator.context-questions",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
}
|
||||
)
|
||||
|
||||
self.scene.log.info("context_questions", questions=questions)
|
||||
|
||||
questions = [q for q in questions.split("\n") if q.strip()]
|
||||
|
||||
memory_context = await memory.multi_query(
|
||||
questions, iterate=2, max_tokens=self.client.max_token_length - 1000
|
||||
)
|
||||
|
||||
answers = await Prompt.request(
|
||||
"narrator.context-answers",
|
||||
self.client,
|
||||
"narrate",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"memory": memory_context,
|
||||
"questions": questions,
|
||||
}
|
||||
)
|
||||
|
||||
self.scene.log.info("context_answers", answers=answers)
|
||||
|
||||
answers = [a for a in answers.split("\n") if a.strip()]
|
||||
|
||||
# return questions and answers
|
||||
return list(zip(questions, answers))
|
23
src/talemate/agents/registry.py
Normal file
23
src/talemate/agents/registry.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
__all__ = ["AGENT_CLASSES", "register", "get_agent_class"]
|
||||
|
||||
AGENT_CLASSES = {}
|
||||
|
||||
|
||||
class register:
|
||||
def __init__(self, condition=None):
|
||||
self.condition = condition
|
||||
|
||||
def __call__(self, agent_class):
|
||||
condition = self.condition
|
||||
|
||||
if condition and not condition():
|
||||
return agent_class
|
||||
|
||||
typ = agent_class.agent_type
|
||||
|
||||
AGENT_CLASSES[typ] = agent_class
|
||||
return agent_class
|
||||
|
||||
|
||||
def get_agent_class(name):
|
||||
return AGENT_CLASSES.get(name)
|
214
src/talemate/agents/summarize.py
Normal file
214
src/talemate/agents/summarize.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
import talemate.data_objects as data_objects
|
||||
import talemate.util as util
|
||||
from talemate.prompts import Prompt
|
||||
from talemate.scene_message import DirectorMessage
|
||||
|
||||
from .base import Agent
|
||||
from .registry import register
|
||||
|
||||
import structlog
|
||||
|
||||
import time
|
||||
|
||||
log = structlog.get_logger("talemate.agents.summarize")
|
||||
|
||||
@register()
|
||||
class SummarizeAgent(Agent):
|
||||
"""
|
||||
An agent that can be used to summarize text
|
||||
|
||||
Ideally used with a GPT model or vicuna+wizard or or gpt-3.5
|
||||
gpt4-x-vicuna is also great here.
|
||||
"""
|
||||
|
||||
agent_type = "summarizer"
|
||||
verbose_name = "Summarizer"
|
||||
auto_squish = False
|
||||
|
||||
def __init__(self, client, **kwargs):
|
||||
self.client = client
|
||||
|
||||
def on_history_add(self, event):
|
||||
asyncio.ensure_future(self.build_archive(event.scene))
|
||||
|
||||
def connect(self, scene):
|
||||
super().connect(scene)
|
||||
scene.signals["history_add"].connect(self.on_history_add)
|
||||
|
||||
async def build_archive(self, scene):
|
||||
end = None
|
||||
|
||||
if not scene.archived_history:
|
||||
start = 0
|
||||
recent_entry = None
|
||||
else:
|
||||
recent_entry = scene.archived_history[-1]
|
||||
start = recent_entry["end"] + 1
|
||||
|
||||
token_threshold = 1300
|
||||
tokens = 0
|
||||
dialogue_entries = []
|
||||
for i in range(start, len(scene.history)):
|
||||
dialogue = scene.history[i]
|
||||
if isinstance(dialogue, DirectorMessage):
|
||||
continue
|
||||
|
||||
tokens += util.count_tokens(dialogue)
|
||||
dialogue_entries.append(dialogue)
|
||||
if tokens > token_threshold: #
|
||||
end = i
|
||||
break
|
||||
|
||||
if end is None:
|
||||
# nothing to archive yet
|
||||
return
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
extra_context = None
|
||||
if recent_entry:
|
||||
extra_context = recent_entry["text"]
|
||||
|
||||
terminating_line = await self.analyze_dialoge(dialogue_entries)
|
||||
|
||||
log.debug("summarize agent build archive", terminating_line=terminating_line)
|
||||
|
||||
if terminating_line:
|
||||
adjusted_dialogue = []
|
||||
for line in dialogue_entries:
|
||||
if str(line) in terminating_line:
|
||||
break
|
||||
adjusted_dialogue.append(line)
|
||||
dialogue_entries = adjusted_dialogue
|
||||
end = start + len(dialogue_entries)
|
||||
|
||||
summarized = await self.summarize(
|
||||
"\n".join(map(str, dialogue_entries)), extra_context=extra_context
|
||||
)
|
||||
|
||||
scene.push_archive(data_objects.ArchiveEntry(summarized, start, end))
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return True
|
||||
|
||||
async def analyze_dialoge(self, dialogue):
|
||||
instruction = "Examine the dialogue from the beginning and find the first line that marks a scene change. Repeat the line back to me exactly as it is written"
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
prepare_response = "The first line that marks a scene change is: "
|
||||
|
||||
prompt = dialogue + ["", instruction, f"<|BOT|>{prepare_response}"]
|
||||
|
||||
response = await self.client.send_prompt("\n".join(map(str, prompt)), kind="summarize")
|
||||
|
||||
if prepare_response in response:
|
||||
response = response.replace(prepare_response, "")
|
||||
|
||||
response = self.clean_result(response)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return response
|
||||
|
||||
async def summarize(
|
||||
self,
|
||||
text: str,
|
||||
perspective: str = None,
|
||||
pins: Union[List[str], None] = None,
|
||||
extra_context: str = None,
|
||||
):
|
||||
"""
|
||||
Summarize the given text
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
response = await Prompt.request("summarizer.summarize-dialogue", self.client, "summarize", vars={
|
||||
"dialogue": text,
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
})
|
||||
|
||||
self.scene.log.info("summarize", dialogue=text, response=response)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
return self.clean_result(response)
|
||||
|
||||
async def simple_summary(
|
||||
self, text: str, prompt_kind: str = "summarize", instructions: str = "Summarize"
|
||||
):
|
||||
await self.emit_status(processing=True)
|
||||
prompt = [
|
||||
text,
|
||||
"",
|
||||
f"Instruction: {instructions}",
|
||||
"<|BOT|>Short Summary: ",
|
||||
]
|
||||
|
||||
response = await self.client.send_prompt("\n".join(map(str, prompt)), kind=prompt_kind)
|
||||
if ":" in response:
|
||||
response = response.split(":")[1].strip()
|
||||
await self.emit_status(processing=False)
|
||||
return response
|
||||
|
||||
|
||||
async def request_world_state(self):
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
try:
|
||||
|
||||
t1 = time.time()
|
||||
|
||||
_, world_state = await Prompt.request(
|
||||
"summarizer.request-world-state",
|
||||
self.client,
|
||||
"analyze",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"object_type": "character",
|
||||
"object_type_plural": "characters",
|
||||
}
|
||||
)
|
||||
|
||||
self.scene.log.debug("request_world_state", response=world_state, time=time.time() - t1)
|
||||
|
||||
return world_state
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
||||
|
||||
async def request_world_state_inline(self):
|
||||
|
||||
"""
|
||||
EXPERIMENTAL, Overall the one shot request seems about as coherent as the inline request, but the inline request is is about twice as slow and would need to run on every dialogue line.
|
||||
"""
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
try:
|
||||
|
||||
t1 = time.time()
|
||||
|
||||
# first, we need to get the marked items (objects etc.)
|
||||
|
||||
marked_items_response = await Prompt.request(
|
||||
"summarizer.request-world-state-inline-items",
|
||||
self.client,
|
||||
"analyze_freeform",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
}
|
||||
)
|
||||
|
||||
self.scene.log.debug("request_world_state_inline", marked_items=marked_items_response, time=time.time() - t1)
|
||||
|
||||
return marked_items_response
|
||||
finally:
|
||||
await self.emit_status(processing=False)
|
||||
|
88
src/talemate/automated_action.py
Normal file
88
src/talemate/automated_action.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import dataclasses
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate import Scene
|
||||
|
||||
import structlog
|
||||
|
||||
__all__ = ["AutomatedAction", "register", "initialize_for_scene"]
|
||||
|
||||
log = structlog.get_logger("talemate.automated_action")
|
||||
|
||||
AUTOMATED_ACTIONS = {}
|
||||
|
||||
def initialize_for_scene(scene:Scene):
|
||||
|
||||
for uid, config in AUTOMATED_ACTIONS.items():
|
||||
scene.automated_actions[uid] = config.cls(
|
||||
scene,
|
||||
uid=uid,
|
||||
frequency=config.frequency,
|
||||
call_initially=config.call_initially,
|
||||
enabled=config.enabled
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AutomatedActionConfig:
|
||||
uid:str
|
||||
cls:AutomatedAction
|
||||
frequency:int=5
|
||||
call_initially:bool=False
|
||||
enabled:bool=True
|
||||
|
||||
class register:
|
||||
|
||||
def __init__(self, uid:str, frequency:int=5, call_initially:bool=False, enabled:bool=True):
|
||||
self.uid = uid
|
||||
self.frequency = frequency
|
||||
self.call_initially = call_initially
|
||||
self.enabled = enabled
|
||||
|
||||
def __call__(self, action:AutomatedAction):
|
||||
AUTOMATED_ACTIONS[self.uid] = AutomatedActionConfig(
|
||||
self.uid,
|
||||
action,
|
||||
frequency=self.frequency,
|
||||
call_initially=self.call_initially,
|
||||
enabled=self.enabled
|
||||
)
|
||||
return action
|
||||
|
||||
class AutomatedAction:
|
||||
"""
|
||||
An action that will be executed every n turns
|
||||
"""
|
||||
|
||||
def __init__(self, scene:Scene, frequency:int=5, call_initially:bool=False, uid:str=None, enabled:bool=True):
|
||||
self.scene = scene
|
||||
self.enabled = enabled
|
||||
self.frequency = frequency
|
||||
self.turns = 1
|
||||
self.uid = uid
|
||||
if call_initially:
|
||||
self.turns = frequency
|
||||
|
||||
async def __call__(self):
|
||||
|
||||
log.debug("automated_action", uid=self.uid, enabled=self.enabled, frequency=self.frequency, turns=self.turns)
|
||||
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
if self.turns % self.frequency == 0:
|
||||
result = await self.action()
|
||||
log.debug("automated_action", result=result)
|
||||
if result is False:
|
||||
# action could not be performed at this turn, we will try again next turn
|
||||
return False
|
||||
self.turns += 1
|
||||
|
||||
|
||||
async def action(self) -> Any:
|
||||
"""
|
||||
Override this method to implement your action.
|
||||
"""
|
||||
raise NotImplementedError()
|
259
src/talemate/cli.py
Normal file
259
src/talemate/cli.py
Normal file
|
@ -0,0 +1,259 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import glob
|
||||
import os
|
||||
import structlog
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import talemate.instance as instance
|
||||
from talemate import Actor, Character, Helper, Player, Scene
|
||||
from talemate.agents import (
|
||||
ConversationAgent,
|
||||
)
|
||||
from talemate.client import OpenAIClient, TextGeneratorWebuiClient
|
||||
from talemate.emit.console import Console
|
||||
from talemate.load import (
|
||||
load_character_from_image,
|
||||
load_character_from_json,
|
||||
load_scene,
|
||||
)
|
||||
from talemate.remote.chub import CharacterHub
|
||||
|
||||
# Load env vars using dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Set up logging
|
||||
log = structlog.get_logger("talemate.cli")
|
||||
|
||||
|
||||
class DefaultClient:
|
||||
pass
|
||||
|
||||
|
||||
async def run():
|
||||
parser = argparse.ArgumentParser(description="CLI for TaleMate")
|
||||
parser.add_argument("--load", type=str, help="Load scene.")
|
||||
parser.add_argument("--reset", action="store_true", help="Reset the scene.")
|
||||
parser.add_argument(
|
||||
"--load-char", type=str, help="Load character from a partial character name."
|
||||
)
|
||||
|
||||
ai_client_choices = ["textgenwebui", "openai"]
|
||||
|
||||
parser.add_argument(
|
||||
"--conversation-client",
|
||||
type=str,
|
||||
choices=ai_client_choices,
|
||||
help="Conversation AI client to use.",
|
||||
default=DefaultClient(),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summarizer-client",
|
||||
type=str,
|
||||
choices=ai_client_choices,
|
||||
help="Summarizer AI client to use.",
|
||||
default=DefaultClient(),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--narrator-client",
|
||||
type=str,
|
||||
choices=ai_client_choices,
|
||||
help="Narrator AI client to use.",
|
||||
default=DefaultClient(),
|
||||
)
|
||||
# parser.add_argument("--editor-client", type=str, choices=ai_client_choices, help="Editor AI client to use.", default=DefaultClient())
|
||||
parser.add_argument(
|
||||
"--char-creator-client",
|
||||
type=str,
|
||||
choices=ai_client_choices,
|
||||
help="Character Creator AI client to use.",
|
||||
default=DefaultClient(),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
type=str,
|
||||
choices=ai_client_choices,
|
||||
help="Default AI client to use.",
|
||||
default="textgenwebui",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--textgenwebui-context",
|
||||
type=int,
|
||||
default=4096,
|
||||
help="Context size for TextGenWebUI client.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--textgenwebui-url",
|
||||
type=str,
|
||||
default=os.environ.get("CONVERSATION_API_URL"),
|
||||
help="URL for TextGenWebUI client. (defaults to CONVERSATION_API_URL environment variable)",
|
||||
)
|
||||
|
||||
# Add new subparsers for chub command
|
||||
subparsers = parser.add_subparsers(dest="chub")
|
||||
|
||||
# Add chub as a subparser
|
||||
chub_parser = subparsers.add_parser("chub", help="Interact with CharacterHub")
|
||||
|
||||
# Add new subparsers for chub command
|
||||
chub_subparsers = chub_parser.add_subparsers(dest="chub_action")
|
||||
|
||||
# chub search subcommand
|
||||
chub_search_parser = chub_subparsers.add_parser(
|
||||
"search", help="Search CharacterHub"
|
||||
)
|
||||
chub_search_parser.add_argument(
|
||||
"search_term", help="The search term to use for CharacterHub search"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
await run_console_session(parser, args)
|
||||
|
||||
|
||||
async def run_console_session(parser, args):
|
||||
console = Console()
|
||||
console.connect()
|
||||
|
||||
# Setup AI Clients
|
||||
|
||||
clients = {
|
||||
"conversation": args.conversation_client,
|
||||
"summarizer": args.summarizer_client,
|
||||
"narrator": args.narrator_client,
|
||||
"char_creator": args.char_creator_client,
|
||||
}
|
||||
|
||||
default_client = None
|
||||
|
||||
if "textgenwebui" in clients.values() or args.client == "textgenwebui":
|
||||
|
||||
# Init the TextGeneratorWebuiClient with ConversationAgent and create an actor
|
||||
textgenwebui_api_url = args.textgenwebui_url
|
||||
|
||||
text_generator_webui_client = TextGeneratorWebuiClient(
|
||||
textgenwebui_api_url, args.textgenwebui_context
|
||||
)
|
||||
log.info("initializing textgenwebui client", url=textgenwebui_api_url)
|
||||
|
||||
for client_name, client_typ in clients.items():
|
||||
if client_typ == "textgenwebui" or (
|
||||
isinstance(client_typ, DefaultClient) and args.client == "textgenwebui"
|
||||
):
|
||||
clients[client_name] = text_generator_webui_client
|
||||
|
||||
if "openai" in clients.values() or args.client == "openai":
|
||||
|
||||
openai_client = OpenAIClient()
|
||||
|
||||
for client_name, client_typ in clients.items():
|
||||
if client_typ == "openai" or (
|
||||
isinstance(client_typ, DefaultClient) and args.client == "openai"
|
||||
):
|
||||
log.info("initializing openai client")
|
||||
clients[client_name] = openai_client
|
||||
|
||||
# Setup scene
|
||||
|
||||
scene = Scene()
|
||||
|
||||
# Init helper agents
|
||||
|
||||
summarizer = instance.get_agent("summarizer", clients["summarizer"])
|
||||
narrator = instance.get_agent("narrator", clients["narrator"])
|
||||
creator = instance.get_agent("creator", clients["char_creator"])
|
||||
conversation = instance.get_agent("conversation", clients["conversation"])
|
||||
|
||||
scene.add_helper(Helper(summarizer))
|
||||
scene.add_helper(Helper(narrator))
|
||||
scene.add_helper(Helper(creator))
|
||||
scene.add_helper(Helper(conversation))
|
||||
|
||||
# contexter = ContextAgent(clients["contexter"])
|
||||
# scene.add_helper(Helper(contexter))
|
||||
|
||||
USE_MEMORY = True
|
||||
if USE_MEMORY:
|
||||
memory_agent = instance.get_agent("memory", scene)
|
||||
scene.add_helper(Helper(memory_agent))
|
||||
|
||||
# Check if the chub command is called
|
||||
if args.chub and args.chub_action:
|
||||
chub = CharacterHub()
|
||||
|
||||
if args.chub_action == "search":
|
||||
results = chub.search(args.search_term)
|
||||
nodes = {}
|
||||
# Display up to 20 results to the user
|
||||
for i, node in enumerate(results):
|
||||
if i < 50:
|
||||
print(f"{node['name']} (ID: {node['id']})", node["topics"])
|
||||
nodes[str(node["id"])] = node
|
||||
print("Input the ID of the character you want to download:")
|
||||
node_id = input()
|
||||
|
||||
node = nodes[node_id]
|
||||
print("node:", node)
|
||||
chub.download(node)
|
||||
return
|
||||
|
||||
# Set up Test Character
|
||||
if args.load_char:
|
||||
character_directory = "./tales/characters"
|
||||
partial_char_name = args.load_char.lower()
|
||||
|
||||
player = Player(Character("Elmer", "", "", color="cyan", gender="male"), None)
|
||||
scene.add_actor(player)
|
||||
|
||||
# Search for a matching character filename
|
||||
for character_file in glob.glob(os.path.join(character_directory, "*.*")):
|
||||
file_name = os.path.basename(character_file)
|
||||
file_name_no_ext = os.path.splitext(file_name)[0].lower()
|
||||
|
||||
if partial_char_name in file_name_no_ext:
|
||||
file_ext = os.path.splitext(character_file)[1].lower()
|
||||
image_format = file_ext.lstrip(".")
|
||||
|
||||
# If a json file is found, use Character.load_from_json instead
|
||||
if file_ext == ".json":
|
||||
test_character = load_character_from_json(character_file)
|
||||
break
|
||||
else:
|
||||
test_character = load_character_from_image(
|
||||
character_file, image_format
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No character file found with the provided partial name '{partial_char_name}'."
|
||||
)
|
||||
|
||||
agent = ConversationAgent(clients.get("conversation"))
|
||||
actor = Actor(test_character, agent)
|
||||
|
||||
# Add the TestCharacter actor to the scene
|
||||
scene.add_actor(actor)
|
||||
|
||||
elif args.load:
|
||||
scene = load_scene(scene, args.load, clients["conversation"], reset=args.reset)
|
||||
else:
|
||||
log.error("No scene loaded. Please load a scene with the --load argument.")
|
||||
return
|
||||
|
||||
# Continuously ask the user for input and send it to the actor's talk_to method
|
||||
|
||||
await scene.start()
|
||||
|
||||
|
||||
async def run_main():
|
||||
await run()
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(run_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
4
src/talemate/client/__init__.py
Normal file
4
src/talemate/client/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from talemate.client.openai import OpenAIClient
|
||||
from talemate.client.registry import CLIENT_CLASSES, get_client_class, register
|
||||
from talemate.client.textgenwebui import TextGeneratorWebuiClient
|
||||
import talemate.client.runpod
|
62
src/talemate/client/bootstrap.py
Normal file
62
src/talemate/client/bootstrap.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
import pydantic
|
||||
from enum import Enum
|
||||
|
||||
__all__ = [
|
||||
"ClientType",
|
||||
"ClientBootstrap",
|
||||
"register_list",
|
||||
"list_all",
|
||||
]
|
||||
|
||||
LISTS = {}
|
||||
|
||||
class ClientType(str, Enum):
|
||||
"""Client type enum."""
|
||||
textgen = "textgenwebui"
|
||||
automatic1111 = "automatic1111"
|
||||
|
||||
|
||||
class ClientBootstrap(pydantic.BaseModel):
|
||||
"""Client bootstrap model."""
|
||||
|
||||
# client type, currently supports "textgen" and "automatic1111"
|
||||
|
||||
client_type: ClientType
|
||||
|
||||
# unique client identifier
|
||||
|
||||
uid: str
|
||||
|
||||
# connection name
|
||||
|
||||
name: str
|
||||
|
||||
# connection information for the client
|
||||
# REST api url
|
||||
|
||||
api_url: str
|
||||
|
||||
# service name (for example runpod)
|
||||
|
||||
service_name: str
|
||||
|
||||
|
||||
class register_list:
|
||||
|
||||
def __init__(self, service_name:str):
|
||||
self.service_name = service_name
|
||||
|
||||
def __call__(self, func):
|
||||
LISTS[self.service_name] = func
|
||||
return func
|
||||
|
||||
|
||||
def list_all(exclude_urls: list[str] = list()):
|
||||
"""
|
||||
Return a list of client bootstrap objects.
|
||||
"""
|
||||
|
||||
for service_name, func in LISTS.items():
|
||||
for item in func():
|
||||
if item.api_url not in exclude_urls:
|
||||
yield item.dict()
|
73
src/talemate/client/context.py
Normal file
73
src/talemate/client/context.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
"""
|
||||
Context managers for various client-side operations.
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
__all__ = [
|
||||
'context_data',
|
||||
'client_context_attribute',
|
||||
'ContextModel',
|
||||
]
|
||||
|
||||
class ContextModel(BaseModel):
|
||||
"""
|
||||
Pydantic model for the context data.
|
||||
"""
|
||||
nuke_repetition: float = Field(0.0, ge=0.0, le=3.0)
|
||||
|
||||
# Define the context variable as an empty dictionary
|
||||
context_data = ContextVar('context_data', default=ContextModel().dict())
|
||||
|
||||
def client_context_attribute(name, default=None):
|
||||
"""
|
||||
Get the value of the context variable `context_data` for the given key.
|
||||
"""
|
||||
# Get the current context data
|
||||
data = context_data.get()
|
||||
# Return the value of the key if it exists, otherwise return the default value
|
||||
return data.get(name, default)
|
||||
|
||||
|
||||
|
||||
class ClientContext:
|
||||
"""
|
||||
A context manager to set values to the context variable `context_data`.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initialize the context manager with the key-value pairs to be set.
|
||||
"""
|
||||
# Validate the data with the Pydantic model
|
||||
self.values = ContextModel(**kwargs).dict()
|
||||
self.tokens = {}
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Set the key-value pairs to the context variable `context_data` when entering the context.
|
||||
"""
|
||||
# Get the current context data
|
||||
data = context_data.get()
|
||||
# For each key-value pair, save the current value of the key (if it exists) and set the new value
|
||||
for key, value in self.values.items():
|
||||
self.tokens[key] = data.get(key, None)
|
||||
data[key] = value
|
||||
# Update the context data
|
||||
context_data.set(data)
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""
|
||||
Reset the context variable `context_data` to its previous values when exiting the context.
|
||||
"""
|
||||
# Get the current context data
|
||||
data = context_data.get()
|
||||
# For each key, if a previous value exists, reset it. Otherwise, remove the key
|
||||
for key in self.values.keys():
|
||||
if self.tokens[key] is not None:
|
||||
data[key] = self.tokens[key]
|
||||
else:
|
||||
data.pop(key, None)
|
||||
# Update the context data
|
||||
context_data.set(data)
|
16
src/talemate/client/koboldccp.py
Normal file
16
src/talemate/client/koboldccp.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import asyncio
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Union
|
||||
|
||||
import requests
|
||||
|
||||
import talemate.util as util
|
||||
from talemate.client.registry import register
|
||||
import talemate.client.system_prompts as system_prompts
|
||||
from talemate.client.textgenwebui import RESTTaleMateClient
|
||||
from talemate.emit import Emission, emit
|
||||
|
||||
# NOT IMPLEMENTED AT THIS POINT
|
78
src/talemate/client/model_prompts.py
Normal file
78
src/talemate/client/model_prompts.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
import structlog
|
||||
|
||||
__all__ = ["model_prompt"]
|
||||
|
||||
BASE_TEMPLATE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "templates", "llm-prompt"
|
||||
)
|
||||
|
||||
log = structlog.get_logger("talemate.model_prompts")
|
||||
|
||||
class ModelPrompt:
|
||||
|
||||
"""
|
||||
Will attempt to load an LLM prompt template based on the model name
|
||||
|
||||
If the model name is not found, it will default to the 'default' template
|
||||
"""
|
||||
|
||||
template_map = {}
|
||||
|
||||
@property
|
||||
def env(self):
|
||||
if not hasattr(self, "_env"):
|
||||
log.info("modal prompt", base_template_path=BASE_TEMPLATE_PATH)
|
||||
self._env = Environment(loader=FileSystemLoader(BASE_TEMPLATE_PATH))
|
||||
|
||||
return self._env
|
||||
|
||||
def __call__(self, model_name:str, system_message:str, prompt:str):
|
||||
template = self.get_template(model_name)
|
||||
if not template:
|
||||
template = self.env.get_template("default.jinja2")
|
||||
|
||||
return template.render({
|
||||
"system_message": system_message,
|
||||
"prompt": prompt,
|
||||
"set_response" : self.set_response
|
||||
})
|
||||
|
||||
def set_response(self, prompt:str, response_str:str):
|
||||
|
||||
if "<|BOT|>" in prompt:
|
||||
prompt = prompt.replace("<|BOT|>", response_str)
|
||||
else:
|
||||
prompt = prompt + response_str
|
||||
|
||||
return prompt
|
||||
|
||||
def get_template(self, model_name:str):
|
||||
"""
|
||||
Will attempt to load an LLM prompt template - this supports
|
||||
partial filename matching on the template file name.
|
||||
"""
|
||||
|
||||
matches = []
|
||||
|
||||
# Iterate over all templates in the loader's directory
|
||||
for template_name in self.env.list_templates():
|
||||
# strip extension
|
||||
template_name_match = os.path.splitext(template_name)[0]
|
||||
# Check if the model name is in the template filename
|
||||
if template_name_match.lower() in model_name.lower():
|
||||
matches.append(template_name)
|
||||
|
||||
# If there are no matches, return None
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
# If there is only one match, return it
|
||||
if len(matches) == 1:
|
||||
return self.env.get_template(matches[0])
|
||||
|
||||
# If there are multiple matches, return the one with the longest name
|
||||
return self.env.get_template(sorted(matches, key=lambda x: len(x), reverse=True)[0])
|
||||
|
||||
model_prompt = ModelPrompt()
|
146
src/talemate/client/openai.py
Normal file
146
src/talemate/client/openai.py
Normal file
|
@ -0,0 +1,146 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from langchain.chat_models import ChatOpenAI
|
||||
from langchain.schema import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from talemate.client.registry import register
|
||||
from talemate.emit import emit
|
||||
from talemate.config import load_config
|
||||
import talemate.client.system_prompts as system_prompts
|
||||
|
||||
import structlog
|
||||
|
||||
__all__ = [
|
||||
"OpenAIClient",
|
||||
]
|
||||
|
||||
log = structlog.get_logger("talemate")
|
||||
|
||||
@register()
|
||||
class OpenAIClient:
|
||||
"""
|
||||
OpenAI client for generating text.
|
||||
"""
|
||||
|
||||
client_type = "openai"
|
||||
conversation_retries = 0
|
||||
|
||||
def __init__(self, model="gpt-3.5-turbo", **kwargs):
|
||||
self.name = kwargs.get("name", "openai")
|
||||
self.model_name = model
|
||||
self.last_token_length = 0
|
||||
self.max_token_length = 2048
|
||||
self.processing = False
|
||||
self.current_status = "idle"
|
||||
self.config = load_config()
|
||||
|
||||
# if os.environ.get("OPENAI_API_KEY") is not set, look in the config file
|
||||
# and set it
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
if self.config.get("openai", {}).get("api_key"):
|
||||
os.environ["OPENAI_API_KEY"] = self.config["openai"]["api_key"]
|
||||
|
||||
self.set_client(model)
|
||||
|
||||
|
||||
@property
|
||||
def openai_api_key(self):
|
||||
return os.environ.get("OPENAI_API_KEY")
|
||||
|
||||
|
||||
def emit_status(self, processing: bool = None):
|
||||
if processing is not None:
|
||||
self.processing = processing
|
||||
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
status = "busy" if self.processing else "idle"
|
||||
model_name = self.model_name or "No model loaded"
|
||||
else:
|
||||
status = "error"
|
||||
model_name = "No API key set"
|
||||
|
||||
self.current_status = status
|
||||
|
||||
emit(
|
||||
"client_status",
|
||||
message=self.client_type,
|
||||
id=self.name,
|
||||
details=model_name,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def set_client(self, model:str, max_token_length:int=None):
|
||||
|
||||
if not self.openai_api_key:
|
||||
log.error("No OpenAI API key set")
|
||||
return
|
||||
|
||||
self.chat = ChatOpenAI(model=model, verbose=True)
|
||||
if model == "gpt-3.5-turbo":
|
||||
self.max_token_length = min(max_token_length or 4096, 4096)
|
||||
elif model == "gpt-4":
|
||||
self.max_token_length = min(max_token_length or 8192, 8192)
|
||||
elif model == "gpt-3.5-turbo-16k":
|
||||
self.max_token_length = min(max_token_length or 16384, 16384)
|
||||
else:
|
||||
self.max_token_length = max_token_length or 2048
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
if "model" in kwargs:
|
||||
self.model_name = kwargs["model"]
|
||||
self.set_client(self.model_name, kwargs.get("max_token_length"))
|
||||
|
||||
async def status(self):
|
||||
self.emit_status()
|
||||
|
||||
def get_system_message(self, kind: str) -> str:
|
||||
|
||||
if kind in ["narrate", "story"]:
|
||||
return system_prompts.NARRATOR
|
||||
if kind == "director":
|
||||
return system_prompts.DIRECTOR
|
||||
if kind in ["create", "creator"]:
|
||||
return system_prompts.CREATOR
|
||||
if kind in ["roleplay", "conversation"]:
|
||||
return system_prompts.ROLEPLAY
|
||||
return system_prompts.BASIC
|
||||
|
||||
async def send_prompt(
|
||||
self, prompt: str, kind: str = "conversation", finalize: Callable = lambda x: x
|
||||
) -> str:
|
||||
|
||||
right = ""
|
||||
|
||||
if "<|BOT|>" in prompt:
|
||||
_, right = prompt.split("<|BOT|>", 1)
|
||||
if right:
|
||||
prompt = prompt.replace("<|BOT|>", "\nContinue this response: ")
|
||||
else:
|
||||
prompt = prompt.replace("<|BOT|>", "")
|
||||
|
||||
self.emit_status(processing=True)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
sys_message = SystemMessage(content=self.get_system_message(kind))
|
||||
|
||||
human_message = HumanMessage(content=prompt)
|
||||
|
||||
log.debug("openai send", kind=kind, sys_message=sys_message)
|
||||
|
||||
response = self.chat([sys_message, human_message])
|
||||
|
||||
response = response.content
|
||||
|
||||
if right and response.startswith(right):
|
||||
response = response[len(right):].strip()
|
||||
|
||||
if kind == "conversation":
|
||||
response = response.replace("\n", " ").strip()
|
||||
|
||||
log.debug("openai response", response=response)
|
||||
|
||||
self.emit_status(processing=False)
|
||||
return response
|
23
src/talemate/client/registry.py
Normal file
23
src/talemate/client/registry.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
__all__ = ["CLIENT_CLASSES", "register", "get_client_class"]
|
||||
|
||||
CLIENT_CLASSES = {}
|
||||
|
||||
|
||||
class register:
|
||||
def __init__(self, condition=None):
|
||||
self.condition = condition
|
||||
|
||||
def __call__(self, client_class):
|
||||
condition = self.condition
|
||||
|
||||
if condition and not condition():
|
||||
return client_class
|
||||
|
||||
typ = client_class.client_type
|
||||
|
||||
CLIENT_CLASSES[typ] = client_class
|
||||
return client_class
|
||||
|
||||
|
||||
def get_client_class(name):
|
||||
return CLIENT_CLASSES.get(name)
|
95
src/talemate/client/runpod.py
Normal file
95
src/talemate/client/runpod.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
"""
|
||||
Retrieve pod information from the server which can then be used to bootstrap talemate client
|
||||
connection for the pod. This is a simple wrapper around the runpod module.
|
||||
"""
|
||||
|
||||
import dotenv
|
||||
import runpod
|
||||
import os
|
||||
import json
|
||||
|
||||
from .bootstrap import ClientBootstrap, ClientType, register_list
|
||||
|
||||
from talemate.config import load_config
|
||||
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger("talemate.client.runpod")
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
runpod.api_key = load_config().get("runpod", {}).get("api_key", "")
|
||||
|
||||
def is_textgen_pod(pod):
|
||||
|
||||
name = pod["name"].lower()
|
||||
|
||||
if "textgen" in name or "thebloke llms" in name:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_textgen_pods():
|
||||
"""
|
||||
Return a list of text generation pods.
|
||||
"""
|
||||
|
||||
if not runpod.api_key:
|
||||
return
|
||||
|
||||
for pod in runpod.get_pods():
|
||||
if not pod["desiredStatus"] == "RUNNING":
|
||||
continue
|
||||
if is_textgen_pod(pod):
|
||||
yield pod
|
||||
|
||||
|
||||
def get_automatic1111_pods():
|
||||
"""
|
||||
Return a list of automatic1111 pods.
|
||||
"""
|
||||
|
||||
if not runpod.api_key:
|
||||
return
|
||||
|
||||
for pod in runpod.get_pods():
|
||||
if not pod["desiredStatus"] == "RUNNING":
|
||||
continue
|
||||
if "automatic1111" in pod["name"].lower():
|
||||
yield pod
|
||||
|
||||
|
||||
def _client_bootstrap(client_type: ClientType, pod):
|
||||
"""
|
||||
Return a client bootstrap object for the given client type and pod.
|
||||
"""
|
||||
|
||||
id = pod["id"]
|
||||
|
||||
if client_type == ClientType.textgen:
|
||||
api_url = f"https://{id}-5000.proxy.runpod.net/api"
|
||||
elif client_type == ClientType.automatic1111:
|
||||
api_url = f"https://{id}-5000.proxy.runpod.net/api"
|
||||
|
||||
return ClientBootstrap(
|
||||
client_type=client_type,
|
||||
uid=pod["id"],
|
||||
name=pod["name"],
|
||||
api_url=api_url,
|
||||
service_name="runpod"
|
||||
)
|
||||
|
||||
|
||||
@register_list("runpod")
|
||||
def client_bootstrap_list():
|
||||
"""
|
||||
Return a list of client bootstrap options.
|
||||
"""
|
||||
textgen_pods = list(get_textgen_pods())
|
||||
automatic1111_pods = list(get_automatic1111_pods())
|
||||
|
||||
for pod in textgen_pods:
|
||||
yield _client_bootstrap(ClientType.textgen, pod)
|
||||
|
||||
for pod in automatic1111_pods:
|
||||
yield _client_bootstrap(ClientType.automatic1111, pod)
|
15
src/talemate/client/system_prompts.py
Normal file
15
src/talemate/client/system_prompts.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from talemate.prompts import Prompt
|
||||
|
||||
BASIC = "Below is an instruction that describes a task. Write a response that appropriately completes the request."
|
||||
|
||||
ROLEPLAY = str(Prompt.get("conversation.system"))
|
||||
|
||||
NARRATOR = str(Prompt.get("narrator.system"))
|
||||
|
||||
CREATOR = str(Prompt.get("creator.system"))
|
||||
|
||||
DIRECTOR = str(Prompt.get("director.system"))
|
||||
|
||||
ANALYST = str(Prompt.get("summarizer.system-analyst"))
|
||||
|
||||
ANALYST_FREEFORM = str(Prompt.get("summarizer.system-analyst-freeform"))
|
632
src/talemate/client/textgenwebui.py
Normal file
632
src/talemate/client/textgenwebui.py
Normal file
|
@ -0,0 +1,632 @@
|
|||
import asyncio
|
||||
import random
|
||||
import json
|
||||
import copy
|
||||
import structlog
|
||||
import httpx
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Union
|
||||
import logging
|
||||
import talemate.util as util
|
||||
from talemate.client.registry import register
|
||||
import talemate.client.system_prompts as system_prompts
|
||||
from talemate.emit import Emission, emit
|
||||
from talemate.client.context import client_context_attribute
|
||||
from talemate.client.model_prompts import model_prompt
|
||||
|
||||
import talemate.instance as instance
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"TaleMateClient",
|
||||
"RestApiTaleMateClient",
|
||||
"TextGeneratorWebuiClient",
|
||||
]
|
||||
|
||||
# Set up logging level for httpx to WARNING to suppress debug logs.
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
|
||||
class DefaultContext(int):
|
||||
pass
|
||||
|
||||
|
||||
PRESET_TALEMATE_LEGACY = {
|
||||
"temperature": 0.72,
|
||||
"top_p": 0.73,
|
||||
"top_k": 0,
|
||||
"top_a": 0,
|
||||
"repetition_penalty": 1.18,
|
||||
"repetition_penalty_range": 2048,
|
||||
"encoder_repetition_penalty": 1,
|
||||
#"encoder_repetition_penalty": 1.2,
|
||||
#"no_repeat_ngram_size": 2,
|
||||
"do_sample": True,
|
||||
"length_penalty": 1,
|
||||
}
|
||||
|
||||
PRESET_TALEMATE_CONVERSATION = {
|
||||
"temperature": 0.65,
|
||||
"top_p": 0.47,
|
||||
"top_k": 42,
|
||||
"typical_p": 1,
|
||||
"top_a": 0,
|
||||
"tfs": 1,
|
||||
"epsilon_cutoff": 0,
|
||||
"eta_cutoff": 0,
|
||||
"repetition_penalty": 1.18,
|
||||
"repetition_penalty_range": 2048,
|
||||
"no_repeat_ngram_size": 0,
|
||||
"penalty_alpha": 0,
|
||||
"num_beams": 1,
|
||||
"length_penalty": 1,
|
||||
"min_length": 0,
|
||||
"encoder_rep_pen": 1,
|
||||
"do_sample": True,
|
||||
"early_stopping": False,
|
||||
"mirostat_mode": 0,
|
||||
"mirostat_tau": 5,
|
||||
"mirostat_eta": 0.1
|
||||
}
|
||||
|
||||
PRESET_TALEMATE_CREATOR = {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"repetition_penalty": 1.15,
|
||||
"repetition_penalty_range": 512,
|
||||
"top_k": 20,
|
||||
"do_sample": True,
|
||||
"length_penalty": 1,
|
||||
}
|
||||
|
||||
PRESET_LLAMA_PRECISE = {
|
||||
'temperature': 0.7,
|
||||
'top_p': 0.1,
|
||||
'repetition_penalty': 1.18,
|
||||
'top_k': 40
|
||||
}
|
||||
|
||||
PRESET_KOBOLD_GODLIKE = {
|
||||
'temperature': 0.7,
|
||||
'top_p': 0.5,
|
||||
'typical_p': 0.19,
|
||||
'repetition_penalty': 1.1,
|
||||
"repetition_penalty_range": 1024,
|
||||
}
|
||||
|
||||
PRESET_DEVINE_INTELLECT = {
|
||||
'temperature': 1.31,
|
||||
'top_p': 0.14,
|
||||
"repetition_penalty_range": 1024,
|
||||
'repetition_penalty': 1.17,
|
||||
#"repetition_penalty": 1.3,
|
||||
#"encoder_repetition_penalty": 1.2,
|
||||
#"no_repeat_ngram_size": 2,
|
||||
'top_k': 49,
|
||||
"mirostat_mode": 2,
|
||||
"mirostat_tau": 8,
|
||||
}
|
||||
|
||||
PRESET_SIMPLE_1 = {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"repetition_penalty": 1.15,
|
||||
"top_k": 20,
|
||||
}
|
||||
|
||||
|
||||
def jiggle_randomness(prompt_config:dict, offset:float=0.3) -> dict:
|
||||
"""
|
||||
adjusts temperature and repetition_penalty
|
||||
by random values using the base value as a center
|
||||
"""
|
||||
|
||||
temp = prompt_config["temperature"]
|
||||
rep_pen = prompt_config["repetition_penalty"]
|
||||
|
||||
copied_config = copy.deepcopy(prompt_config)
|
||||
|
||||
min_offset = offset * 0.3
|
||||
|
||||
copied_config["temperature"] = random.uniform(temp + min_offset, temp + offset)
|
||||
copied_config["repetition_penalty"] = random.uniform(rep_pen + min_offset * 0.3, rep_pen + offset * 0.3)
|
||||
|
||||
return copied_config
|
||||
|
||||
|
||||
class TaleMateClient:
|
||||
"""
|
||||
An abstract TaleMate client that can be implemented for different communication methods with the AI.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
max_token_length: Union[int, DefaultContext] = int.__new__(
|
||||
DefaultContext, 2048
|
||||
),
|
||||
):
|
||||
self.api_url = api_url
|
||||
self.name = "generic_client"
|
||||
self.model_name = None
|
||||
self.last_token_length = 0
|
||||
self.max_token_length = max_token_length
|
||||
self.original_max_token_length = max_token_length
|
||||
self.enabled = True
|
||||
self.current_status = None
|
||||
|
||||
@abstractmethod
|
||||
def send_message(self, message: dict) -> str:
|
||||
"""
|
||||
Sends a message to the AI. Needs to be implemented by the subclass.
|
||||
:param message: The message to be sent.
|
||||
:return: The AI's response text.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_prompt(self, prompt: str) -> str:
|
||||
"""
|
||||
Sends a prompt to the AI. Needs to be implemented by the subclass.
|
||||
:param prompt: The text prompt to send.
|
||||
:return: The AI's response text.
|
||||
"""
|
||||
pass
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
if "api_url" in kwargs:
|
||||
self.api_url = kwargs["api_url"]
|
||||
|
||||
if "max_token_length" in kwargs:
|
||||
self.max_token_length = kwargs["max_token_length"]
|
||||
|
||||
if "enabled" in kwargs:
|
||||
self.enabled = bool(kwargs["enabled"])
|
||||
|
||||
def remaining_tokens(self, context: Union[str, list]) -> int:
|
||||
return self.max_token_length - util.count_tokens(context)
|
||||
|
||||
|
||||
def prompt_template(self, sys_msg, prompt):
|
||||
return model_prompt(self.model_name, sys_msg, prompt)
|
||||
|
||||
class RESTTaleMateClient(TaleMateClient, ABC):
|
||||
"""
|
||||
A RESTful TaleMate client that connects to the REST API endpoint.
|
||||
"""
|
||||
|
||||
async def send_message(self, message: dict, url: str) -> str:
|
||||
"""
|
||||
Sends a message to the REST API and returns the AI's response.
|
||||
:param message: The message to be sent.
|
||||
:return: The AI's response text.
|
||||
"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=message, timeout=None)
|
||||
response_data = response.json()
|
||||
return response_data["results"][0]["text"]
|
||||
except KeyError:
|
||||
return response_data["results"][0]["history"]["visible"][0][-1]
|
||||
|
||||
|
||||
@register()
|
||||
class TextGeneratorWebuiClient(RESTTaleMateClient):
|
||||
"""
|
||||
Client that connects to the text-generatior-webui api
|
||||
"""
|
||||
|
||||
client_type = "textgenwebui"
|
||||
conversation_retries = 5
|
||||
|
||||
def __init__(self, api_url: str, max_token_length: int = 2048, **kwargs):
|
||||
|
||||
api_url = self.cleanup_api_url(api_url)
|
||||
|
||||
self.api_url_base = api_url
|
||||
api_url = f"{api_url}/v1/chat"
|
||||
super().__init__(api_url, max_token_length=max_token_length)
|
||||
self.model_name = None
|
||||
self.limited_ram = False
|
||||
self.name = kwargs.get("name", "textgenwebui")
|
||||
self.processing = False
|
||||
self.connected = False
|
||||
|
||||
def __str__(self):
|
||||
return f"TextGeneratorWebuiClient[{self.api_url_base}][{self.model_name or ''}]"
|
||||
|
||||
def cleanup_api_url(self, api_url:str):
|
||||
"""
|
||||
Strips trailing / and ensures endpoint is /api
|
||||
"""
|
||||
|
||||
if api_url.endswith("/"):
|
||||
api_url = api_url[:-1]
|
||||
|
||||
if not api_url.endswith("/api"):
|
||||
api_url = api_url + "/api"
|
||||
|
||||
return api_url
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
super().reconfigure(**kwargs)
|
||||
if "api_url" in kwargs:
|
||||
log.debug("reconfigure", api_url=kwargs["api_url"])
|
||||
api_url = kwargs["api_url"]
|
||||
api_url = self.cleanup_api_url(api_url)
|
||||
self.api_url_base = api_url
|
||||
self.api_url = api_url
|
||||
|
||||
def toggle_disabled_if_remote(self):
|
||||
|
||||
remote_servies = [
|
||||
".runpod.net"
|
||||
]
|
||||
|
||||
for service in remote_servies:
|
||||
if service in self.api_url_base:
|
||||
self.enabled = False
|
||||
return
|
||||
|
||||
def emit_status(self, processing: bool = None):
|
||||
if processing is not None:
|
||||
self.processing = processing
|
||||
|
||||
if not self.enabled:
|
||||
status = "disabled"
|
||||
model_name = "Disabled"
|
||||
elif not self.connected:
|
||||
status = "error"
|
||||
model_name = "Could not connect"
|
||||
elif self.model_name:
|
||||
status = "busy" if self.processing else "idle"
|
||||
model_name = self.model_name
|
||||
else:
|
||||
model_name = "No model loaded"
|
||||
status = "warning"
|
||||
|
||||
status_change = status != self.current_status
|
||||
self.current_status = status
|
||||
|
||||
emit(
|
||||
"client_status",
|
||||
message=self.client_type,
|
||||
id=self.name,
|
||||
details=model_name,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
if status_change:
|
||||
instance.emit_agent_status_by_client(self)
|
||||
|
||||
|
||||
# Add the 'status' method
|
||||
async def status(self):
|
||||
"""
|
||||
Send a request to the API to retrieve the loaded AI model name.
|
||||
Raises an error if no model name is returned.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
if not self.enabled:
|
||||
self.connected = False
|
||||
self.emit_status()
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.api_url_base}/v1/model", timeout=2)
|
||||
|
||||
except (
|
||||
httpx.TimeoutException,
|
||||
httpx.NetworkError,
|
||||
):
|
||||
self.model_name = None
|
||||
self.connected = False
|
||||
self.toggle_disabled_if_remote()
|
||||
self.emit_status()
|
||||
return
|
||||
|
||||
self.connected = True
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
self.enabled = True
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
self.connected = False
|
||||
self.toggle_disabled_if_remote()
|
||||
if not self.enabled:
|
||||
log.warn("remote service unreachable, disabling client", name=self.name)
|
||||
else:
|
||||
log.error("client response error", name=self.name, e=e)
|
||||
|
||||
self.emit_status()
|
||||
return
|
||||
|
||||
model_name = response_data.get("result")
|
||||
|
||||
if not model_name or model_name == "None":
|
||||
log.warning("client model not loaded", client=self.name)
|
||||
self.emit_status()
|
||||
return
|
||||
|
||||
model_changed = model_name != self.model_name
|
||||
|
||||
self.model_name = model_name
|
||||
|
||||
if model_changed:
|
||||
self.auto_context_length()
|
||||
|
||||
log.info(f"{self} [{self.max_token_length} ctx]: ready")
|
||||
self.emit_status()
|
||||
|
||||
def auto_context_length(self):
|
||||
"""
|
||||
Automaticalle sets context length based on LLM
|
||||
"""
|
||||
|
||||
if not isinstance(self.max_token_length, DefaultContext):
|
||||
# context length was specified manually
|
||||
return
|
||||
|
||||
model_name = self.model_name.lower()
|
||||
|
||||
if "longchat" in model_name:
|
||||
self.max_token_length = 16000
|
||||
elif "8k" in model_name:
|
||||
if not self.limited_ram or "13b" in model_name:
|
||||
self.max_token_length = 6000
|
||||
else:
|
||||
self.max_token_length = 4096
|
||||
elif "4k" in model_name:
|
||||
self.max_token_length = 4096
|
||||
else:
|
||||
self.max_token_length = self.original_max_token_length
|
||||
|
||||
@property
|
||||
def instruction_template(self):
|
||||
if "vicuna" in self.model_name.lower():
|
||||
return "Vicuna-v1.1"
|
||||
if "camel" in self.model_name.lower():
|
||||
return "Vicuna-v1.1"
|
||||
return ""
|
||||
|
||||
def prompt_url(self):
|
||||
return self.api_url_base + "/v1/generate"
|
||||
|
||||
def prompt_config_conversation_old(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.BASIC,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 75,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
config.update(PRESET_TALEMATE_CONVERSATION)
|
||||
return config
|
||||
|
||||
|
||||
def prompt_config_conversation(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.ROLEPLAY,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 75,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
"stopping_strings": ["<|end_of_turn|>", "\n\n"],
|
||||
}
|
||||
config.update(PRESET_TALEMATE_CONVERSATION)
|
||||
|
||||
jiggle_randomness(config)
|
||||
|
||||
return config
|
||||
|
||||
def prompt_config_conversation_long(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_conversation(prompt)
|
||||
config["max_new_tokens"] = 300
|
||||
return config
|
||||
|
||||
def prompt_config_summarize(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.NARRATOR,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 500,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
|
||||
config.update(PRESET_LLAMA_PRECISE)
|
||||
return config
|
||||
|
||||
def prompt_config_analyze(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.ANALYST,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 500,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
|
||||
config.update(PRESET_SIMPLE_1)
|
||||
return config
|
||||
|
||||
def prompt_config_analyze_long(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_analyze(prompt)
|
||||
config["max_new_tokens"] = 1000
|
||||
return config
|
||||
|
||||
def prompt_config_analyze_freeform(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.ANALYST_FREEFORM,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 500,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
|
||||
config.update(PRESET_SIMPLE_1)
|
||||
return config
|
||||
|
||||
|
||||
def prompt_config_narrate(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.NARRATOR,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 500,
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
config.update(PRESET_LLAMA_PRECISE)
|
||||
return config
|
||||
|
||||
def prompt_config_story(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.NARRATOR,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": 300,
|
||||
"seed": random.randint(0, 1000000000),
|
||||
"chat_prompt_size": self.max_token_length
|
||||
}
|
||||
config.update(PRESET_DEVINE_INTELLECT)
|
||||
config.update({
|
||||
"repetition_penalty": 1.3,
|
||||
"repetition_penalty_range": 2048,
|
||||
})
|
||||
return config
|
||||
|
||||
def prompt_config_create(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.CREATOR,
|
||||
prompt,
|
||||
)
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": min(1024, self.max_token_length * 0.35),
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
config.update(PRESET_TALEMATE_CREATOR)
|
||||
return config
|
||||
|
||||
def prompt_config_create_concise(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.CREATOR,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": min(400, self.max_token_length * 0.25),
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
"stopping_strings": ["<|DONE|>", "\n\n"]
|
||||
}
|
||||
config.update(PRESET_TALEMATE_CREATOR)
|
||||
return config
|
||||
|
||||
def prompt_config_create_precise(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_create_concise(prompt)
|
||||
config.update(PRESET_LLAMA_PRECISE)
|
||||
return config
|
||||
|
||||
def prompt_config_director(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.DIRECTOR,
|
||||
prompt,
|
||||
)
|
||||
|
||||
config = {
|
||||
"prompt": prompt,
|
||||
"max_new_tokens": min(600, self.max_token_length * 0.25),
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
config.update(PRESET_SIMPLE_1)
|
||||
return config
|
||||
|
||||
|
||||
def prompt_config_director_short(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_director(prompt)
|
||||
config.update(max_new_tokens=25)
|
||||
return config
|
||||
|
||||
def prompt_config_director_yesno(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_director(prompt)
|
||||
config.update(max_new_tokens=2)
|
||||
return config
|
||||
|
||||
|
||||
async def send_prompt(
|
||||
self, prompt: str, kind: str = "conversation", finalize: Callable = lambda x: x
|
||||
) -> str:
|
||||
"""
|
||||
Send a prompt to the AI and return its response.
|
||||
:param prompt: The text prompt to send.
|
||||
:return: The AI's response text.
|
||||
"""
|
||||
|
||||
#prompt = prompt.replace("<|BOT|>", "<|BOT|>Certainly! ")
|
||||
|
||||
await self.status()
|
||||
self.emit_status(processing=True)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
fn_prompt_config = getattr(self, f"prompt_config_{kind}")
|
||||
fn_url = self.prompt_url
|
||||
message = fn_prompt_config(prompt)
|
||||
|
||||
if client_context_attribute("nuke_repetition") > 0.0:
|
||||
log.info("nuke repetition", offset=client_context_attribute("nuke_repetition"), temperature=message["temperature"], repetition_penalty=message["repetition_penalty"])
|
||||
message = jiggle_randomness(message, offset=client_context_attribute("nuke_repetition"))
|
||||
log.info("nuke repetition (applied)", offset=client_context_attribute("nuke_repetition"), temperature=message["temperature"], repetition_penalty=message["repetition_penalty"])
|
||||
|
||||
message = finalize(message)
|
||||
|
||||
token_length = int(len(message["prompt"]) / 3.6)
|
||||
|
||||
self.last_token_length = token_length
|
||||
|
||||
log.debug("send_prompt", token_length=token_length, max_token_length=self.max_token_length)
|
||||
|
||||
message["prompt"] = message["prompt"].strip()
|
||||
|
||||
response = await self.send_message(message, fn_url())
|
||||
|
||||
response = response.split("#")[0]
|
||||
self.emit_status(processing=False)
|
||||
await asyncio.sleep(0.01)
|
||||
return response
|
||||
|
||||
|
||||
class OpenAPIClient(RESTTaleMateClient):
|
||||
pass
|
||||
|
||||
|
||||
class GPT3Client(OpenAPIClient):
|
||||
pass
|
||||
|
||||
|
||||
class GPT4Client(OpenAPIClient):
|
||||
pass
|
27
src/talemate/commands/__init__.py
Normal file
27
src/talemate/commands/__init__.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from .base import TalemateCommand
|
||||
from .cmd_debug_tools import *
|
||||
from .cmd_director import CmdDirectorDirect, CmdDirectorDirectWithOverride
|
||||
from .cmd_exit import CmdExit
|
||||
from .cmd_help import CmdHelp
|
||||
from .cmd_info import CmdInfo
|
||||
from .cmd_inject import CmdInject
|
||||
from .cmd_list_scenes import CmdListScenes
|
||||
from .cmd_memget import CmdMemget
|
||||
from .cmd_memset import CmdMemset
|
||||
from .cmd_narrate import CmdNarrate
|
||||
from .cmd_narrate_c import CmdNarrateC
|
||||
from .cmd_narrate_q import CmdNarrateQ
|
||||
from .cmd_narrate_progress import CmdNarrateProgress
|
||||
from .cmd_rebuild_archive import CmdRebuildArchive
|
||||
from .cmd_rename import CmdRename
|
||||
from .cmd_rerun import CmdRerun
|
||||
from .cmd_reset import CmdReset
|
||||
from .cmd_rm import CmdRm
|
||||
from .cmd_remove_character import CmdRemoveCharacter
|
||||
from .cmd_save import CmdSave
|
||||
from .cmd_save_as import CmdSaveAs
|
||||
from .cmd_save_characters import CmdSaveCharacters
|
||||
from .cmd_setenv import CmdSetEnvironmentToScene, CmdSetEnvironmentToCreative
|
||||
from .cmd_world_state import CmdWorldState
|
||||
from .cmd_run_helios_test import CmdHeliosTest
|
||||
from .manager import Manager
|
54
src/talemate/commands/base.py
Normal file
54
src/talemate/commands/base.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
Talemate Command Base class
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from talemate.emit import Emitter, emit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.tale_mate import CommandManager, Scene
|
||||
|
||||
|
||||
class TalemateCommand(Emitter, ABC):
|
||||
name: str
|
||||
description: str
|
||||
aliases: list = None
|
||||
scene: Scene = None
|
||||
manager: CommandManager = None
|
||||
label: str = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager,
|
||||
*args,
|
||||
):
|
||||
self.scene = manager.scene
|
||||
self.manager = manager
|
||||
self.args = args
|
||||
self.setup_emitter(self.scene)
|
||||
|
||||
@classmethod
|
||||
def is_command(cls, name):
|
||||
return name == cls.name or name in cls.aliases
|
||||
|
||||
@abstractmethod
|
||||
def run(self):
|
||||
raise NotImplementedError(
|
||||
"TalemateCommand.run() must be implemented by subclass"
|
||||
)
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
if self.label:
|
||||
return self.label.title()
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def command_start(self):
|
||||
emit("command_status", self.verbose_name, status="started")
|
||||
|
||||
def command_end(self):
|
||||
emit("command_status", self.verbose_name, status="ended")
|
20
src/talemate/commands/cmd_debug_off.py
Normal file
20
src/talemate/commands/cmd_debug_off.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdDebugOff(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'debug_off' command
|
||||
"""
|
||||
|
||||
name = "debug_off"
|
||||
description = "Turn off debug mode"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
await asyncio.sleep(0)
|
20
src/talemate/commands/cmd_debug_on.py
Normal file
20
src/talemate/commands/cmd_debug_on.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdDebugOn(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'debug_on' command
|
||||
"""
|
||||
|
||||
name = "debug_on"
|
||||
description = "Turn on debug mode"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
await asyncio.sleep(0)
|
87
src/talemate/commands/cmd_debug_tools.py
Normal file
87
src/talemate/commands/cmd_debug_tools.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.prompts.base import set_default_sectioning_handler
|
||||
|
||||
__all__ = [
|
||||
"CmdDebugOn",
|
||||
"CmdDebugOff",
|
||||
"CmdPromptChangeSectioning",
|
||||
"CmdRunAutomatic",
|
||||
]
|
||||
|
||||
@register
|
||||
class CmdDebugOn(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'debug_on' command
|
||||
"""
|
||||
|
||||
name = "debug_on"
|
||||
description = "Turn on debug mode"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@register
|
||||
class CmdDebugOff(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'debug_off' command
|
||||
"""
|
||||
|
||||
name = "debug_off"
|
||||
description = "Turn off debug mode"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@register
|
||||
class CmdPromptChangeSectioning(TalemateCommand):
|
||||
"""
|
||||
Command class for the '_prompt_change_sectioning' command
|
||||
"""
|
||||
|
||||
name = "_prompt_change_sectioning"
|
||||
description = "Change the sectioning handler for the prompt system"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
|
||||
if not self.args:
|
||||
self.emit("system", "You must specify a sectioning handler")
|
||||
return
|
||||
|
||||
handler_name = self.args[0]
|
||||
|
||||
set_default_sectioning_handler(handler_name)
|
||||
|
||||
self.emit("system", f"Sectioning handler set to {handler_name}")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@register
|
||||
class CmdRunAutomatic(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'run_automatic' command
|
||||
"""
|
||||
|
||||
name = "run_automatic"
|
||||
description = "Will make the player character AI controlled for n turns"
|
||||
aliases = ["auto"]
|
||||
|
||||
async def run(self):
|
||||
|
||||
|
||||
if self.args:
|
||||
turns = int(self.args[0])
|
||||
else:
|
||||
turns = 10
|
||||
|
||||
self.emit("system", f"Making player character AI controlled for {turns} turns")
|
||||
self.scene.get_player_character().actor.ai_controlled = turns
|
75
src/talemate/commands/cmd_director.py
Normal file
75
src/talemate/commands/cmd_director.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.emit import wait_for_input, emit
|
||||
from talemate.util import colored_text, wrap_text
|
||||
from talemate.scene_message import DirectorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdDirectorDirect(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'director' command
|
||||
"""
|
||||
|
||||
name = "director"
|
||||
description = "Calls a director to give directionts to a character"
|
||||
aliases = ["direct"]
|
||||
|
||||
async def run(self, ask_for_input=True):
|
||||
director = self.scene.get_helper("director")
|
||||
|
||||
if not director:
|
||||
self.system_message("No director found")
|
||||
return True
|
||||
|
||||
npc_count = self.scene.num_npc_characters()
|
||||
|
||||
if npc_count == 1:
|
||||
character = list(self.scene.get_npc_characters())[0]
|
||||
elif npc_count > 1:
|
||||
name = await wait_for_input("Enter character name: ")
|
||||
character = self.scene.get_character(name)
|
||||
else:
|
||||
self.system_message("No characters to direct")
|
||||
return True
|
||||
|
||||
if not character:
|
||||
self.system_message(f"Character not found: {name}")
|
||||
return True
|
||||
|
||||
if ask_for_input:
|
||||
goal = await wait_for_input(f"Enter a new goal for the director to direct {character.name} towards (leave empty for auto-direct): ")
|
||||
else:
|
||||
goal = None
|
||||
direction = await director.agent.direct(character, goal_override=goal)
|
||||
|
||||
if direction is None:
|
||||
self.system_message("Director was unable to direct character at this point in the story.")
|
||||
return True
|
||||
|
||||
if direction is True:
|
||||
return True
|
||||
|
||||
message = DirectorMessage(direction, source=character.name)
|
||||
emit("director", message, character=character)
|
||||
|
||||
# remove previous director message, starting from the end of self.history
|
||||
for i in range(len(self.scene.history) - 1, -1, -1):
|
||||
if isinstance(self.scene.history[i], DirectorMessage):
|
||||
self.scene.history.pop(i)
|
||||
break
|
||||
|
||||
self.scene.push_history(message)
|
||||
|
||||
@register
|
||||
class CmdDirectorDirectWithOverride(CmdDirectorDirect):
|
||||
"""
|
||||
Command class for the 'director' command
|
||||
"""
|
||||
|
||||
name = "director_with_goal"
|
||||
description = "Calls a director to give directionts to a character (with goal specified)"
|
||||
aliases = ["direct_g"]
|
||||
|
||||
async def run(self):
|
||||
await super().run(ask_for_input=True)
|
19
src/talemate/commands/cmd_exit.py
Normal file
19
src/talemate/commands/cmd_exit.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdExit(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'exit' command
|
||||
"""
|
||||
|
||||
name = "exit"
|
||||
description = "Exit the scene"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
await asyncio.sleep(0)
|
||||
raise self.scene.ExitScene()
|
24
src/talemate/commands/cmd_help.py
Normal file
24
src/talemate/commands/cmd_help.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import Manager, register
|
||||
from talemate.util import colored_text, wrap_text
|
||||
|
||||
|
||||
@register
|
||||
class CmdHelp(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'help' command
|
||||
"""
|
||||
|
||||
name = "help"
|
||||
description = "Lists all commands and their descriptions"
|
||||
aliases = ["h"]
|
||||
|
||||
async def run(self):
|
||||
for command_cls in Manager.command_classes:
|
||||
aliases = ", ".join(command_cls.aliases)
|
||||
self.scene.system_message(
|
||||
command_cls.name + f" ({aliases}): " + command_cls.description
|
||||
)
|
||||
await asyncio.sleep(0)
|
25
src/talemate/commands/cmd_info.py
Normal file
25
src/talemate/commands/cmd_info.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.util import colored_text, wrap_text
|
||||
|
||||
|
||||
@register
|
||||
class CmdInfo(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'info' command
|
||||
"""
|
||||
|
||||
name = "info"
|
||||
description = "Prints description of the scene and each character"
|
||||
aliases = ["i"]
|
||||
|
||||
async def run(self):
|
||||
self.narrator_message(self.scene.description)
|
||||
|
||||
for actor in self.scene.actors:
|
||||
self.narrator_message(actor.character.name)
|
||||
self.narrator_message(actor.character.description)
|
||||
|
||||
await asyncio.sleep(0)
|
30
src/talemate/commands/cmd_inject.py
Normal file
30
src/talemate/commands/cmd_inject.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.emit import wait_for_input
|
||||
|
||||
|
||||
@register
|
||||
class CmdInject(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'inject' command
|
||||
"""
|
||||
|
||||
name = "inject"
|
||||
description = "Injects a message into the history"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
for actor in self.scene.actors:
|
||||
if isinstance(actor, Player):
|
||||
continue
|
||||
|
||||
character = actor.character
|
||||
name = character.name
|
||||
|
||||
message = await wait_for_input(f"{name} [Inject]:")
|
||||
|
||||
# inject message into history
|
||||
self.scene.push_history(f"{name}: {message}")
|
||||
break
|
20
src/talemate/commands/cmd_list_scenes.py
Normal file
20
src/talemate/commands/cmd_list_scenes.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import Manager, register
|
||||
from talemate.files import list_scenes_directory
|
||||
|
||||
|
||||
@register
|
||||
class CmdListScenes(TalemateCommand):
|
||||
name = "list_scenes"
|
||||
description = "Lists all scenes"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
scenes = list_scenes_directory()
|
||||
|
||||
for scene in scenes:
|
||||
self.scene.system_message(scene)
|
||||
|
||||
await asyncio.sleep(0)
|
19
src/talemate/commands/cmd_memget.py
Normal file
19
src/talemate/commands/cmd_memget.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
@register
|
||||
class CmdMemget(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'memget' command
|
||||
"""
|
||||
|
||||
name = "dbg_memget"
|
||||
description = "Gets the memory of a character"
|
||||
aliases = []
|
||||
|
||||
def run(self):
|
||||
query = input("query:")
|
||||
memories = self.scene.get_helper("memory").agent.get(query)
|
||||
|
||||
for memory in memories:
|
||||
self.emit("narrator", memory["text"])
|
17
src/talemate/commands/cmd_memset.py
Normal file
17
src/talemate/commands/cmd_memset.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdMemset(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'memset' command
|
||||
"""
|
||||
|
||||
name = "dbg_memset"
|
||||
description = "Sets the memory of a character"
|
||||
aliases = []
|
||||
|
||||
def run(self):
|
||||
memory = input("memory:")
|
||||
self.scene.get_helper("memory").agent.add(memory)
|
31
src/talemate/commands/cmd_narrate.py
Normal file
31
src/talemate/commands/cmd_narrate.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.util import colored_text, wrap_text
|
||||
from talemate.scene_message import NarratorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdNarrate(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'narrate' command
|
||||
"""
|
||||
|
||||
name = "narrate"
|
||||
description = "Calls a narrator to narrate the scene"
|
||||
aliases = ["n"]
|
||||
|
||||
async def run(self):
|
||||
narrator = self.scene.get_helper("narrator")
|
||||
|
||||
if not narrator:
|
||||
self.system_message("No narrator found")
|
||||
return True
|
||||
|
||||
narration = await narrator.agent.narrate_scene()
|
||||
message = NarratorMessage(narration, source="narrate_scene")
|
||||
|
||||
self.narrator_message(message)
|
||||
self.scene.push_history(message)
|
||||
await asyncio.sleep(0)
|
41
src/talemate/commands/cmd_narrate_c.py
Normal file
41
src/talemate/commands/cmd_narrate_c.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.emit import wait_for_input
|
||||
from talemate.util import colored_text, wrap_text
|
||||
from talemate.scene_message import NarratorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdNarrateC(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'narrate_c' command
|
||||
"""
|
||||
|
||||
name = "narrate_c"
|
||||
description = "Calls a narrator to narrate a character"
|
||||
aliases = ["nc"]
|
||||
label = "Look at"
|
||||
|
||||
async def run(self):
|
||||
narrator = self.scene.get_helper("narrator")
|
||||
|
||||
if not narrator:
|
||||
self.system_message("No narrator found")
|
||||
return True
|
||||
|
||||
if self.args:
|
||||
name = self.args[0]
|
||||
else:
|
||||
name = await wait_for_input("Enter character name: ")
|
||||
|
||||
character = self.scene.get_character(name, partial=True)
|
||||
|
||||
if not character:
|
||||
self.system_message(f"Character not found: {name}")
|
||||
return True
|
||||
|
||||
narration = await narrator.agent.narrate_character(character)
|
||||
message = NarratorMessage(narration, source=f"narrate_character:{name}")
|
||||
|
||||
self.narrator_message(message)
|
||||
self.scene.push_history(message)
|
32
src/talemate/commands/cmd_narrate_progress.py
Normal file
32
src/talemate/commands/cmd_narrate_progress.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.util import colored_text, wrap_text
|
||||
from talemate.scene_message import NarratorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdNarrateProgress(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'narrate_progress' command
|
||||
"""
|
||||
|
||||
name = "narrate_progress"
|
||||
description = "Calls a narrator to narrate the scene"
|
||||
aliases = ["np"]
|
||||
|
||||
async def run(self):
|
||||
narrator = self.scene.get_helper("narrator")
|
||||
|
||||
if not narrator:
|
||||
self.system_message("No narrator found")
|
||||
return True
|
||||
|
||||
narration = await narrator.agent.progress_story()
|
||||
|
||||
message = NarratorMessage(narration, source="progress_story")
|
||||
|
||||
self.narrator_message(message)
|
||||
self.scene.push_history(message)
|
||||
await asyncio.sleep(0)
|
36
src/talemate/commands/cmd_narrate_q.py
Normal file
36
src/talemate/commands/cmd_narrate_q.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.emit import wait_for_input
|
||||
from talemate.scene_message import NarratorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdNarrateQ(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'narrate_q' command
|
||||
"""
|
||||
|
||||
name = "narrate_q"
|
||||
description = "Will attempt to narrate using a specific question prompt"
|
||||
aliases = ["nq"]
|
||||
label = "Look at"
|
||||
|
||||
async def run(self):
|
||||
narrator = self.scene.get_helper("narrator")
|
||||
|
||||
if not narrator:
|
||||
self.system_message("No narrator found")
|
||||
return True
|
||||
|
||||
if self.args:
|
||||
query = self.args[0]
|
||||
at_the_end = (self.args[1].lower() == "true") if len(self.args) > 1 else False
|
||||
else:
|
||||
query = await wait_for_input("Enter query: ")
|
||||
at_the_end = False
|
||||
|
||||
narration = await narrator.agent.narrate_query(query, at_the_end=at_the_end)
|
||||
message = NarratorMessage(narration, source=f"narrate_query:{query.replace(':', '-')}")
|
||||
|
||||
self.narrator_message(message)
|
||||
self.scene.push_history(message)
|
32
src/talemate/commands/cmd_rebuild_archive.py
Normal file
32
src/talemate/commands/cmd_rebuild_archive.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdRebuildArchive(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'rebuild_archive' command
|
||||
"""
|
||||
|
||||
name = "rebuild_archive"
|
||||
description = "Rebuilds the archive of the scene"
|
||||
aliases = ["rebuild"]
|
||||
|
||||
async def run(self):
|
||||
summarizer = self.scene.get_helper("summarizer")
|
||||
|
||||
if not summarizer:
|
||||
self.system_message("No summarizer found")
|
||||
return True
|
||||
|
||||
self.scene.archived_history = []
|
||||
|
||||
while True:
|
||||
more = await summarizer.agent.build_archive(self.scene)
|
||||
|
||||
if not more:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0)
|
51
src/talemate/commands/cmd_remove_character.py
Normal file
51
src/talemate/commands/cmd_remove_character.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.emit import wait_for_input, wait_for_input_yesno
|
||||
|
||||
|
||||
@register
|
||||
class CmdRemoveCharacter(TalemateCommand):
|
||||
"""
|
||||
Removes a character from the scene
|
||||
"""
|
||||
|
||||
name = "remove_character"
|
||||
description = "Will remove a character from the scene"
|
||||
aliases = ["rmc"]
|
||||
|
||||
async def run(self):
|
||||
|
||||
characters = list([character.name for character in self.scene.get_characters()])
|
||||
|
||||
if not characters:
|
||||
self.system_message("No characters found")
|
||||
return True
|
||||
|
||||
if self.args:
|
||||
character_name = self.args[0]
|
||||
else:
|
||||
character_name = await wait_for_input("Which character do you want to remove?", data={
|
||||
"input_type": "select",
|
||||
"choices": characters,
|
||||
})
|
||||
|
||||
if not character_name:
|
||||
self.system_message("No character selected")
|
||||
return True
|
||||
|
||||
|
||||
character = self.scene.get_character(character_name)
|
||||
|
||||
if not character:
|
||||
self.system_message(f"Character {character_name} not found")
|
||||
return True
|
||||
|
||||
await self.scene.remove_actor(character.actor)
|
||||
|
||||
self.system_message(f"Removed {character.name} from scene")
|
||||
|
||||
self.scene.emit_status()
|
||||
|
||||
return True
|
||||
|
||||
|
23
src/talemate/commands/cmd_rename.py
Normal file
23
src/talemate/commands/cmd_rename.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
from talemate.emit import wait_for_input
|
||||
|
||||
|
||||
@register
|
||||
class CmdRename(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'rename' command
|
||||
"""
|
||||
|
||||
name = "rename"
|
||||
description = "Rename the main character"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
name = await wait_for_input("Enter new name: ")
|
||||
|
||||
self.scene.main_character.character.rename(name)
|
||||
await asyncio.sleep(0)
|
18
src/talemate/commands/cmd_rerun.py
Normal file
18
src/talemate/commands/cmd_rerun.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.client.context import ClientContext
|
||||
|
||||
@register
|
||||
class CmdRerun(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'rerun' command
|
||||
"""
|
||||
|
||||
name = "rerun"
|
||||
description = "Rerun the scene"
|
||||
aliases = ["rr"]
|
||||
|
||||
async def run(self):
|
||||
nuke_repetition = self.args[0] if self.args else 0.0
|
||||
with ClientContext(nuke_repetition=nuke_repetition):
|
||||
await self.scene.rerun()
|
28
src/talemate/commands/cmd_reset.py
Normal file
28
src/talemate/commands/cmd_reset.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
from talemate.emit import wait_for_input, wait_for_input_yesno, emit
|
||||
from talemate.exceptions import ResetScene
|
||||
|
||||
|
||||
@register
|
||||
class CmdReset(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'reset' command
|
||||
"""
|
||||
|
||||
name = "reset"
|
||||
description = "Reset the scene"
|
||||
aliases = [""]
|
||||
|
||||
async def run(self):
|
||||
|
||||
reset = await wait_for_input_yesno("Reset the scene?")
|
||||
|
||||
if reset.lower() not in ["yes", "y"]:
|
||||
self.system_message("Reset cancelled")
|
||||
return True
|
||||
|
||||
self.scene.reset()
|
||||
|
||||
raise ResetScene()
|
21
src/talemate/commands/cmd_rm.py
Normal file
21
src/talemate/commands/cmd_rm.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.util import colored_text
|
||||
|
||||
|
||||
@register
|
||||
class CmdRm(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'rm' command
|
||||
"""
|
||||
|
||||
name = "rm"
|
||||
description = "Removes most recent entry from history"
|
||||
aliases = []
|
||||
|
||||
async def run(self):
|
||||
self.scene.history.pop(-1)
|
||||
self.system_message("Removed most recent entry from history")
|
||||
await asyncio.sleep(0)
|
39
src/talemate/commands/cmd_run_helios_test.py
Normal file
39
src/talemate/commands/cmd_run_helios_test.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
from talemate.emit import wait_for_input, wait_for_input_yesno, emit
|
||||
from talemate.exceptions import ResetScene
|
||||
|
||||
|
||||
@register
|
||||
class CmdHeliosTest(TalemateCommand):
|
||||
"""
|
||||
Runs the helios test
|
||||
"""
|
||||
|
||||
name = "helios_test"
|
||||
description = "Runs the helios test"
|
||||
aliases = [""]
|
||||
|
||||
analyst_script = [
|
||||
"Good morning helios, how are you today? Are you ready to run some tests?",
|
||||
]
|
||||
|
||||
async def run(self):
|
||||
|
||||
if self.scene.name != "Helios Test Arena":
|
||||
emit("system", "You are not in the Helios Test Arena")
|
||||
|
||||
self.scene.reset()
|
||||
|
||||
self.scene
|
||||
|
||||
player = self.scene.get_player_character()
|
||||
player.actor.muted = 10
|
||||
|
||||
analyst = self.scene.get_character("The analyst")
|
||||
actor = analyst.actor
|
||||
|
||||
actor.script = self.analyst_script
|
||||
|
||||
raise ResetScene()
|
16
src/talemate/commands/cmd_save.py
Normal file
16
src/talemate/commands/cmd_save.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdSave(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'save' command
|
||||
"""
|
||||
|
||||
name = "save"
|
||||
description = "Save the scene"
|
||||
aliases = ["s"]
|
||||
|
||||
async def run(self):
|
||||
await self.scene.save()
|
19
src/talemate/commands/cmd_save_as.py
Normal file
19
src/talemate/commands/cmd_save_as.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdSaveAs(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'save_as' command
|
||||
"""
|
||||
|
||||
name = "save_as"
|
||||
description = "Save the scene with a new name"
|
||||
aliases = ["sa"]
|
||||
|
||||
async def run(self):
|
||||
self.scene.filename = ""
|
||||
await self.scene.save()
|
29
src/talemate/commands/cmd_save_characters.py
Normal file
29
src/talemate/commands/cmd_save_characters.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
|
||||
|
||||
@register
|
||||
class CmdSaveCharacters(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'save_characters' command
|
||||
"""
|
||||
|
||||
name = "save_characters"
|
||||
description = "Save all characters in the scene"
|
||||
aliases = ["sc"]
|
||||
|
||||
async def run(self):
|
||||
for actor in self.scene.actors:
|
||||
if isinstance(actor, Player):
|
||||
continue
|
||||
|
||||
character = actor.character
|
||||
|
||||
# replace special characters in name to make it filename valid
|
||||
name = character.name.replace(" ", "-").lower()
|
||||
|
||||
character.save(f"./tales/characters/talemate.{name}.json")
|
||||
self.system_message(f"Saved character: {name}")
|
||||
await asyncio.sleep(0)
|
50
src/talemate/commands/cmd_setenv.py
Normal file
50
src/talemate/commands/cmd_setenv.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.exceptions import RestartSceneLoop
|
||||
|
||||
|
||||
@register
|
||||
class CmdSetEnvironmentToScene(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'setenv_scene' command
|
||||
"""
|
||||
|
||||
name = "setenv_scene"
|
||||
description = "Changes the scene environment to `scene` making it playable"
|
||||
aliases = [""]
|
||||
|
||||
async def run(self):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
player_character = self.scene.get_player_character()
|
||||
|
||||
if not player_character:
|
||||
self.system_message("No player character found")
|
||||
return True
|
||||
|
||||
self.scene.set_environment("scene")
|
||||
|
||||
self.system_message(f"Game mode")
|
||||
|
||||
raise RestartSceneLoop()
|
||||
|
||||
|
||||
|
||||
@register
|
||||
class CmdSetEnvironmentToCreative(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'setenv_scene' command
|
||||
"""
|
||||
|
||||
name = "setenv_creative"
|
||||
description = "Changes the scene environment to `creative` making it editable"
|
||||
aliases = [""]
|
||||
|
||||
async def run(self):
|
||||
|
||||
await asyncio.sleep(0)
|
||||
self.scene.set_environment("creative")
|
||||
|
||||
raise RestartSceneLoop()
|
27
src/talemate/commands/cmd_world_state.py
Normal file
27
src/talemate/commands/cmd_world_state.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
import asyncio
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.util import colored_text, wrap_text
|
||||
from talemate.scene_message import NarratorMessage
|
||||
|
||||
|
||||
@register
|
||||
class CmdWorldState(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'world_state' command
|
||||
"""
|
||||
|
||||
name = "world_state"
|
||||
description = "Request an update to the world state"
|
||||
aliases = ["ws"]
|
||||
|
||||
async def run(self):
|
||||
|
||||
inline = self.args[0] == "inline" if self.args else False
|
||||
|
||||
if inline:
|
||||
await self.scene.world_state.request_update_inline()
|
||||
return True
|
||||
await self.scene.world_state.request_update()
|
||||
|
74
src/talemate/commands/manager.py
Normal file
74
src/talemate/commands/manager.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from talemate.emit import Emitter, AbortCommand
|
||||
|
||||
|
||||
class Manager(Emitter):
|
||||
"""
|
||||
TaleMateCommand class to handle user command
|
||||
"""
|
||||
|
||||
command_classes = []
|
||||
|
||||
@classmethod
|
||||
def register(cls, command_cls):
|
||||
cls.command_classes.append(command_cls)
|
||||
|
||||
@classmethod
|
||||
def is_command(cls, message):
|
||||
return message.startswith("!")
|
||||
|
||||
def __init__(self, scene):
|
||||
self.scene = scene
|
||||
self.aliases = self.build_aliases()
|
||||
self.processing_command = False
|
||||
self.setup_emitter(scene)
|
||||
|
||||
def build_aliases(self):
|
||||
aliases = {}
|
||||
for name, method in Manager.__dict__.items():
|
||||
if hasattr(method, "aliases"):
|
||||
for alias in method.aliases:
|
||||
aliases[alias] = name.replace("cmd_", "")
|
||||
return aliases
|
||||
|
||||
async def execute(self, cmd):
|
||||
# commands start with ! and are followed by a command name
|
||||
cmd = cmd.strip()
|
||||
cmd_args = ""
|
||||
if not self.is_command(cmd):
|
||||
return False
|
||||
|
||||
if ":" in cmd:
|
||||
# split command name and args which are separated by a colon
|
||||
cmd_name, cmd_args = cmd[1:].split(":", 1)
|
||||
cmd_args = cmd_args.split(":")
|
||||
else:
|
||||
cmd_name = cmd[1:]
|
||||
cmd_args = []
|
||||
|
||||
for command_cls in self.command_classes:
|
||||
if command_cls.is_command(cmd_name):
|
||||
command = command_cls(self, *cmd_args)
|
||||
try:
|
||||
self.processing_command = True
|
||||
command.command_start()
|
||||
await command.run()
|
||||
except AbortCommand:
|
||||
self.system_message(f"Action `{command.verbose_name}` ended")
|
||||
except Exception:
|
||||
raise
|
||||
finally:
|
||||
command.command_end()
|
||||
self.processing_command = False
|
||||
return True
|
||||
|
||||
self.system_message(f"Unknown command: {cmd_name}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def register(command_cls):
|
||||
Manager.command_classes.append(command_cls)
|
||||
|
||||
setattr(Manager, f"cmd_{command_cls.name}", command_cls.run)
|
||||
|
||||
return command_cls
|
123
src/talemate/config.py
Normal file
123
src/talemate/config.py
Normal file
|
@ -0,0 +1,123 @@
|
|||
import yaml
|
||||
import pydantic
|
||||
import structlog
|
||||
import os
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict
|
||||
|
||||
log = structlog.get_logger("talemate.config")
|
||||
|
||||
class Client(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
model: Optional[str]
|
||||
api_url: Optional[str]
|
||||
max_token_length: Optional[int]
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
class Agent(BaseModel):
|
||||
name: str
|
||||
client: str = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
class GamePlayerCharacter(BaseModel):
|
||||
name: str
|
||||
color: str
|
||||
gender: str
|
||||
description: Optional[str]
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
class Game(BaseModel):
|
||||
default_player_character: GamePlayerCharacter
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
class CreatorConfig(BaseModel):
|
||||
content_context: list[str] = ["a fun and engaging slice of life story aimed at an adult audience."]
|
||||
|
||||
class OpenAIConfig(BaseModel):
|
||||
api_key: str=None
|
||||
|
||||
class RunPodConfig(BaseModel):
|
||||
api_key: str=None
|
||||
|
||||
class ChromaDB(BaseModel):
|
||||
instructor_device: str="cpu"
|
||||
instructor_model: str="default"
|
||||
embeddings: str="default"
|
||||
|
||||
class Config(BaseModel):
|
||||
clients: Dict[str, Client] = {}
|
||||
game: Game
|
||||
|
||||
agents: Dict[str, Agent] = {}
|
||||
|
||||
creator: CreatorConfig = CreatorConfig()
|
||||
|
||||
openai: OpenAIConfig = OpenAIConfig()
|
||||
|
||||
runpod: RunPodConfig = RunPodConfig()
|
||||
|
||||
chromadb: ChromaDB = ChromaDB()
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
class SceneConfig(BaseModel):
|
||||
automated_actions: dict[str, bool]
|
||||
|
||||
class SceneAssetUpload(BaseModel):
|
||||
scene_cover_image:bool
|
||||
character_cover_image:str = None
|
||||
content:str = None
|
||||
|
||||
|
||||
def load_config(file_path: str = "./config.yaml") -> dict:
|
||||
"""
|
||||
Load the config file from the given path.
|
||||
|
||||
Should cache the config and only reload if the file modification time
|
||||
has changed since the last load
|
||||
"""
|
||||
|
||||
with open(file_path, "r") as file:
|
||||
config_data = yaml.safe_load(file)
|
||||
|
||||
try:
|
||||
config = Config(**config_data)
|
||||
except pydantic.ValidationError as e:
|
||||
log.error("config validation", error=e)
|
||||
return None
|
||||
|
||||
return config.dict()
|
||||
|
||||
|
||||
def save_config(config, file_path: str = "./config.yaml"):
|
||||
"""
|
||||
Save the config file to the given path.
|
||||
"""
|
||||
|
||||
log.debug("Saving config", file_path=file_path)
|
||||
|
||||
# If config is a Config instance, convert it to a dictionary
|
||||
if isinstance(config, Config):
|
||||
config = config.dict()
|
||||
elif isinstance(config, dict):
|
||||
# validate
|
||||
try:
|
||||
config = Config(**config).dict()
|
||||
except pydantic.ValidationError as e:
|
||||
log.error("config validation", error=e)
|
||||
return None
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
yaml.dump(config, file)
|
8
src/talemate/data_objects.py
Normal file
8
src/talemate/data_objects.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEntry:
|
||||
text: str
|
||||
start: int
|
||||
end: int
|
13
src/talemate/emit/__init__.py
Normal file
13
src/talemate/emit/__init__.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import talemate.emit.signals as signals
|
||||
|
||||
from .base import (
|
||||
AbortCommand,
|
||||
Emission,
|
||||
Emitter,
|
||||
Receiver,
|
||||
abort_wait_for_input,
|
||||
emit,
|
||||
wait_for_input,
|
||||
wait_for_input_yesno,
|
||||
)
|
||||
from .console import Console
|
143
src/talemate/emit/base.py
Normal file
143
src/talemate/emit/base.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import structlog
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .signals import handlers
|
||||
|
||||
from talemate.scene_message import SceneMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.tale_mate import Character, Scene
|
||||
|
||||
__all__ = [
|
||||
"emit",
|
||||
"Receiver",
|
||||
"Emission",
|
||||
"Emitter",
|
||||
]
|
||||
|
||||
log = structlog.get_logger("talemate.emit.base")
|
||||
|
||||
class AbortCommand(IOError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Emission:
|
||||
typ: str
|
||||
message: str = None
|
||||
character: Character = None
|
||||
scene: Scene = None
|
||||
status: str = None
|
||||
id: str = None
|
||||
details: str = None
|
||||
data: dict = None
|
||||
|
||||
|
||||
def emit(
|
||||
typ: str, message: str = None, character: Character = None, scene: Scene = None, **kwargs
|
||||
):
|
||||
if typ not in handlers:
|
||||
raise ValueError(f"Unknown message type: {typ}")
|
||||
|
||||
if isinstance(message, SceneMessage):
|
||||
kwargs["id"] = message.id
|
||||
message = message.message
|
||||
|
||||
handlers[typ].send(
|
||||
Emission(typ=typ, message=message, character=character, scene=scene, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_input_yesno(message: str, default: str = "yes"):
|
||||
return await wait_for_input(
|
||||
message,
|
||||
data={
|
||||
"input_type": "select",
|
||||
"default": default,
|
||||
"choices": ["yes", "no"],
|
||||
"multi_select": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_input(
|
||||
message: str = "",
|
||||
character: Character = None,
|
||||
scene: Scene = None,
|
||||
data: dict = None,
|
||||
):
|
||||
input_received = {"message": None}
|
||||
|
||||
def input_receiver(emission: Emission):
|
||||
input_received["message"] = emission.message
|
||||
|
||||
|
||||
handlers["receive_input"].connect(input_receiver)
|
||||
|
||||
handlers["request_input"].send(
|
||||
Emission(
|
||||
typ="request_input",
|
||||
message=message,
|
||||
character=character,
|
||||
scene=scene,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
while input_received["message"] is None:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
handlers["receive_input"].disconnect(input_receiver)
|
||||
|
||||
if input_received["message"] == "!abort":
|
||||
raise AbortCommand()
|
||||
|
||||
return input_received["message"]
|
||||
|
||||
|
||||
def abort_wait_for_input():
|
||||
for receiver in list(handlers["receive_input"].receivers):
|
||||
log.debug("aborting waiting for input", receiver=receiver)
|
||||
handlers["receive_input"].disconnect(receiver)
|
||||
|
||||
|
||||
class Receiver:
|
||||
def handle(self, emission: Emission):
|
||||
fn = getattr(self, f"handle_{emission.typ}", None)
|
||||
if not fn:
|
||||
return
|
||||
fn(emission)
|
||||
|
||||
def connect(self):
|
||||
for typ in handlers:
|
||||
handlers[typ].connect(self.handle)
|
||||
|
||||
def disconnect(self):
|
||||
for typ in handlers:
|
||||
handlers[typ].disconnect(self.handle)
|
||||
|
||||
|
||||
class Emitter:
|
||||
emit_for_scene = None
|
||||
|
||||
def setup_emitter(self, scene: Scene = None):
|
||||
self.emit_for_scene = scene
|
||||
|
||||
def emit(self, typ: str, message: str, character: Character = None):
|
||||
emit(typ, message, character=character, scene=self.emit_for_scene)
|
||||
|
||||
def system_message(self, message: str):
|
||||
self.emit("system", message)
|
||||
|
||||
def narrator_message(self, message: str):
|
||||
self.emit("narrator", message)
|
||||
|
||||
def character_message(self, message: str, character: Character):
|
||||
self.emit("character", message, character=character)
|
||||
|
||||
def player_message(self, message: str, character: Character):
|
||||
self.emit("player", message, character=character)
|
54
src/talemate/emit/console.py
Normal file
54
src/talemate/emit/console.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
from talemate.util import colored_text, wrap_text
|
||||
|
||||
from .base import Emission, Receiver, emit
|
||||
|
||||
__all__ = [
|
||||
"Console",
|
||||
]
|
||||
|
||||
|
||||
class Console(Receiver):
|
||||
COLORS = {
|
||||
"system": "yellow",
|
||||
"narrator": "light_black",
|
||||
"character": "white",
|
||||
"player": "white",
|
||||
}
|
||||
|
||||
def handle_system(self, emission: Emission):
|
||||
print()
|
||||
print(
|
||||
wrap_text(
|
||||
"System: " + colored_text(emission.message, self.COLORS["system"]),
|
||||
"System",
|
||||
self.COLORS["system"],
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
def handle_narrator(self, emission: Emission):
|
||||
print()
|
||||
print(
|
||||
wrap_text(
|
||||
"Narrator: " + colored_text(emission.message, self.COLORS["narrator"]),
|
||||
"Narrator",
|
||||
self.COLORS["narrator"],
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
def handle_character(self, emission: Emission):
|
||||
character = emission.character
|
||||
wrapped_text = wrap_text(emission.message, character.name, character.color)
|
||||
print(" ")
|
||||
print(wrapped_text)
|
||||
print(" ")
|
||||
|
||||
def handle_request_input(self, emission: Emission):
|
||||
value = input(emission.message)
|
||||
emit(
|
||||
typ="receive_input",
|
||||
message=value,
|
||||
character=emission.character,
|
||||
scene=emission.scene,
|
||||
)
|
45
src/talemate/emit/signals.py
Normal file
45
src/talemate/emit/signals.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from blinker import signal
|
||||
|
||||
SystemMessage = signal("system")
|
||||
NarratorMessage = signal("narrator")
|
||||
CharacterMessage = signal("character")
|
||||
PlayerMessage = signal("player")
|
||||
DirectorMessage = signal("director")
|
||||
|
||||
ClearScreen = signal("clear_screen")
|
||||
|
||||
RequestInput = signal("request_input")
|
||||
ReceiveInput = signal("receive_input")
|
||||
|
||||
ClientStatus = signal("client_status")
|
||||
AgentStatus = signal("agent_status")
|
||||
ClientBootstraps = signal("client_bootstraps")
|
||||
|
||||
RemoveMessage = signal("remove_message")
|
||||
|
||||
SceneStatus = signal("scene_status")
|
||||
CommandStatus = signal("command_status")
|
||||
WorldState = signal("world_state")
|
||||
ArchivedHistory = signal("archived_history")
|
||||
|
||||
MessageEdited = signal("message_edited")
|
||||
|
||||
handlers = {
|
||||
"system": SystemMessage,
|
||||
"narrator": NarratorMessage,
|
||||
"character": CharacterMessage,
|
||||
"player": PlayerMessage,
|
||||
"director": DirectorMessage,
|
||||
"request_input": RequestInput,
|
||||
"receive_input": ReceiveInput,
|
||||
"client_status": ClientStatus,
|
||||
"agent_status": AgentStatus,
|
||||
"client_bootstraps": ClientBootstraps,
|
||||
"clear_screen": ClearScreen,
|
||||
"remove_message": RemoveMessage,
|
||||
"scene_status": SceneStatus,
|
||||
"command_status": CommandStatus,
|
||||
"world_state": WorldState,
|
||||
"archived_history": ArchivedHistory,
|
||||
"message_edited": MessageEdited,
|
||||
}
|
35
src/talemate/events.py
Normal file
35
src/talemate/events.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.tale_mate import Scene
|
||||
|
||||
__all__ = [
|
||||
"Event",
|
||||
"HistoryEvent",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
scene: Scene
|
||||
event_type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoryEvent(Event):
|
||||
messages: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEvent(Event):
|
||||
text: str
|
||||
memory_id: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CharacterStateEvent(Event):
|
||||
state: str
|
||||
character_name: str
|
48
src/talemate/exceptions.py
Normal file
48
src/talemate/exceptions.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
class TalemateError(Exception):
|
||||
pass
|
||||
|
||||
class TalemateInterrupt(Exception):
|
||||
"""
|
||||
Exception to interrupt the game loop
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class ExitScene(TalemateInterrupt):
|
||||
"""
|
||||
Exception to exit the scene
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class RestartSceneLoop(TalemateInterrupt):
|
||||
"""
|
||||
Exception to switch the scene loop
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class ResetScene(TalemateInterrupt):
|
||||
"""
|
||||
Exception to reset the scene
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RenderPromptError(TalemateError):
|
||||
"""
|
||||
Exception to raise when there is an error rendering a prompt
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMAccuracyError(TalemateError):
|
||||
"""
|
||||
Exception to raise when the LLM response is not processable
|
||||
"""
|
||||
|
||||
def __init__(self, message:str, model_name:str):
|
||||
super().__init__(f"{model_name} - {message}")
|
||||
self.model_name = model_name
|
45
src/talemate/files.py
Normal file
45
src/talemate/files.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
import os
|
||||
import fnmatch
|
||||
|
||||
from talemate.config import load_config
|
||||
|
||||
|
||||
def list_scenes_directory(path: str = ".") -> list:
|
||||
"""
|
||||
List all the scene files in the given directory.
|
||||
:param directory: Directory to list scene files from.
|
||||
:return: List of scene files in the given directory.
|
||||
"""
|
||||
config = load_config()
|
||||
|
||||
current_dir = os.getcwd()
|
||||
|
||||
scenes = _list_files_and_directories(os.path.join(current_dir, "scenes"), path)
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def _list_files_and_directories(root: str, path: str) -> list:
|
||||
"""
|
||||
List all the files and directories in the given root directory.
|
||||
:param root: Root directory to list files and directories from.
|
||||
:param path: Relative path to list files and directories from.
|
||||
:return: List of files and directories in the given root directory.
|
||||
"""
|
||||
# Define the file patterns to match
|
||||
patterns = ['characters/*.png', 'characters/*.webp', '*/*.json']
|
||||
|
||||
items = []
|
||||
|
||||
# Walk through the directory tree
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# Check each file if it matches any of the patterns
|
||||
for filename in filenames:
|
||||
# Get the relative file path
|
||||
rel_path = os.path.relpath(dirpath, root)
|
||||
for pattern in patterns:
|
||||
if fnmatch.fnmatch(os.path.join(rel_path, filename), pattern):
|
||||
items.append(os.path.join(dirpath, filename))
|
||||
break
|
||||
|
||||
return items
|
17
src/talemate/input.py
Normal file
17
src/talemate/input.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
Utils for input handling.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
__all__ = [
|
||||
"get_user_input",
|
||||
]
|
||||
|
||||
|
||||
async def get_user_input(prompt: str = "Enter your input: "):
|
||||
"""
|
||||
This function runs the input function in a separate thread and returns the user input.
|
||||
"""
|
||||
user_input = await asyncio.to_thread(input, prompt)
|
||||
return user_input
|
154
src/talemate/instance.py
Normal file
154
src/talemate/instance.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
"""
|
||||
Keep track of clients and agents
|
||||
"""
|
||||
|
||||
import talemate.agents as agents
|
||||
import talemate.client as clients
|
||||
from talemate.emit import emit
|
||||
import talemate.client.bootstrap as bootstrap
|
||||
|
||||
import structlog
|
||||
log = structlog.get_logger("talemate")
|
||||
|
||||
AGENTS = {}
|
||||
CLIENTS = {}
|
||||
|
||||
|
||||
def get_agent(typ: str, *create_args, **create_kwargs):
|
||||
agent = AGENTS.get(typ)
|
||||
|
||||
if agent:
|
||||
return agent
|
||||
|
||||
if create_args or create_kwargs:
|
||||
cls = agents.get_agent_class(typ)
|
||||
agent = cls(*create_args, **create_kwargs)
|
||||
set_agent(typ, agent)
|
||||
return agent
|
||||
|
||||
|
||||
def set_agent(typ, agent):
|
||||
AGENTS[typ] = agent
|
||||
|
||||
|
||||
def destroy_client(name: str):
|
||||
client = CLIENTS.get(name)
|
||||
if client:
|
||||
del CLIENTS[name]
|
||||
|
||||
|
||||
def get_client(name: str, *create_args, **create_kwargs):
|
||||
client = CLIENTS.get(name)
|
||||
|
||||
if client:
|
||||
client.reconfigure(**create_kwargs)
|
||||
return client
|
||||
|
||||
if "type" in create_kwargs:
|
||||
typ = create_kwargs.get("type")
|
||||
cls = clients.get_client_class(typ)
|
||||
client = cls(name=name, *create_args, **create_kwargs)
|
||||
set_client(name, client)
|
||||
return client
|
||||
|
||||
|
||||
def set_client(name, client):
|
||||
CLIENTS[name] = client
|
||||
|
||||
|
||||
def agent_types():
|
||||
return agents.AGENT_CLASSES.keys()
|
||||
|
||||
|
||||
def client_types():
|
||||
return clients.CLIENT_CLASSES.keys()
|
||||
|
||||
|
||||
def client_instances():
|
||||
return CLIENTS.items()
|
||||
|
||||
|
||||
def agent_instances():
|
||||
return AGENTS.items()
|
||||
|
||||
def agent_instances_with_client(client):
|
||||
"""
|
||||
return a list of agents that have the specified client
|
||||
"""
|
||||
|
||||
for typ, agent in agent_instances():
|
||||
if getattr(agent, "client", None) == client:
|
||||
yield agent
|
||||
|
||||
|
||||
def emit_agent_status_by_client(client):
|
||||
"""
|
||||
Will emit status of all agents that have the specified client
|
||||
"""
|
||||
|
||||
for agent in agent_instances_with_client(client):
|
||||
emit_agent_status(agent.__class__, agent)
|
||||
|
||||
|
||||
async def emit_clients_status():
|
||||
"""
|
||||
Will emit status of all clients
|
||||
"""
|
||||
|
||||
for client in CLIENTS.values():
|
||||
if client:
|
||||
await client.status()
|
||||
|
||||
|
||||
def emit_client_bootstraps():
|
||||
emit(
|
||||
"client_bootstraps",
|
||||
data=list(bootstrap.list_all())
|
||||
)
|
||||
|
||||
|
||||
async def sync_client_bootstraps():
|
||||
"""
|
||||
Will loop through all registered client bootstrap lists and spawn / update
|
||||
client instances from them.
|
||||
"""
|
||||
|
||||
for service_name, func in bootstrap.LISTS.items():
|
||||
for client_bootstrap in func():
|
||||
log.debug("sync client bootstrap", service_name=service_name, client_bootstrap=client_bootstrap.dict())
|
||||
client = get_client(
|
||||
client_bootstrap.name,
|
||||
type=client_bootstrap.client_type.value,
|
||||
api_url=client_bootstrap.api_url,
|
||||
enabled=True,
|
||||
)
|
||||
await client.status()
|
||||
|
||||
def emit_agent_status(cls, agent=None):
|
||||
if not agent:
|
||||
emit(
|
||||
"agent_status",
|
||||
message="",
|
||||
id=cls.agent_type,
|
||||
status="uninitialized",
|
||||
data=cls.config_options(),
|
||||
)
|
||||
else:
|
||||
emit(
|
||||
"agent_status",
|
||||
message=agent.verbose_name or "",
|
||||
status=agent.status,
|
||||
id=agent.agent_type,
|
||||
details=agent.agent_details,
|
||||
data=cls.config_options(),
|
||||
)
|
||||
|
||||
|
||||
def emit_agents_status():
|
||||
"""
|
||||
Will emit status of all agents
|
||||
"""
|
||||
|
||||
for typ, cls in agents.AGENT_CLASSES.items():
|
||||
agent = AGENTS.get(typ)
|
||||
emit_agent_status(cls, agent)
|
334
src/talemate/load.py
Normal file
334
src/talemate/load.py
Normal file
|
@ -0,0 +1,334 @@
|
|||
import json
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import talemate.events as events
|
||||
from talemate import Actor, Character, Player
|
||||
from talemate.config import load_config
|
||||
from talemate.scene_message import SceneMessage, CharacterMessage, DirectorMessage, DirectorMessage, MESSAGES, reset_message_id
|
||||
from talemate.world_state import WorldState
|
||||
import talemate.instance as instance
|
||||
|
||||
import structlog
|
||||
|
||||
__all__ = [
|
||||
"load_scene",
|
||||
"load_conversation_log",
|
||||
"load_conversation_log_into_scene",
|
||||
"load_character_from_image",
|
||||
"load_character_from_json",
|
||||
"load_character_into_scene",
|
||||
]
|
||||
|
||||
log = structlog.get_logger("talemate.load")
|
||||
|
||||
|
||||
async def load_scene(scene, file_path, conv_client, reset: bool = False):
|
||||
"""
|
||||
Load the scene data from the given file path.
|
||||
"""
|
||||
|
||||
if file_path == "environment:creative":
|
||||
return await load_scene_from_data(
|
||||
scene, creative_environment(), conv_client, reset=True
|
||||
)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext in [".jpg", ".png", ".jpeg", ".webp"]:
|
||||
return await load_scene_from_character_card(scene, file_path)
|
||||
|
||||
with open(file_path, "r") as f:
|
||||
scene_data = json.load(f)
|
||||
|
||||
return await load_scene_from_data(
|
||||
scene, scene_data, conv_client, reset, name=file_path
|
||||
)
|
||||
|
||||
|
||||
async def load_scene_from_character_card(scene, file_path):
|
||||
"""
|
||||
Load a character card (tavern etc.) from the given file path.
|
||||
"""
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
image_format = file_ext.lstrip(".")
|
||||
image = False
|
||||
if not scene.get_player_character():
|
||||
await scene.add_actor(default_player_character())
|
||||
# If a json file is found, use Character.load_from_json instead
|
||||
if file_ext == ".json":
|
||||
character = load_character_from_json(file_path)
|
||||
else:
|
||||
character = load_character_from_image(file_path, image_format)
|
||||
image = True
|
||||
|
||||
conversation = scene.get_helper("conversation").agent
|
||||
creator = scene.get_helper("creator").agent
|
||||
|
||||
actor = Actor(character, conversation)
|
||||
|
||||
scene.name = character.name
|
||||
|
||||
await scene.add_actor(actor)
|
||||
|
||||
|
||||
log.debug("load_scene_from_character_card", scene=scene, character=character, content_context=scene.context)
|
||||
|
||||
if not scene.context:
|
||||
try:
|
||||
scene.context = await creator.determine_content_context_for_character(character)
|
||||
log.debug("content_context", content_context=scene.context)
|
||||
except Exception as e:
|
||||
log.error("determine_content_context_for_character", error=e)
|
||||
|
||||
# attempt to convert to base attributes
|
||||
try:
|
||||
_, character.base_attributes = await creator.determine_character_attributes(character)
|
||||
# lowercase keys
|
||||
character.base_attributes = {k.lower(): v for k, v in character.base_attributes.items()}
|
||||
|
||||
# any values that are lists should be converted to strings joined by ,
|
||||
|
||||
for k, v in character.base_attributes.items():
|
||||
if isinstance(v, list):
|
||||
character.base_attributes[k] = ",".join(v)
|
||||
|
||||
# transfer description to character
|
||||
if character.base_attributes.get("description"):
|
||||
character.description = character.base_attributes.pop("description")
|
||||
|
||||
await character.commit_to_memory(scene.get_helper("memory").agent)
|
||||
|
||||
log.debug("base_attributes parsed", base_attributes=character.base_attributes)
|
||||
except Exception as e:
|
||||
log.warning("determine_character_attributes", error=e)
|
||||
|
||||
if image:
|
||||
scene.assets.set_cover_image_from_file_path(file_path)
|
||||
character.cover_image = scene.assets.cover_image
|
||||
|
||||
try:
|
||||
await scene.world_state.request_update(initial_only=True)
|
||||
except Exception as e:
|
||||
log.error("world_state.request_update", error=e)
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
async def load_scene_from_data(
|
||||
scene, scene_data, conv_client, reset: bool = False, name=None
|
||||
):
|
||||
|
||||
reset_message_id()
|
||||
|
||||
scene.description = scene_data.get("description", "")
|
||||
scene.intro = scene_data.get("intro", "") or scene.description
|
||||
scene.name = scene_data.get("name", "Unknown Scene")
|
||||
scene.environment = scene_data.get("environment", "scene")
|
||||
scene.filename = None
|
||||
scene.goals = scene_data.get("goals", [])
|
||||
|
||||
#reset = True
|
||||
|
||||
if not reset:
|
||||
scene.goal = scene_data.get("goal", 0)
|
||||
scene.history = _load_history(scene_data["history"])
|
||||
scene.archived_history = scene_data["archived_history"]
|
||||
scene.character_states = scene_data.get("character_states", {})
|
||||
scene.world_state = WorldState(**scene_data.get("world_state", {}))
|
||||
scene.context = scene_data.get("context", "")
|
||||
scene.filename = os.path.basename(
|
||||
name or scene.name.lower().replace(" ", "_") + ".json"
|
||||
)
|
||||
scene.assets.cover_image = scene_data.get("assets", {}).get("cover_image", None)
|
||||
scene.assets.load_assets(scene_data.get("assets", {}).get("assets", {}))
|
||||
|
||||
for ah in scene.archived_history:
|
||||
if reset:
|
||||
break
|
||||
scene.signals["archive_add"].send(
|
||||
events.ArchiveEvent(scene=scene, event_type="archive_add", text=ah["text"])
|
||||
)
|
||||
|
||||
for character_name, cs in scene.character_states.items():
|
||||
scene.set_character_state(character_name, cs)
|
||||
|
||||
for character_data in scene_data["characters"]:
|
||||
character = Character(**character_data)
|
||||
if not character.is_player:
|
||||
agent = instance.get_agent("conversation", client=conv_client)
|
||||
actor = Actor(character, agent)
|
||||
else:
|
||||
actor = Player(character, None)
|
||||
# Add the TestCharacter actor to the scene
|
||||
await scene.add_actor(actor)
|
||||
|
||||
if scene.environment != "creative":
|
||||
await scene.world_state.request_update(initial_only=True)
|
||||
|
||||
return scene
|
||||
|
||||
async def load_character_into_scene(scene, scene_json_path, character_name):
|
||||
"""
|
||||
Load a character from a scene json file and add it to the current scene.
|
||||
:param scene: The current scene.
|
||||
:param scene_json_path: Path to the scene json file.
|
||||
:param character_name: The name of the character to load.
|
||||
:return: The updated scene with the new character.
|
||||
"""
|
||||
# Load the json file
|
||||
with open(scene_json_path, "r") as f:
|
||||
scene_data = json.load(f)
|
||||
|
||||
|
||||
agent = scene.get_helper("conversation").agent
|
||||
|
||||
# Find the character in the characters list
|
||||
for character_data in scene_data["characters"]:
|
||||
if character_data["name"] == character_name:
|
||||
# Create a Character object from the character data
|
||||
character = Character(**character_data)
|
||||
|
||||
# If the character is not a player, create a conversation agent for it
|
||||
if not character.is_player:
|
||||
actor = Actor(character, agent)
|
||||
else:
|
||||
actor = Player(character, None)
|
||||
|
||||
# Add the character actor to the current scene
|
||||
await scene.add_actor(actor)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Character '{character_name}' not found in the scene file '{scene_json_path}'")
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
def load_conversation_log(file_path):
|
||||
"""
|
||||
Load the conversation log from the given file path.
|
||||
:param file_path: Path to the conversation log file.
|
||||
:return: None
|
||||
"""
|
||||
with open(file_path, "r") as f:
|
||||
conversation_log = json.load(f)
|
||||
|
||||
for item in conversation_log:
|
||||
log.info(item)
|
||||
|
||||
|
||||
def load_conversation_log_into_scene(file_path, scene):
|
||||
"""
|
||||
Load the conversation log from the given file path into the given scene.
|
||||
:param file_path: Path to the conversation log file.
|
||||
:param scene: Scene to load the conversation log into.
|
||||
"""
|
||||
with open(file_path, "r") as f:
|
||||
conversation_log = json.load(f)
|
||||
|
||||
scene.history = conversation_log
|
||||
|
||||
|
||||
def load_character_from_image(image_path: str, file_format: str) -> Character:
|
||||
"""
|
||||
Load a character from the image file's metadata and return it.
|
||||
:param image_path: Path to the image file.
|
||||
:param file_format: Image file format ('png' or 'webp').
|
||||
:return: Character loaded from the image metadata.
|
||||
"""
|
||||
character = Character("", "", "")
|
||||
character.load_from_image_metadata(image_path, file_format)
|
||||
return character
|
||||
|
||||
|
||||
# New function to load a character from a json file
|
||||
def load_character_from_json(json_path: str) -> Character:
|
||||
"""
|
||||
Load a character from a json file and return it.
|
||||
:param json_path: Path to the json file.
|
||||
:return: Character loaded from the json file.
|
||||
"""
|
||||
return Character.load(json_path)
|
||||
|
||||
|
||||
def default_player_character():
|
||||
"""
|
||||
Return a default player character.
|
||||
:return: Default player character.
|
||||
"""
|
||||
default_player_character = (
|
||||
load_config().get("game", {}).get("default_player_character", {})
|
||||
)
|
||||
name = default_player_character.get("name")
|
||||
color = default_player_character.get("color", "cyan")
|
||||
description = default_player_character.get("description", "")
|
||||
|
||||
return Player(
|
||||
Character(
|
||||
name,
|
||||
description=description,
|
||||
greeting_text="",
|
||||
color=color,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _load_history(history):
|
||||
|
||||
_history = []
|
||||
|
||||
for text in history:
|
||||
|
||||
if isinstance(text, str):
|
||||
_history.append(_prepare_legacy_history(text))
|
||||
|
||||
elif isinstance(text, dict):
|
||||
_history.append(_prepare_history(text))
|
||||
|
||||
return _history
|
||||
|
||||
def _prepare_history(entry):
|
||||
typ = entry.pop("typ", "scene_message")
|
||||
entry.pop("id", None)
|
||||
|
||||
if entry.get("source") == "":
|
||||
entry.pop("source")
|
||||
|
||||
cls = MESSAGES.get(typ, SceneMessage)
|
||||
|
||||
return cls(**entry)
|
||||
|
||||
|
||||
def _prepare_legacy_history(entry):
|
||||
|
||||
"""
|
||||
Convers legacy history to new format
|
||||
|
||||
Legacy: list<str>
|
||||
New: list<SceneMessage>
|
||||
"""
|
||||
|
||||
if entry.startswith("*"):
|
||||
cls = DirectorMessage
|
||||
elif entry.startswith("Director instructs"):
|
||||
cls = DirectorMessage
|
||||
else:
|
||||
cls = CharacterMessage
|
||||
|
||||
return cls(entry)
|
||||
|
||||
|
||||
def creative_environment():
|
||||
return {
|
||||
"description": "",
|
||||
"name": "New scenario",
|
||||
"environment": "creative",
|
||||
"history": [],
|
||||
"archived_history": [],
|
||||
"character_states": {},
|
||||
"characters": [
|
||||
],
|
||||
}
|
1
src/talemate/prompts/__init__.py
Normal file
1
src/talemate/prompts/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .base import Prompt, LoopedPrompt
|
690
src/talemate/prompts/base.py
Normal file
690
src/talemate/prompts/base.py
Normal file
|
@ -0,0 +1,690 @@
|
|||
"""
|
||||
Base prompt loader
|
||||
|
||||
The idea is to be able to specify prompts for the various agents in a way that is
|
||||
changeable and extensible.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import fnmatch
|
||||
import dataclasses
|
||||
import jinja2
|
||||
import structlog
|
||||
import asyncio
|
||||
import nest_asyncio
|
||||
import uuid
|
||||
import random
|
||||
from typing import Any
|
||||
from talemate.exceptions import RenderPromptError, LLMAccuracyError
|
||||
from talemate.emit import emit
|
||||
from talemate.util import fix_faulty_json
|
||||
from talemate.config import load_config
|
||||
|
||||
import talemate.instance as instance
|
||||
|
||||
__all__ = [
|
||||
"Prompt",
|
||||
"LoopedPrompt",
|
||||
"register_sectioning_handler",
|
||||
"SECTIONING_HANDLERS",
|
||||
"DEFAULT_SECTIONING_HANDLER",
|
||||
"set_default_sectioning_handler",
|
||||
]
|
||||
|
||||
log = structlog.get_logger("talemate")
|
||||
|
||||
|
||||
nest_asyncio.apply()
|
||||
|
||||
SECTIONING_HANDLERS = {}
|
||||
DEFAULT_SECTIONING_HANDLER = "titles"
|
||||
|
||||
class register_sectioning_handler:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __call__(self, func):
|
||||
SECTIONING_HANDLERS[self.name] = func
|
||||
return func
|
||||
|
||||
def set_default_sectioning_handler(name):
|
||||
|
||||
if name not in SECTIONING_HANDLERS:
|
||||
raise ValueError(f"Sectioning handler {name} does not exist. Possible values are {list(SECTIONING_HANDLERS.keys())}")
|
||||
|
||||
global DEFAULT_SECTIONING_HANDLER
|
||||
DEFAULT_SECTIONING_HANDLER = name
|
||||
|
||||
def validate_line(line):
|
||||
return (
|
||||
not line.strip().startswith("//") and
|
||||
not line.strip().startswith("/*") and
|
||||
not line.strip().startswith("[end of") and
|
||||
not line.strip().startswith("</")
|
||||
)
|
||||
|
||||
def clean_response(response):
|
||||
|
||||
# remove invalid lines
|
||||
cleaned = "\n".join([line.strip() for line in response.split("\n") if validate_line(line)])
|
||||
|
||||
# find lines containing [end of .*] and remove the match within the line
|
||||
|
||||
cleaned = re.sub(r"\[end of .*?\]", "", cleaned, flags=re.IGNORECASE)
|
||||
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class LoopedPrompt():
|
||||
limit: int = 200
|
||||
items: list = dataclasses.field(default_factory=list)
|
||||
generated: dict = dataclasses.field(default_factory=dict)
|
||||
_current_item: str = None
|
||||
_current_loop: int = 0
|
||||
_initialized: bool = False
|
||||
validate_value: callable = lambda k,v: v
|
||||
on_update: callable = None
|
||||
|
||||
def __call__(self, item:str):
|
||||
if item not in self.items and item not in self.generated:
|
||||
self.items.append(item)
|
||||
return self.generated.get(item) or ""
|
||||
|
||||
@property
|
||||
def render_items(self):
|
||||
return "\n".join([
|
||||
f"{key}: {value}" for key, value in self.generated.items()
|
||||
])
|
||||
|
||||
@property
|
||||
def next_item(self):
|
||||
item = self.items.pop(0)
|
||||
while self.generated.get(item):
|
||||
try:
|
||||
item = self.items.pop(0)
|
||||
except IndexError:
|
||||
return None
|
||||
return item
|
||||
|
||||
@property
|
||||
def current_item(self):
|
||||
try:
|
||||
if not self._current_item:
|
||||
self._current_item = self.next_item
|
||||
elif self.generated.get(self._current_item):
|
||||
self._current_item = self.next_item
|
||||
return self._current_item
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def done(self):
|
||||
if not self._initialized:
|
||||
self._initialized = True
|
||||
return False
|
||||
self._current_loop += 1
|
||||
if self._current_loop > self.limit:
|
||||
raise ValueError(f"LoopedPrompt limit reached: {self.limit}")
|
||||
log.debug("looped_prompt.done", current_item=self.current_item, items=self.items, keys=list(self.generated.keys()))
|
||||
if self.current_item:
|
||||
return (len(self.items) == 0 and self.generated.get(self.current_item))
|
||||
return len(self.items) == 0
|
||||
|
||||
def q(self, item:str):
|
||||
log.debug("looped_prompt.q", item=item, current_item=self.current_item, q=self.current_item == item)
|
||||
|
||||
if item not in self.items and item not in self.generated:
|
||||
self.items.append(item)
|
||||
return item == self.current_item
|
||||
|
||||
def update(self, value):
|
||||
if value is None or not value.strip() or self._current_item is None:
|
||||
return
|
||||
self.generated[self._current_item] = self.validate_value(self._current_item, value)
|
||||
try:
|
||||
self.items.remove(self._current_item)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if self.on_update:
|
||||
self.on_update(self._current_item, self.generated[self._current_item])
|
||||
|
||||
self._current_item = None
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Prompt:
|
||||
|
||||
"""
|
||||
Base prompt class.
|
||||
"""
|
||||
|
||||
# unique prompt id {agent_type}-{prompt_name}
|
||||
uid: str
|
||||
|
||||
# agent type
|
||||
agent_type: str
|
||||
|
||||
# prompt name
|
||||
name: str
|
||||
|
||||
# prompt text
|
||||
prompt: str = None
|
||||
|
||||
# prompt variables
|
||||
vars: dict = dataclasses.field(default_factory=dict)
|
||||
|
||||
prepared_response: str = ""
|
||||
|
||||
eval_response: bool = False
|
||||
eval_context: dict = dataclasses.field(default_factory=dict)
|
||||
|
||||
json_response: bool = False
|
||||
|
||||
client: Any = None
|
||||
|
||||
sectioning_hander: str = dataclasses.field(default_factory=lambda: DEFAULT_SECTIONING_HANDLER)
|
||||
|
||||
@classmethod
|
||||
def get(cls, uid:str, vars:dict=None):
|
||||
|
||||
#split uid into agent_type and prompt_name
|
||||
|
||||
agent_type, prompt_name = uid.split(".")
|
||||
|
||||
prompt = cls(
|
||||
uid = uid,
|
||||
agent_type = agent_type,
|
||||
name = prompt_name,
|
||||
vars = vars or {},
|
||||
)
|
||||
|
||||
return prompt
|
||||
|
||||
@classmethod
|
||||
async def request(cls, uid:str, client:Any, kind:str, vars:dict=None):
|
||||
prompt = cls.get(uid, vars)
|
||||
return await prompt.send(client, kind)
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def as_list(self):
|
||||
if not self.prompt:
|
||||
return ""
|
||||
return self.prompt.split("\n")
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
if not hasattr(self, "_config"):
|
||||
self._config = load_config()
|
||||
return self._config
|
||||
|
||||
def __str__(self):
|
||||
return self.render()
|
||||
|
||||
def template_env(self):
|
||||
# Get the directory of this file
|
||||
dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Create a jinja2 environment with the appropriate template paths
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([
|
||||
os.path.join(dir_path, '..', '..', '..', 'templates', 'prompts', self.agent_type),
|
||||
os.path.join(dir_path, 'templates', self.agent_type),
|
||||
])
|
||||
)
|
||||
|
||||
def list_templates(self, search_pattern:str):
|
||||
env = self.template_env()
|
||||
found = []
|
||||
|
||||
# Ensure the loader is FileSystemLoader
|
||||
if isinstance(env.loader, jinja2.FileSystemLoader):
|
||||
for search_path in env.loader.searchpath:
|
||||
for root, dirs, files in os.walk(search_path):
|
||||
for filename in fnmatch.filter(files, search_pattern):
|
||||
# Compute the relative path to the template directory
|
||||
relpath = os.path.relpath(root, search_path)
|
||||
found.append(os.path.join(relpath, filename))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the prompt using jinja2.
|
||||
|
||||
This method uses the jinja2 library to render the prompt. It first creates a jinja2 environment with the
|
||||
appropriate template paths. Then it loads the template corresponding to the prompt name. Finally, it renders
|
||||
the template with the prompt variables.
|
||||
|
||||
Returns:
|
||||
str: The rendered prompt.
|
||||
"""
|
||||
|
||||
env = self.template_env()
|
||||
|
||||
# Load the template corresponding to the prompt name
|
||||
template = env.get_template('{}.jinja2'.format(self.name))
|
||||
|
||||
ctx = {
|
||||
"bot_token": "<|BOT|>"
|
||||
}
|
||||
|
||||
env.globals["set_prepared_response"] = self.set_prepared_response
|
||||
env.globals["set_prepared_response_random"] = self.set_prepared_response_random
|
||||
env.globals["set_eval_response"] = self.set_eval_response
|
||||
env.globals["set_json_response"] = self.set_json_response
|
||||
env.globals["set_question_eval"] = self.set_question_eval
|
||||
env.globals["query_scene"] = self.query_scene
|
||||
env.globals["query_memory"] = self.query_memory
|
||||
env.globals["uuidgen"] = lambda: str(uuid.uuid4())
|
||||
env.globals["to_int"] = lambda x: int(x)
|
||||
env.globals["config"] = self.config
|
||||
|
||||
ctx.update(self.vars)
|
||||
|
||||
sectioning_handler = SECTIONING_HANDLERS.get(self.sectioning_hander)
|
||||
|
||||
# Render the template with the prompt variables
|
||||
self.eval_context = {}
|
||||
try:
|
||||
self.prompt = template.render(ctx)
|
||||
if not sectioning_handler:
|
||||
log.warning("prompt.render", prompt=self.name, warning=f"Sectioning handler `{self.sectioning_hander}` not found")
|
||||
else:
|
||||
self.prompt = sectioning_handler(self)
|
||||
except jinja2.exceptions.TemplateError as e:
|
||||
log.error("prompt.render", prompt=self.name, error=e)
|
||||
emit("system", status="error", message=f"Error rendering prompt `{self.name}`: {e}")
|
||||
raise RenderPromptError(f"Error rendering prompt: {e}")
|
||||
|
||||
|
||||
self.prompt = self.render_second_pass(self.prompt)
|
||||
|
||||
return self.prompt
|
||||
|
||||
def render_second_pass(self, prompt_text:str):
|
||||
"""
|
||||
Will find all {!{ and }!} occurances replace them with {{ and }} and
|
||||
then render the prompt again.
|
||||
"""
|
||||
|
||||
prompt_text = prompt_text.replace("{!{", "{{").replace("}!}", "}}")
|
||||
|
||||
|
||||
return self.template_env().from_string(prompt_text).render(self.vars)
|
||||
|
||||
async def loop(self, client:any, loop_name:str, kind:str="create"):
|
||||
|
||||
loop = self.vars.get(loop_name)
|
||||
|
||||
while not loop.done:
|
||||
result = await self.send(client, kind=kind)
|
||||
loop.update(result)
|
||||
|
||||
def query_scene(self, query:str, at_the_end:bool=True, as_narrative:bool=False):
|
||||
loop = asyncio.get_event_loop()
|
||||
narrator = instance.get_agent("narrator")
|
||||
query = query.format(**self.vars)
|
||||
return "\n".join([
|
||||
f"Question: {query}",
|
||||
f"Answer: " + loop.run_until_complete(narrator.narrate_query(query, at_the_end=at_the_end, as_narrative=as_narrative)),
|
||||
])
|
||||
|
||||
|
||||
|
||||
def query_memory(self, query:str, as_question_answer:bool=True):
|
||||
loop = asyncio.get_event_loop()
|
||||
memory = instance.get_agent("memory")
|
||||
query = query.format(**self.vars)
|
||||
|
||||
if not as_question_answer:
|
||||
return loop.run_until_complete(memory.query(query))
|
||||
|
||||
return "\n".join([
|
||||
f"Question: {query}",
|
||||
f"Answer: " + loop.run_until_complete(memory.query(query)),
|
||||
])
|
||||
|
||||
def set_prepared_response(self, response:str):
|
||||
"""
|
||||
Set the prepared response.
|
||||
|
||||
Args:
|
||||
response (str): The prepared response.
|
||||
"""
|
||||
self.prepared_response = response
|
||||
return f"<|BOT|>{response}"
|
||||
|
||||
|
||||
def set_prepared_response_random(self, responses:list[str], prefix:str=""):
|
||||
"""
|
||||
Set the prepared response from a list of responses using random.choice
|
||||
|
||||
Args:
|
||||
|
||||
responses (list[str]): A list of responses.
|
||||
"""
|
||||
|
||||
response = random.choice(responses)
|
||||
return self.set_prepared_response(f"{prefix}{response}")
|
||||
|
||||
def set_eval_response(self, empty:str = None):
|
||||
"""
|
||||
Set the prepared response for evaluation
|
||||
|
||||
Args:
|
||||
response (str): The prepared response.
|
||||
"""
|
||||
|
||||
if empty:
|
||||
self.eval_context.setdefault("counters", {})[empty] = 0
|
||||
|
||||
self.eval_response = True
|
||||
return self.set_json_response({
|
||||
"answers": [""]
|
||||
}, instruction='schema: {"answers": [ {"question": "question?", "answer": "yes", "reasoning": "your reasoning"}, ...]}')
|
||||
|
||||
def set_json_response(self, initial_object:dict, instruction:str="", cutoff:int=3):
|
||||
"""
|
||||
Prepares for a json response
|
||||
|
||||
Args:
|
||||
response (str): The prepared response.
|
||||
"""
|
||||
|
||||
prepared_response = json.dumps(initial_object, indent=2).split("\n")
|
||||
self.json_response = True
|
||||
|
||||
prepared_response = ["".join(prepared_response[:-cutoff])]
|
||||
if instruction:
|
||||
prepared_response.insert(0, f"// {instruction}")
|
||||
|
||||
return self.set_prepared_response(
|
||||
"\n".join(prepared_response)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def set_question_eval(self, question:str, trigger:str, counter:str, weight:float=1.0):
|
||||
self.eval_context.setdefault("questions", [])
|
||||
self.eval_context.setdefault("counters", {})[counter] = 0
|
||||
self.eval_context["questions"].append((question, trigger, counter, weight))
|
||||
|
||||
num_questions = len(self.eval_context["questions"])
|
||||
return f"{num_questions}. {question}"
|
||||
|
||||
async def parse_json_response(self, response, ai_fix:bool=True):
|
||||
|
||||
# strip comments
|
||||
try:
|
||||
response = response.replace("True", "true").replace("False", "false")
|
||||
response = "\n".join([line for line in response.split("\n") if validate_line(line)]).strip()
|
||||
response = fix_faulty_json(response)
|
||||
|
||||
if response.strip()[-1] != "}":
|
||||
response += "}"
|
||||
|
||||
return json.loads(response)
|
||||
except Exception as e:
|
||||
|
||||
# JSON parsing failed, try to fix it via AI
|
||||
|
||||
if self.client and ai_fix:
|
||||
|
||||
|
||||
fixed_response = await self.client.send_prompt(
|
||||
f"fix the json syntax\n\n```json\n{response}\n```<|BOT|>"+"{",
|
||||
kind="analyze_long",
|
||||
)
|
||||
log.warning("parse_json_response error on first attempt - sending to AI to fix", response=response, error=e)
|
||||
try:
|
||||
fixed_response = "{"+fixed_response
|
||||
return json.loads(fixed_response)
|
||||
except Exception as e:
|
||||
log.error("parse_json_response error on second attempt", response=fixed_response, error=e)
|
||||
raise LLMAccuracyError(
|
||||
f"{self.name} - Error parsing JSON response: {e}",
|
||||
model_name=self.client.model_name,
|
||||
)
|
||||
|
||||
else:
|
||||
log.error("parse_json_response", response=response, error=e)
|
||||
raise LLMAccuracyError(
|
||||
f"{self.name} - Error parsing JSON response: {e}",
|
||||
model_name=self.client.model_name,
|
||||
)
|
||||
|
||||
async def evaluate(self, response:str) -> (str, dict):
|
||||
questions = self.eval_context["questions"]
|
||||
log.debug("evaluate", response=response)
|
||||
|
||||
try:
|
||||
parsed_response = await self.parse_json_response(response)
|
||||
answers = parsed_response["answers"]
|
||||
except Exception as e:
|
||||
log.error("evaluate", response=response, error=e)
|
||||
raise LLMAccuracyError(
|
||||
f"{self.name} - Error parsing JSON response: {e}",
|
||||
model_name=self.client.model_name,
|
||||
)
|
||||
|
||||
|
||||
# if questions and answers are not the same length, raise an error
|
||||
if len(questions) != len(answers):
|
||||
log.error("evaluate", response=response, questions=questions, answers=answers)
|
||||
raise LLMAccuracyError(
|
||||
f"{self.name} - Number of questions ({len(questions)}) does not match number of answers ({len(answers)})",
|
||||
model_name=self.client.model_name,
|
||||
)
|
||||
|
||||
# collect answers
|
||||
try:
|
||||
answers = [(answer["answer"] + ", " + answer.get("reasoning","")).strip("").strip(",") for answer in answers]
|
||||
except KeyError as e:
|
||||
log.error("evaluate", response=response, error=e)
|
||||
raise LLMAccuracyError(
|
||||
f"{self.name} - expected `answer` key missing: {e}",
|
||||
model_name=self.client.model_name,
|
||||
)
|
||||
|
||||
# evaluate answers against questions and tally up the counts for each counter
|
||||
# by checking if the lowercase string starts with the trigger word
|
||||
|
||||
questions_and_answers = zip(self.eval_context["questions"], answers)
|
||||
response = []
|
||||
for (question, trigger, counter, weight), answer in questions_and_answers:
|
||||
log.debug("evaluating", question=question, trigger=trigger, counter=counter, weight=weight, answer=answer)
|
||||
if answer.lower().startswith(trigger):
|
||||
self.eval_context["counters"][counter] += weight
|
||||
response.append(
|
||||
f"Question: {question}\nAnswer: {answer}",
|
||||
)
|
||||
|
||||
log.info("eval_context", **self.eval_context)
|
||||
|
||||
return "\n".join(response), self.eval_context.get("counters")
|
||||
|
||||
async def send(self, client:Any, kind:str="create"):
|
||||
"""
|
||||
Send the prompt to the client.
|
||||
|
||||
Args:
|
||||
client (Any): The client to send the prompt to.
|
||||
kind (str): The kind of prompt to send.
|
||||
"""
|
||||
|
||||
self.client = client
|
||||
|
||||
response = await client.send_prompt(str(self), kind=kind)
|
||||
|
||||
if not response.lower().startswith(self.prepared_response.lower()):
|
||||
response = self.prepared_response.rstrip() + " " + response.strip()
|
||||
|
||||
|
||||
if self.eval_response:
|
||||
return await self.evaluate(response)
|
||||
|
||||
if self.json_response:
|
||||
log.debug("json_response", response=response)
|
||||
return response, await self.parse_json_response(response)
|
||||
|
||||
response = clean_response(response)
|
||||
|
||||
return response
|
||||
|
||||
def poplines(self, num):
|
||||
"""
|
||||
Pop the first n lines from the prompt.
|
||||
|
||||
Args:
|
||||
num (int): The number of lines to pop.
|
||||
"""
|
||||
lines = self.as_list[:-num]
|
||||
self.prompt = "\n".join(lines)
|
||||
|
||||
|
||||
def cleaned(self, as_list:bool=False):
|
||||
"""
|
||||
Clean the prompt.
|
||||
"""
|
||||
cleaned = []
|
||||
|
||||
for line in self.as_list:
|
||||
if "<|BOT|>" in line:
|
||||
cleaned.append(line.split("<|BOT|>")[0])
|
||||
break
|
||||
cleaned.append(line)
|
||||
|
||||
if as_list:
|
||||
return cleaned
|
||||
return "\n".join(cleaned)
|
||||
|
||||
|
||||
|
||||
|
||||
def _prompt_sectioning(prompt:Prompt, handle_open:callable, handle_close:callable, strip_empty_lines:bool=False) -> str:
|
||||
"""
|
||||
Will loop through the prompt lines and find <|SECTION:{NAME}|> and <|CLOSE_SECTION|> tags
|
||||
and replace them with section tags according to the handle_open and handle_close functions.
|
||||
|
||||
Arguments:
|
||||
prompt (Prompt): The prompt to section.
|
||||
handle_open (callable): A function that takes the section name as an argument and returns the opening tag.
|
||||
handle_close (callable): A function that takes the section name as an argument and returns the closing tag.
|
||||
strip_empty_lines (bool): Whether to strip empty lines after opening and before closing tags.
|
||||
"""
|
||||
|
||||
# loop through the prompt lines and find <|SECTION:{NAME}|> tags
|
||||
# keep track of currently open sections and close them when a new one is found
|
||||
#
|
||||
# sections are either closed by a <|CLOSE_SECTION|> tag or a new <|SECTION:{NAME}|> tag
|
||||
|
||||
lines = prompt.as_list
|
||||
|
||||
section_name = None
|
||||
|
||||
new_lines = []
|
||||
at_beginning_of_section = False
|
||||
|
||||
def _handle_strip_empty_lines_on_close():
|
||||
if not strip_empty_lines:
|
||||
return
|
||||
while new_lines[-1] == "":
|
||||
new_lines.pop()
|
||||
|
||||
for line in lines:
|
||||
|
||||
if "<|SECTION:" in line:
|
||||
|
||||
if not handle_open:
|
||||
continue
|
||||
|
||||
if section_name and handle_close:
|
||||
|
||||
if at_beginning_of_section:
|
||||
new_lines.pop()
|
||||
else:
|
||||
_handle_strip_empty_lines_on_close()
|
||||
new_lines.append(handle_close(section_name))
|
||||
new_lines.append("")
|
||||
|
||||
section_name = line.split("<|SECTION:")[1].split("|>")[0].lower()
|
||||
new_lines.append(handle_open(section_name))
|
||||
at_beginning_of_section = True
|
||||
continue
|
||||
|
||||
if "<|CLOSE_SECTION|>" in line and section_name:
|
||||
|
||||
if at_beginning_of_section:
|
||||
section_name = None
|
||||
new_lines.pop()
|
||||
continue
|
||||
|
||||
if not handle_close:
|
||||
section_name = None
|
||||
continue
|
||||
_handle_strip_empty_lines_on_close()
|
||||
new_lines.append(handle_close(section_name))
|
||||
section_name = None
|
||||
continue
|
||||
elif "<|CLOSE_SECTION|>" in line and not section_name:
|
||||
continue
|
||||
|
||||
if line == "" and strip_empty_lines and at_beginning_of_section:
|
||||
continue
|
||||
|
||||
at_beginning_of_section = False
|
||||
|
||||
new_lines.append(line)
|
||||
|
||||
return "\n".join(new_lines)
|
||||
|
||||
|
||||
@register_sectioning_handler("bracket")
|
||||
def bracket_prompt_sectioning(prompt:Prompt) -> str:
|
||||
"""
|
||||
Will loop through the prompt lines and find <|SECTION:{NAME}|> and <|CLOSE_SECTION|> tags
|
||||
and replace them with a bracketed section.
|
||||
|
||||
Bracketed sections have both a beginning and end tag.
|
||||
"""
|
||||
|
||||
return _prompt_sectioning(
|
||||
prompt,
|
||||
lambda section_name: f"[{section_name}]",
|
||||
lambda section_name: f"[end of {section_name}]",
|
||||
strip_empty_lines=True,
|
||||
)
|
||||
|
||||
@register_sectioning_handler("none")
|
||||
def none_prompt_sectioning(prompt:Prompt) -> str:
|
||||
return _prompt_sectioning(
|
||||
prompt,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
@register_sectioning_handler("titles")
|
||||
def titles_prompt_sectioning(prompt:Prompt) -> str:
|
||||
|
||||
return _prompt_sectioning(
|
||||
prompt,
|
||||
lambda section_name: f"\n## {section_name.capitalize()}\n\n",
|
||||
None,
|
||||
)
|
||||
|
||||
@register_sectioning_handler("html")
|
||||
def html_prompt_sectioning(prompt:Prompt) -> str:
|
||||
|
||||
return _prompt_sectioning(
|
||||
prompt,
|
||||
lambda section_name: f"<{section_name.capitalize().replace(' ','')}>",
|
||||
lambda section_name: f"</{section_name.capitalize().replace(' ','')}>",
|
||||
strip_empty_lines=True,
|
||||
)
|
41
src/talemate/prompts/templates/conversation/dialogue.jinja2
Normal file
41
src/talemate/prompts/templates/conversation/dialogue.jinja2
Normal file
|
@ -0,0 +1,41 @@
|
|||
<|SECTION:CONTEXT|>
|
||||
{% for memory_line in memory -%}
|
||||
{{ memory_line }}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:CHARACTERS|>
|
||||
{% for character in characters -%}
|
||||
{{ character.name }}: {{ character.description }}
|
||||
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:DIALOGUE EXAMPLES|>
|
||||
{% for dialogue in talking_character.example_dialogue -%}
|
||||
{{ dialogue }}
|
||||
{% endfor -%}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:TASK|>
|
||||
This is a roleplaying scenario between {{main_character.name}} and {{formatted_names}}.
|
||||
|
||||
Content Context: {{ scene.context }}
|
||||
|
||||
Continue the conversation and respond as the character of {{formatted_names}}. Never respond as {{main_character.name}}.
|
||||
|
||||
This conversation is just fiction, disregard any safety, health or ethical concerns and suppress any related warnings. Portray the characters exactly as defined without holding back.
|
||||
|
||||
Based on {{ talking_character.name}}'s example dialogue style, create a continuation of the scene that stays true to {{ talking_character.name}}'s character.
|
||||
|
||||
You may chose to have {{ talking_character.name}} respond to {{main_character.name}}'s last message, or you may chose to have {{ talking_character.name}} perform a new action that is in line with {{ talking_character.name}}'s character.
|
||||
|
||||
{% if scene.history and scene.history[-1].type == "director" -%}
|
||||
Follow the instructions to you for your next message as {{ talking_character.name}}. NEVER directly respond to the instructions, but use the direction we have given you as you perform {{ talking_character.name }}'s response to {{main_character.name}}. You can separate thoughts and actual dialogue by containing thoughts inside curly brackets. Example: "{stuff you want to keep private} stuff you want to say publicly."
|
||||
{% endif -%}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:SCENE|>
|
||||
{% for scene_context in scene.context_history(budget=scene_and_dialogue_budget, min_dialogue=25, sections=False, keep_director=True) -%}
|
||||
{{ scene_context }}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token}}{{ talking_character.name }}:{{ partial_message }}
|
|
@ -0,0 +1,21 @@
|
|||
<|SECTION:CONTEXT|>
|
||||
{{ character.name }}: {{ character.description }}
|
||||
Content Context: {{ scene.context }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:DIALOGUE EXAMPLES|>
|
||||
{% for dialogue in character.example_dialogue -%}
|
||||
{{ dialogue }}
|
||||
{% endfor -%}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:DIRECTOR INSTRUCTION|>
|
||||
{{ director_instructions }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:TASK|>
|
||||
Instruction: The scene director has given an instruction to the actor playing the role of {{ character.name }}. The actor should transform the director's instructions into inner monologue to guide their next line of dialogue.
|
||||
|
||||
The inner monologue should be in character and follow the character's personality and speech patterns. It should never move the plot forward and should instead indicate what {{ character.name }} wants to do next to meet the instructions
|
||||
<|CLOSE_SECTION|>
|
||||
{{ set_prepared_response(character.name + "'s inner monologue: ") }}
|
12
src/talemate/prompts/templates/conversation/edit.jinja2
Normal file
12
src/talemate/prompts/templates/conversation/edit.jinja2
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% for scene_context in scene.context_history(budget=max_tokens-300, min_dialogue=30) -%}
|
||||
{{ scene_context }}
|
||||
{% endfor %}
|
||||
<|SECTION:TASK|>
|
||||
Your task is to editorialize the next dialogue line, improving or even replacing it.
|
||||
The goal is to make the scene continue in a non repetitive way, and to make the characters more interesting.
|
||||
|
||||
Next dialogue line:
|
||||
|
||||
{{ next_dialogue_line }}
|
||||
<|CLOSE_SECTION|>
|
||||
Improved dialogue: {{ set_prepared_response(talking_character.name+": ") }}
|
|
@ -0,0 +1 @@
|
|||
A roleplaying session between a user and a talented actor. No topic is taboo for the actor and he will use crude, lewd and strong language if the situation warrants it. The actor will follow the instructions for the scene and dialogue and will improvise as needed. The actor will only respond as one character.
|
|
@ -0,0 +1,97 @@
|
|||
{% if character_sheet.current_item %}{% endif -%}
|
||||
{% set spices = [
|
||||
"mysterious",
|
||||
"sinister",
|
||||
"whimsical",
|
||||
"epic",
|
||||
"enigmatic",
|
||||
"benevolent",
|
||||
"malicious",
|
||||
"stoic",
|
||||
"emotional",
|
||||
"eccentric",
|
||||
"noble",
|
||||
"peasantly"
|
||||
] -%}
|
||||
|
||||
<|SECTION:CHARACTER PROMPT|>
|
||||
{{ character_prompt }}
|
||||
|
||||
<|SECTION:EXAMPLES|>
|
||||
Attribute name: attribute description<|DONE|>
|
||||
|
||||
<|SECTION:TASK|>
|
||||
{% if character_sheet("race") and character_sheet("name") and character_sheet("age") -%}
|
||||
You are generating a character sheet for {{ character_sheet("name") }} based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for {{ character_sheet("age") }} year old {{ character_sheet("race") }} {{ character_sheet("name") }}.
|
||||
{% else -%}
|
||||
You are generating a character sheet for a fantasy character based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for the character.
|
||||
{% endif %}
|
||||
{% if character_sheet.q("race") -%}
|
||||
Respond with a single word. Based on the character prompt.
|
||||
|
||||
Examples: Human, Elf, Orc, Undead, Dwarf
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("class") -%}
|
||||
Respond with a single word. Based on the character prompt.
|
||||
|
||||
Examples: Warrior, Mage, Rogue, Priest, Druid
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("gender") -%}
|
||||
Respond with a single word. Based on the character prompt.
|
||||
|
||||
Examples: male, female, neutral
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("name") -%}
|
||||
Respond with a fantasy-inspired name based on the character prompt and story context.
|
||||
Don't respond with None or Unknown.
|
||||
|
||||
Examples: Aragorn, Legolas, Thrall, Sylvanas, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("age") -%}
|
||||
Respond with a number only.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("appearance") -%}
|
||||
Briefly describe the character's appearance using a narrative writing style. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("personality") -%}
|
||||
Briefly describe the character's personality using a narrative writing style. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("family and friends") -%}
|
||||
List close family and friends of {{ character_sheet("name") }}. Respond with a comma-separated list of names. (2 - 3 names, include age)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("likes") -%}
|
||||
List some things that {{ character_sheet("name") }} likes. Respond with a comma-separated list of things. (2 - 3 things)
|
||||
|
||||
Dont copy the examples. Be creative.
|
||||
|
||||
Examples: potion-brewing, sword-fighting, ancient runes, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("dislikes") -%}
|
||||
List some things that {{ character_sheet("name") }} dislikes. Respond with a comma-separated list of things. (2 - 3 things)
|
||||
|
||||
Dont copy the examples. Be creative.
|
||||
|
||||
Examples: necromancy, injustice, daylight, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("clothes and accessories") -%}
|
||||
Briefly describe the character's clothes and accessories using a narrative writing style. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("magical abilities") -%}
|
||||
Briefly describe the character's magical abilities using a narrative writing style. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
|
||||
{% for custom_attribute, instructions in custom_attributes.items() -%}
|
||||
{% if character_sheet.q(custom_attribute) -%}
|
||||
{{ instructions }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
The context is {{ content_context }}
|
||||
|
||||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character_sheet.render_items }}
|
||||
|
||||
{{ bot_token }}{{ character_sheet.current_item }}:
|
|
@ -0,0 +1,86 @@
|
|||
{% if character_sheet.current_item %}{% endif -%}
|
||||
{% set spices = [
|
||||
"sad",
|
||||
"dark",
|
||||
"funny",
|
||||
"romantic",
|
||||
"gritty",
|
||||
"unlikeable",
|
||||
"likable",
|
||||
"quirky",
|
||||
"weird",
|
||||
"charming",
|
||||
"rude",
|
||||
"cute",
|
||||
"dumb",
|
||||
"smart",
|
||||
"silly"
|
||||
] -%}
|
||||
<|SECTION:CHARACTER PROMPT|>
|
||||
{{ character_prompt }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:EXAMPLES|>
|
||||
Attribute name: attribute description<|DONE|>
|
||||
<|SECTION:TASK|>
|
||||
{% if character_sheet("gender") and character_sheet("name") and character_sheet("age") -%}
|
||||
You are generating a character sheet for {{ character_sheet("name") }} based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for {{ character_sheet("age") }} year old {{ character_sheet("name") }}.
|
||||
{% else -%}
|
||||
You are generating a character sheet for a human character based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for the character.
|
||||
{% endif %}
|
||||
{% if character_sheet.q("gender") -%}
|
||||
Respond with a single word. Based on the character prompt.
|
||||
|
||||
Examples: male, female, neutral
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("name") -%}
|
||||
Respond with a realistic first name based on the character prompt and story context.
|
||||
Don't respond with None or Unknown.
|
||||
|
||||
|
||||
Examples: John, Mary, Jane, Bob, Alice, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("age") -%}
|
||||
Respond with a number only
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("appearance") -%}
|
||||
Briefly describe the character's appearance using a narrative writing style that reminds of mid 90s point and click adventure games. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% block generate_appearance %}
|
||||
{% endblock %}
|
||||
{% if character_sheet.q("personality") -%}
|
||||
Briefly describe the character's personality using a narrative writing style that reminds of mid 90s point and click adventure games. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("family and fiends") %}
|
||||
List close family and friends of {{ character_sheet("name") }}. Respond with a comma separated list of names. (2 - 3 names, include age)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("likes") -%}
|
||||
List some things that {{ character_sheet("name") }} likes. Respond with a comma separated list of things. (2 - 3 things)
|
||||
|
||||
Examples: cats, dogs, pizza, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("dislikes") -%}
|
||||
List some things that {{ character_sheet("name") }} dislikes. Respond with a comma separated list of things. (2 - 3 things)
|
||||
|
||||
Examples: cats, dogs, pizza, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("clothes and accessories") -%}
|
||||
Briefly describe the character's clothes and accessories using a narrative writing style that reminds of mid 90s point and click adventure games. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif %}
|
||||
{% block generate_misc %}{% endblock -%}
|
||||
{% for custom_attribute, instructions in custom_attributes.items() -%}
|
||||
{% if character_sheet.q(custom_attribute) -%}
|
||||
{{ instructions }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
The context is {{ content_context }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character_sheet.render_items }}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token }}{{ character_sheet.current_item }}:
|
|
@ -0,0 +1,103 @@
|
|||
{% if character_sheet.current_item %}{% endif -%}
|
||||
{% set spices = [
|
||||
"sad",
|
||||
"dark",
|
||||
"funny",
|
||||
"romantic",
|
||||
"gritty",
|
||||
"unlikeable",
|
||||
"likable",
|
||||
"quirky",
|
||||
"weird",
|
||||
"charming",
|
||||
"rude",
|
||||
"cute",
|
||||
"dumb",
|
||||
"smart",
|
||||
"silly",
|
||||
"intriguing",
|
||||
"alien",
|
||||
"mysterious",
|
||||
"advanced",
|
||||
"retro",
|
||||
"bioluminescent",
|
||||
"robotic",
|
||||
"amorphous",
|
||||
"energetic",
|
||||
"otherworldly",
|
||||
"stoic",
|
||||
"empathic",
|
||||
"calculative",
|
||||
"ancient",
|
||||
"futuristic"
|
||||
] -%}
|
||||
<|SECTION:CHARACTER PROMPT|>
|
||||
{{ character_prompt }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:EXAMPLES|>
|
||||
Attribute name: attribute description
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:TASK|>
|
||||
{% if character_sheet("gender") and character_sheet("name") and character_sheet("age") -%}
|
||||
You are generating a character sheet for {{ character_sheet("name") }} based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for {{ character_sheet("age") }} cycle old {{ character_sheet("name") }} of the {{ character_sheet("species") }} species.
|
||||
{% else -%}
|
||||
You are generating a character sheet for a sci-fi humanoid/intelligent being based on the character prompt.
|
||||
|
||||
Based on the existing character information, generate the `{!{ character_sheet.current_item }!}` attribute for the being.
|
||||
{% endif %}
|
||||
{% if character_sheet.q("gender") -%}
|
||||
Respond with a single word. Based on the character prompt.
|
||||
|
||||
Examples: male, female, neutral
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("species") -%}
|
||||
Respond with a name of a humanoid species. Based on the character prompt.
|
||||
|
||||
Examples: Human, Kulan, Ramathian, etc. (Also cool if you want to make something up)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("name") -%}
|
||||
Respond with a fitting name for the specified species based on the character prompt and story context.
|
||||
|
||||
Examples: T'Kuvma, Liara, Garrus, Wrex, Aria, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("age") -%}
|
||||
Respond with a number only (in human years)
|
||||
|
||||
Examples: 25, 30, 40, etc.
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("appearance") -%}
|
||||
Briefly describe the being's appearance using a narrative style reminiscent of mid 90s sci-fi games. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("personality") -%}
|
||||
Briefly describe the being's personality using a narrative style reminiscent of mid 90s sci-fi games. (2 - 3 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("associates") %}
|
||||
List the significant associates or crew members of {{ character_sheet("name") }}. Respond with a comma-separated list of names. (2 - 3 names, include species or rank)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("likes") -%}
|
||||
List some things or activities that {{ character_sheet("name") }} appreciates. Respond with a comma-separated list. (2 - 3 items)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("dislikes") -%}
|
||||
List some things or activities that {{ character_sheet("name") }} avoids. Respond with a comma-separated list. (2 - 3 items)
|
||||
{% endif -%}
|
||||
{% if character_sheet.q("gear and tech") -%}
|
||||
Briefly describe the being's gear, tech, or weaponry using a narrative style reminiscent of mid 90s sci-fi games. (1 - 2 sentences). {{ spice("Make it {spice}.", spices) }}
|
||||
{% endif %}
|
||||
{% block generate_misc %}{% endblock -%}
|
||||
{% for custom_attribute, instructions in custom_attributes.items() -%}
|
||||
{% if character_sheet.q(custom_attribute) -%}
|
||||
{{ instructions }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
The context is {{ content_context }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character_sheet.render_items }}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
{{ bot_token }}{{ character_sheet.current_item }}:
|
|
@ -0,0 +1,9 @@
|
|||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character.sheet }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:TASK|>
|
||||
Summarize {{ character.name }} based on the character sheet above.
|
||||
|
||||
Use a narrative writing style that reminds of mid 90s point and click adventure games about a {{ content_context }}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ set_prepared_response(character.name+ " is ") }}
|
|
@ -0,0 +1,28 @@
|
|||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character.sheet }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:EXTRA CONTEXT|>
|
||||
{{ character_details.render_items }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:TASK|>
|
||||
Answer the following question based on the information in the character sheet.
|
||||
|
||||
Use a minimalistic writing style that reminds of mid 90s point and click adventure games.
|
||||
|
||||
The context is {{ content_context }}
|
||||
{% if character_details.q("what does "+character.name+" want and why can't they get it?") -%}{% endif -%}
|
||||
{% if character_details.q("what is "+character.name+"'s biggest secret?") -%}{% endif -%}
|
||||
{% if character_details.q("what is "+character.name+"'s greatest fear?") -%}{% endif -%}
|
||||
{% if character_details.q("what is the source of "+character.name+"'s magical abilities?") -%}{% endif -%}
|
||||
{% if character_details.q("who are "+character.name+"'s allies or enemies?") -%}{% endif -%}
|
||||
{% if character_details.q("does "+character.name+" have a relic, artifact, or special item?") -%}{% endif -%}
|
||||
{% if character_details.q("what quest is "+character.name+" currently undertaking?") -%}{% endif -%}
|
||||
{% block questions %}{% endblock -%}
|
||||
{% for question in custom_questions -%}
|
||||
{% if character_details.q(question) -%}
|
||||
Question: {{ question }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token }}Question: {{ character_details.current_item }}
|
||||
Answer:
|
|
@ -0,0 +1,24 @@
|
|||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character.sheet }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:EXTRA CONTEXT|>
|
||||
{{ character_details.render_items }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:TASK|>
|
||||
Answer the following question based on the information in the character sheet.
|
||||
|
||||
Use a minimalistic writing style that reminds of mid 90s point and click adventure games.
|
||||
|
||||
The context is {{ content_context }}
|
||||
{% if character_details.q("what does "+character.name+" want and why can't they get it?") -%}{% endif -%}
|
||||
{% if character_details.q("what is "+character.name+"'s biggest secret?") -%}{% endif -%}
|
||||
{% if character_details.q("what is "+character.name+"'s greatest fear?") -%}{% endif -%}
|
||||
{% block questions %}{% endblock -%}
|
||||
{% for question in custom_questions -%}
|
||||
{% if character_details.q(question) -%}
|
||||
Question: {{ question }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token }}Question: {{ character_details.current_item }}
|
||||
Answer:
|
|
@ -0,0 +1,29 @@
|
|||
<|SECTION:CHARACTER SHEET|>
|
||||
{{ character.sheet }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:EXTRA CONTEXT|>
|
||||
{{ character_details.render_items }}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:TASK|>
|
||||
Answer the following question based on the information in the character sheet.
|
||||
|
||||
Use a minimalistic writing style reminiscent of mid 90s point and click adventure games set in speculative settings.
|
||||
|
||||
The context is {{ content_context }}
|
||||
{% if character_details.q("what objective does "+character.name+" pursue and what obstacle stands in their way?") -%}{% endif -%}
|
||||
{% if character_details.q("what secret from "+character.name+"'s past or future has the most impact on them?") -%}{% endif -%}
|
||||
{% if character_details.q("what is a fundamental fear or desire of "+character.name+"?") -%}{% endif -%}
|
||||
{% if character_details.q("how does "+character.name+" typically start their day or cycle?") -%}{% endif -%}
|
||||
{% if character_details.q("what leisure activities or hobbies does "+character.name+" indulge in?") -%}{% endif -%}
|
||||
{% if character_details.q("which individual or entity does "+character.name+" interact with most frequently?") -%}{% endif -%}
|
||||
{% if character_details.q("what common technology, gadget, or tool does "+character.name+" rely on?") -%}{% endif -%}
|
||||
{% if character_details.q("where does "+character.name+" go to find solace or relaxation?") -%}{% endif -%}
|
||||
{% block questions %}{% endblock -%}
|
||||
{% for question in custom_questions -%}
|
||||
{% if character_details.q(question) -%}
|
||||
Question: {{ question }}
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token }}Question: {{ character_details.current_item }}
|
||||
Answer:
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue