mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 11:53:56 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,17 +1,84 @@
|
||||
# luci-app-hermes — OpenWrt LuCI plugin for Hermes Agent (Python AI Gateway)
|
||||
# Dual-version: supports luci 18.06 (Lua CBI) + luci 24.10+ (JS view)
|
||||
# Works as feeds source or standalone in package/ directory
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_VERSION:=1.0.0-r20240101
|
||||
PKG_RELEASE:=2
|
||||
PKG_NAME:=luci-app-hermes
|
||||
PKG_VERSION:=2026.05.10
|
||||
PKG_RELEASE:=3
|
||||
|
||||
LUCI_TITLE:=LuCI support for Hermes
|
||||
PKG_MAINTAINER:=Hermes Agent
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
LUCI_TITLE:=Hermes AI Agent Gateway
|
||||
LUCI_DEPENDS:=+luci-compat +luci-base +python3 +python3-pip
|
||||
LUCI_PKGARCH:=all
|
||||
LUCI_DEPENDS:=+docker +dockerd +luci-lib-taskd
|
||||
|
||||
define Package/luci-app-hermes/conffiles
|
||||
# Prefer feeds/luci/luci.mk (handles install + i18n automatically)
|
||||
LUCI_MK:=$(firstword $(wildcard $(TOPDIR)/feeds/luci/luci.mk))
|
||||
|
||||
ifneq ($(LUCI_MK),)
|
||||
include $(LUCI_MK)
|
||||
else
|
||||
# Standalone mode: no luci feed available
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/$(PKG_NAME)
|
||||
SECTION:=luci
|
||||
CATEGORY:=LuCI
|
||||
SUBMENU:=3. Applications
|
||||
TITLE:=$(LUCI_TITLE)
|
||||
DEPENDS:=$(LUCI_DEPENDS)
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/description
|
||||
Hermes Agent AI Gateway LuCI management plugin.
|
||||
Python-based AI agent with web UI, gateway, and terminal.
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/install
|
||||
# JS view (luci 24.10+)
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/hermes.js \
|
||||
$(1)/www/luci-static/resources/view/hermes.js
|
||||
# Lua compat (luci 18.06)
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
|
||||
$(INSTALL_DATA) ./luasrc/controller/hermes.lua \
|
||||
$(1)/usr/lib/lua/luci/controller/hermes.lua
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi
|
||||
$(INSTALL_DATA) ./luasrc/model/cbi/hermes.lua \
|
||||
$(1)/usr/lib/lua/luci/model/cbi/hermes.lua
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/hermes
|
||||
$(INSTALL_DATA) ./luasrc/view/hermes/status.htm \
|
||||
$(1)/usr/lib/lua/luci/view/hermes/status.htm
|
||||
$(INSTALL_DATA) ./luasrc/view/hermes/console.htm \
|
||||
$(1)/usr/lib/lua/luci/view/hermes/console.htm
|
||||
$(INSTALL_DATA) ./luasrc/view/hermes/terminal.htm \
|
||||
$(1)/usr/lib/lua/luci/view/hermes/terminal.htm
|
||||
# root overlay
|
||||
$(CP) ./root/* $(1)/
|
||||
# fix permissions
|
||||
chmod 755 $(1)/etc/init.d/hermes 2>/dev/null || true
|
||||
chmod 755 $(1)/etc/uci-defaults/99-hermes 2>/dev/null || true
|
||||
chmod 755 $(1)/usr/bin/hermes-env 2>/dev/null || true
|
||||
chmod 755 $(1)/usr/share/hermes/luci-helper 2>/dev/null || true
|
||||
chmod 755 $(1)/usr/share/hermes/web-pty.py 2>/dev/null || true
|
||||
endef
|
||||
endif
|
||||
|
||||
define Package/$(PKG_NAME)/conffiles
|
||||
/etc/config/hermes
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
define Package/$(PKG_NAME)/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
( . /etc/uci-defaults/99-hermes ) && rm -f /etc/uci-defaults/99-hermes
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
endef
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
$(eval $(call BuildPackage,$(PKG_NAME)))
|
||||
@@ -0,0 +1,167 @@
|
||||
# luci-app-hermes
|
||||
|
||||
OpenWrt LuCI 插件,为 [Hermes Agent](https://github.com/nousresearch/hermes-agent) 提供 Web 管理界面。
|
||||
|
||||
[](https://github.com/Boos4721/luci-app-hermes/releases)
|
||||
[](https://github.com/Boos4721/luci-app-hermes/actions/workflows/build-ipk.yml)
|
||||
[](LICENSE)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **一键安装**:自动下载 Python3 + 创建虚拟环境 + 安装 Hermes Agent,适配 OpenWrt/ImmortalWrt
|
||||
- **Web 管理界面**:概况、设置、Web 控制台、配置终端 全中文 UI
|
||||
- **Web 控制台**:内嵌 Hermes Agent Gateway Web UI,支持完整 AI 交互
|
||||
- **配置终端**:内嵌 WebSocket PTY,支持交互式配置管理
|
||||
- **资源自适应**:根据设备内存自动限制 Python 进程内存,防止 OOM
|
||||
- **磁盘空间优化**:安装前预检空间,PyPI 多镜像重试(阿里云 → 清华 → PyPI 官方)
|
||||
- **BusyBox 兼容**:完整适配 OpenWrt BusyBox 环境
|
||||
- **OpenWrt 安全**:LuCI 鉴权保护,Token 认证机制
|
||||
- **服务管理**:支持 Gateway 和 PTY 独立启停、自动重启
|
||||
|
||||
## 支持架构
|
||||
|
||||
| 架构 | 说明 |
|
||||
|------|------|
|
||||
| all | 架构无关包,Python 运行时通过 opkg 自动安装 |
|
||||
|
||||
支持所有 OpenWrt/ImmortalWrt 设备(x86_64、aarch64、armv7l 等)。
|
||||
|
||||
---
|
||||
|
||||
## 安装(推荐)
|
||||
|
||||
在 OpenWrt 路由器上执行一条命令即可完成安装:
|
||||
|
||||
```sh
|
||||
opkg update && opkg install luci-app-hermes
|
||||
```
|
||||
|
||||
或者使用一键脚本(从 GitHub 直接安装):
|
||||
|
||||
```sh
|
||||
sh -c "$(wget -qO- https://cdn.jsdelivr.net/gh/Boos4721/luci-app-hermes@main/scripts/install.sh)"
|
||||
```
|
||||
|
||||
> 脚本会自动下载最新版 `.ipk`,通过 `opkg` 安装,并清除 LuCI 缓存。
|
||||
|
||||
---
|
||||
|
||||
### 方式二:手动下载 IPK
|
||||
|
||||
从 [Releases](https://github.com/Boos4721/luci-app-hermes/releases) 页面下载最新版:
|
||||
|
||||
```sh
|
||||
wget -O /tmp/luci-app-hermes.ipk \
|
||||
https://github.com/Boos4721/luci-app-hermes/releases/latest/download/luci-app-hermes_*_all.ipk
|
||||
opkg install --force-reinstall /tmp/luci-app-hermes.ipk
|
||||
```
|
||||
|
||||
### 方式三:作为 OpenWrt feeds
|
||||
|
||||
```bash
|
||||
# 在 feeds.conf.default 中添加
|
||||
src-git hermes https://github.com/Boos4721/luci-app-hermes.git
|
||||
|
||||
# 更新并安装
|
||||
./scripts/feeds update hermes
|
||||
./scripts/feeds install luci-app-hermes
|
||||
make package/luci-app-hermes/compile V=s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安装后配置
|
||||
|
||||
1. 打开 LuCI → **服务** → **Hermes Agent**
|
||||
2. 点击 **更多** → **安装环境**,等待 Python3 + Hermes Agent 安装完成(约 5~15 分钟)
|
||||
3. 启用服务,在 **设置** 中配置 API Endpoint 和 API Key
|
||||
4. 在 **Web 控制台** 中与 AI 模型交互
|
||||
|
||||
## 磁盘空间不足解决方案
|
||||
|
||||
OpenWrt 设备 root 分区通常只有 1GB 以下,Python + Hermes Agent 安装包约 500MB。
|
||||
|
||||
**解决方案:bind mount tmpfs**
|
||||
|
||||
```bash
|
||||
# 在 /etc/rc.local 中添加(开机自动执行)
|
||||
mkdir -p /tmp/hermes
|
||||
mount --bind /tmp/hermes /opt/hermes
|
||||
```
|
||||
|
||||
> `/tmp` 通常挂载为 tmpfs,大小约为物理内存的 50%,重启后数据丢失,需重新安装
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
luci-app-hermes/
|
||||
├── .github/workflows/
|
||||
│ └── build-ipk.yml # 自动编译 IPK,推送 tag 时触发
|
||||
├── htdocs/
|
||||
│ └── luci-static/resources/view/
|
||||
│ └── hermes.js # LuCI JS 视图(主 UI)
|
||||
├── luasrc/
|
||||
│ ├── controller/hermes.lua # LuCI 路由控制器
|
||||
│ ├── model/cbi/hermes.lua # CBI 配置模型(兼容旧版 LuCI)
|
||||
│ └── view/hermes/ # Lua 视图模板
|
||||
├── root/
|
||||
│ ├── etc/
|
||||
│ │ ├── config/hermes # UCI 默认配置
|
||||
│ │ ├── init.d/hermes # procd 服务脚本
|
||||
│ │ └── uci-defaults/ # 首次安装初始化
|
||||
│ └── usr/
|
||||
│ ├── bin/hermes-env # 安装/管理脚本(核心)
|
||||
│ └── share/hermes/
|
||||
│ ├── luci-helper # LuCI RPC 辅助脚本
|
||||
│ ├── web-pty.py # WebSocket PTY 服务器
|
||||
│ └── ui/ # Web 控制台 UI
|
||||
├── po/zh_Hans/hermes.po # 中文翻译
|
||||
├── scripts/
|
||||
│ └── install.sh # 一键安装脚本
|
||||
├── Makefile
|
||||
├── VERSION
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 安装时提示 python3 找不到**
|
||||
A: 插件依赖 `python3` 和 `python3-pip`,确保 OpenWrt 软件源已更新(`opkg update`)。
|
||||
|
||||
**Q: Hermes Agent 安装失败,pip 超时**
|
||||
A: hermes-env 已内置三镜像重试(阿里云 → 清华 → PyPI 官方)。如仍失败,检查路由器 DNS 和网络连接,或手动执行 `hermes-env setup` 查看详细日志。
|
||||
|
||||
**Q: 安装完成后 Gateway 不启动**
|
||||
A: 检查磁盘空间:`df -h /opt`,若已满需配置 bind mount 到 tmpfs。同时确认 Python 虚拟环境已正确安装:`hermes-env check`。
|
||||
|
||||
**Q: 内存不足设备如何使用**
|
||||
A: 插件自动根据内存设置进程限制(512MB RAM → 限制 256MB),无需手动配置。
|
||||
|
||||
**Q: Web 控制台无法访问**
|
||||
A: 确保服务已启用并在 **设置** 中配置了正确的 API Endpoint 和 API Key。Gateway 默认端口 3000,PTY 端口 3001。
|
||||
|
||||
**Q: 配置终端无法输入**
|
||||
A: 确保点击终端区域使其获得焦点,然后输入命令并按 Enter。
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| 2026.05.10 | 2026-05-10 | 初始发布,支持 LuCI 24/25,Python-based 架构 |
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Hermes Agent](https://github.com/nousresearch/hermes-agent) — AI Agent 核心
|
||||
- [ImmortalWrt](https://immortalwrt.org) — 主要测试平台
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 © [Boos4721](https://github.com/Boos4721)
|
||||
@@ -0,0 +1 @@
|
||||
2026.05.10
|
||||
@@ -0,0 +1,686 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require fs';
|
||||
'require poll';
|
||||
|
||||
var HELPER = '/usr/share/hermes/luci-helper';
|
||||
|
||||
function callHelper(args) {
|
||||
return L.resolveDefault(fs.exec_direct(HELPER, args), '').then(function(res) {
|
||||
try { return JSON.parse(String(res).trim()); }
|
||||
catch(e) { return {}; }
|
||||
});
|
||||
}
|
||||
|
||||
function fmtMem(kb) {
|
||||
kb = parseInt(kb) || 0;
|
||||
if (kb <= 0) return '-';
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB';
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(1) + ' MB';
|
||||
return kb + ' KB';
|
||||
}
|
||||
|
||||
/* ── Logo ── */
|
||||
var LOGO_SVG = '<img src="/luci-static/hermes/logo.svg" width="44" height="44" style="vertical-align:middle;border-radius:8px" onerror="this.style.display=\'none\'">';
|
||||
|
||||
/* ── CSS: inherit page theme ── */
|
||||
var CSS = '\
|
||||
*,*::before,*::after{box-sizing:border-box}\
|
||||
#hm-app{margin:0;padding:0;width:100%;color:inherit}\
|
||||
.hm-header{padding:6px 0 4px;display:flex;align-items:center;gap:10px}\
|
||||
.hm-header h2{margin:0;font-size:20px;font-weight:600;color:inherit}\
|
||||
.hm-header .sub{font-size:11px;opacity:.5;margin-top:2px;letter-spacing:.3px}\
|
||||
.hm-header>*,.hm-header h2,.hm-header .sub{background:transparent!important;box-shadow:none!important;border:none!important;border-radius:0!important;padding:0!important;margin:0!important}\
|
||||
.hm-tabs{display:flex;border-bottom:2px solid currentColor;border-bottom-color:rgba(128,128,128,.25);overflow:hidden}\
|
||||
.hm-tab{padding:10px 18px;font-size:13px;font-weight:500;opacity:.55;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .2s;white-space:nowrap;user-select:none;color:inherit}\
|
||||
.hm-tab:hover{opacity:.85}\
|
||||
.hm-tab.active{opacity:1;border-bottom-color:var(--primary,#5e72e4)}\
|
||||
.hm-panel{display:none;padding:20px 0}\
|
||||
.hm-panel.active{display:block}\
|
||||
.hm-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}\
|
||||
.hm-card{border:1px solid rgba(128,128,128,.2);border-radius:10px;padding:14px 16px}\
|
||||
.hm-card .lbl{font-size:11px;opacity:.55;letter-spacing:.3px}\
|
||||
.hm-card .val{font-size:22px;font-weight:700;margin-top:4px;line-height:1.2}\
|
||||
.hm-card .val.sm{font-size:15px;font-weight:600}\
|
||||
.hm-badge{display:inline-block;padding:3px 14px;border-radius:20px;font-size:12px;font-weight:600}\
|
||||
.hm-badge-run{background:#e8f5e9;color:#2e7d32}\
|
||||
.hm-badge-stop{background:#ffebee;color:#c62828}\
|
||||
.hm-badge-start{background:#fff8e1;color:#f57f17}\
|
||||
.hm-badge-off{opacity:.5}\
|
||||
.hm-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}\
|
||||
.hm-dot-g{background:#2e7d32}.hm-dot-r{background:#c62828}.hm-dot-y{background:#f57f17}.hm-dot-x{background:#9e9e9e}\
|
||||
.hm-info{border:1px solid rgba(128,128,128,.2);border-radius:10px;padding:0;overflow:hidden;margin-bottom:20px}\
|
||||
.hm-info-title{padding:12px 18px;font-size:13px;font-weight:600;opacity:.7;border-bottom:1px solid rgba(128,128,128,.15)}\
|
||||
.hm-info table{width:100%;border-collapse:collapse}\
|
||||
.hm-info tr,.hm-info tr:nth-of-type(2n){background:transparent!important}\
|
||||
.hm-info td{padding:8px 16px;font-size:13px;border-bottom:1px solid rgba(128,128,128,.1)}\
|
||||
.hm-info tr:last-child td{border-bottom:none}\
|
||||
.hm-info td:first-child{width:100px;opacity:.6;font-weight:500}\
|
||||
.hm-info td:last-child{word-break:break-all}\
|
||||
.hm-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:16px}\
|
||||
.hm-btn{padding:8px 16px;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}\
|
||||
.hm-btn:hover{filter:brightness(.93)}\
|
||||
.hm-btn:active{transform:scale(.97)}\
|
||||
.hm-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;filter:none}\
|
||||
.hm-btn-p{background:var(--primary,#5e72e4);color:#fff}\
|
||||
.hm-btn-s{background:#1b5e20;color:#fff;box-shadow:0 2px 6px rgba(27,94,32,.35)}\
|
||||
.hm-btn-s:hover{background:#2e7d32}\
|
||||
.hm-btn-d{background:#37474f;color:#fff;box-shadow:0 2px 6px rgba(55,71,79,.3)}\
|
||||
.hm-btn-d:hover{background:#546e7a}\
|
||||
.hm-btn-r{background:#1565c0;color:#fff;box-shadow:0 2px 6px rgba(21,101,192,.35)}\
|
||||
.hm-btn-r:hover{background:#1976d2}\
|
||||
.hm-btn-g{border:1px solid rgba(128,128,128,.3);color:inherit;background:transparent}\
|
||||
.hm-btn-icon{padding:6px 10px;font-size:16px;line-height:1;min-width:unset}\
|
||||
.hm-log-wrap{margin-top:16px;display:none}\
|
||||
.hm-log-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}\
|
||||
.hm-log-hdr span{font-weight:600;font-size:13px;opacity:.7}\
|
||||
.hm-log-st{font-size:12px}\
|
||||
.hm-log{background:#1e1e2e;color:#cdd6f4;padding:14px 16px;border-radius:8px;font-family:Consolas,Monaco,"Courier New",monospace;font-size:12px;line-height:1.6;max-height:380px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;border:1px solid #313244}\
|
||||
.hm-log-result{margin-top:10px;padding:12px 16px;border-radius:8px;font-size:13px}\
|
||||
.hm-log-ok{background:#e8f5e9;border:1px solid #c8e6c9;color:#2e7d32}\
|
||||
.hm-log-fail{background:#ffebee;border:1px solid #ffcdd2;color:#c62828}\
|
||||
.hm-form{border:1px solid rgba(128,128,128,.2);border-radius:10px;overflow:hidden;margin-bottom:20px}\
|
||||
.hm-form-title{padding:12px 18px;font-size:13px;font-weight:600;opacity:.7;border-bottom:1px solid rgba(128,128,128,.15)}\
|
||||
.hm-form-body{padding:6px 18px}\
|
||||
.hm-form-row{display:flex;align-items:center;padding:12px 0;border-bottom:1px solid rgba(128,128,128,.1)}\
|
||||
.hm-form-row:last-child{border-bottom:none}\
|
||||
.hm-form-lbl{width:120px;font-size:13px;font-weight:500;opacity:.7;flex-shrink:0}\
|
||||
.hm-form-ctl{flex:1;min-width:0}\
|
||||
.hm-form-ctl input,.hm-form-ctl select{padding:7px 10px;border:1px solid rgba(128,128,128,.3);border-radius:6px;font-size:13px;width:100%;max-width:260px;outline:none;transition:border-color .2s;background:transparent;color:inherit}\
|
||||
.hm-form-ctl input:focus,.hm-form-ctl select:focus{border-color:var(--primary,#5e72e4)}\
|
||||
.hm-form-hint{font-size:11px;opacity:.5;margin-top:3px}\
|
||||
.hm-iframe-wrap{border:2px solid rgba(128,128,128,.2);border-radius:10px;overflow:hidden;margin-top:10px}\
|
||||
.hm-iframe-wrap iframe{width:100%;height:650px;border:none;display:block}\
|
||||
.hm-iframe-msg{padding:48px;text-align:center;opacity:.55;font-size:14px;line-height:1.8}\
|
||||
.hm-iframe-msg .icon{font-size:36px;margin-bottom:12px}\
|
||||
.hm-switch{position:relative;display:inline-block;width:44px;height:24px}\
|
||||
.hm-switch input{opacity:0;width:0;height:0}\
|
||||
.hm-switch .slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:rgba(128,128,128,.35);border-radius:24px;transition:.3s}\
|
||||
.hm-switch .slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s}\
|
||||
.hm-switch input:checked+.slider{background:var(--primary,#5e72e4)}\
|
||||
.hm-switch input:checked+.slider:before{transform:translateX(20px)}\
|
||||
.hm-more-wrap{position:relative;display:inline-block}\
|
||||
.hm-more-menu{position:fixed;border:1px solid rgba(128,128,128,.2);border-radius:10px;box-shadow:0 -4px 20px rgba(0,0,0,.15);min-width:190px;z-index:9999;display:none;overflow:hidden;padding:4px 0;background:var(--background-color-low,#f8f8f8)}\
|
||||
.hm-more-item{display:flex;align-items:center;gap:8px;padding:10px 16px;font-size:13px;cursor:pointer;border:none;background:none;width:100%;text-align:left;transition:background .12s;color:inherit}\
|
||||
.hm-more-item:hover{background:rgba(128,128,128,.1)}\
|
||||
.hm-more-item.danger{color:inherit;background:none}\
|
||||
.hm-more-item.danger:hover{background:rgba(128,128,128,.1)}\
|
||||
.hm-more-sep{height:1px;background:rgba(128,128,128,.15);margin:4px 0}\
|
||||
@media(max-width:768px){\
|
||||
#hm-app{padding:0 10px;overflow-x:hidden}\
|
||||
.hm-tabs{max-width:100%}\
|
||||
.hm-header{padding:8px 0;gap:10px}\
|
||||
.hm-header h2{font-size:16px}\
|
||||
.hm-cards{grid-template-columns:repeat(2,1fr);gap:10px}\
|
||||
.hm-card .val{font-size:17px}\
|
||||
.hm-card .val.sm{font-size:13px}\
|
||||
.hm-actions{gap:6px;flex-wrap:wrap;max-width:100%}\
|
||||
.hm-actions>*{flex:0 1 auto;min-width:0}\
|
||||
.hm-actions .hm-btn{padding:7px 10px;font-size:12px;white-space:nowrap;justify-content:center}\
|
||||
.hm-form-row{flex-direction:column;align-items:stretch;gap:4px}\
|
||||
.hm-form-lbl{width:auto}\
|
||||
.hm-form-ctl input,.hm-form-ctl select{max-width:100%}\
|
||||
.hm-iframe-wrap iframe{height:400px}\
|
||||
.hm-info td:first-child{width:80px}\
|
||||
}\
|
||||
@media(max-width:420px){\
|
||||
.hm-header{padding:6px 0}\
|
||||
.hm-header h2{font-size:15px}\
|
||||
.hm-tab{padding:9px 14px;font-size:12px}\
|
||||
.hm-btn{padding:6px 10px;font-size:12px}\
|
||||
.hm-actions .hm-btn{padding:6px 8px;font-size:11px}\
|
||||
.hm-info td{padding:7px 12px;font-size:12px}\
|
||||
.hm-cards{gap:8px}\
|
||||
.hm-card{padding:10px 12px}\
|
||||
.hm-card .val{font-size:16px}\
|
||||
.hm-iframe-wrap iframe{height:350px}\
|
||||
}\
|
||||
';
|
||||
|
||||
return view.extend({
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('hermes'),
|
||||
callHelper(['status'])
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var st = data[1] || {};
|
||||
this._st = st;
|
||||
this._setupTimer = null;
|
||||
this._tabEls = {};
|
||||
|
||||
var contentEl = E('div', { 'id': 'hm-content' }, [
|
||||
this._overview(st),
|
||||
this._settings(),
|
||||
this._console(st),
|
||||
this._terminal(st)
|
||||
]);
|
||||
this._contentEl = contentEl;
|
||||
|
||||
var app = E('div', { 'id': 'hm-app' }, [
|
||||
E('style', {}, [CSS]),
|
||||
this._header(),
|
||||
this._tabBar(),
|
||||
contentEl
|
||||
]);
|
||||
|
||||
this._switchTab('overview');
|
||||
poll.add(L.bind(this._poll, this), 5);
|
||||
|
||||
document.addEventListener('click', function() {
|
||||
var m = document.getElementById('hm-more-menu');
|
||||
if (m) m.style.display = 'none';
|
||||
});
|
||||
|
||||
return app;
|
||||
},
|
||||
|
||||
/* ═══ Header ═══ */
|
||||
_header: function() {
|
||||
var h = E('div', { 'class': 'hm-header' });
|
||||
h.innerHTML = LOGO_SVG +
|
||||
'<div><h2>Hermes Agent</h2>' +
|
||||
'<div class="sub">' + _('Python AI Agent Gateway for OpenWrt') + '</div></div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
/* ═══ Tabs ═══ */
|
||||
_tabBar: function() {
|
||||
var self = this;
|
||||
var tabs = [
|
||||
['overview', _('Overview')],
|
||||
['settings', _('Settings')],
|
||||
['console', _('Web Console')],
|
||||
['terminal', _('Config Terminal')]
|
||||
];
|
||||
var bar = E('div', { 'class': 'hm-tabs' });
|
||||
tabs.forEach(function(t) {
|
||||
var el = E('div', {
|
||||
'class': 'hm-tab',
|
||||
'data-tab': t[0],
|
||||
'click': function() { self._switchTab(t[0]); }
|
||||
}, [t[1]]);
|
||||
self._tabEls[t[0]] = el;
|
||||
bar.appendChild(el);
|
||||
});
|
||||
return bar;
|
||||
},
|
||||
|
||||
_switchTab: function(id) {
|
||||
var self = this;
|
||||
Object.keys(this._tabEls).forEach(function(k) {
|
||||
self._tabEls[k].classList.toggle('active', k === id);
|
||||
});
|
||||
var root = this._contentEl || document.getElementById('hm-content');
|
||||
if (root) {
|
||||
var panels = root.querySelectorAll('.hm-panel');
|
||||
if (panels) panels.forEach(function(p) {
|
||||
p.classList.toggle('active', p.getAttribute('data-panel') === id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/* ═══ Overview Panel ═══ */
|
||||
_overview: function(st) {
|
||||
return E('div', { 'class': 'hm-panel', 'data-panel': 'overview' }, [
|
||||
/* Status Cards */
|
||||
E('div', { 'class': 'hm-cards' }, [
|
||||
this._card('status', _('Service Status'), this._badge(st)),
|
||||
this._card('port', _('Gateway Port'), st.port || '3000', true),
|
||||
this._card('memory', _('Memory'), fmtMem(st.memory_kb), true),
|
||||
this._card('uptime', _('Uptime'), st.uptime || '-', true)
|
||||
]),
|
||||
/* Version Info */
|
||||
this._infoTable(st),
|
||||
/* Action Buttons */
|
||||
this._actionBtns(),
|
||||
/* Log Viewer */
|
||||
this._logViewer()
|
||||
]);
|
||||
},
|
||||
|
||||
_card: function(id, label, valueHtml, small) {
|
||||
var c = E('div', { 'class': 'hm-card' }, [
|
||||
E('div', { 'class': 'lbl' }, [label]),
|
||||
E('div', { 'class': 'val' + (small ? ' sm' : ''), 'id': 'hm-c-' + id })
|
||||
]);
|
||||
if (typeof valueHtml === 'string' && valueHtml.indexOf('<') >= 0)
|
||||
c.querySelector('.val').innerHTML = valueHtml;
|
||||
else
|
||||
c.querySelector('.val').textContent = valueHtml || '-';
|
||||
return c;
|
||||
},
|
||||
|
||||
_badge: function(st) {
|
||||
if (!st || !st.enabled) return '<span class="hm-badge hm-badge-off">' + _('Unknown') + '</span>';
|
||||
if (st.enabled !== '1') return '<span class="hm-badge hm-badge-off"><span class="hm-dot hm-dot-x"></span>' + _('Disabled') + '</span>';
|
||||
if (st.gateway_running) return '<span class="hm-badge hm-badge-run"><span class="hm-dot hm-dot-g"></span>' + _('Running') + '</span>';
|
||||
if (st.gateway_starting) return '<span class="hm-badge hm-badge-start"><span class="hm-dot hm-dot-y"></span>' + _('Starting') + '</span>';
|
||||
return '<span class="hm-badge hm-badge-stop"><span class="hm-dot hm-dot-r"></span>' + _('Stopped') + '</span>';
|
||||
},
|
||||
|
||||
_infoTable: function(st) {
|
||||
var rows = [
|
||||
[_('Python'), 'hm-i-python', st.python_version || _('Not installed')],
|
||||
[_('Hermes'), 'hm-i-hermes', st.hermes_version || _('Not installed')],
|
||||
[_('Plugin'), 'hm-i-plugin', st.plugin_version || '-'],
|
||||
[_('Model'), 'hm-i-model', st.active_model || '-'],
|
||||
[_('PID'), 'hm-i-pid', st.pid || '-'],
|
||||
[_('Config PTY'), 'hm-i-pty', st.pty_running ? '✅ ' + _('Running') + ' (:' + (st.pty_port || '3001') + ')' : '⏹ ' + _('Stopped')]
|
||||
];
|
||||
var tbody = E('tbody');
|
||||
rows.forEach(function(r) {
|
||||
tbody.appendChild(E('tr', {}, [
|
||||
E('td', {}, [r[0]]),
|
||||
E('td', { 'id': r[1] }, [r[2]])
|
||||
]));
|
||||
});
|
||||
return E('div', { 'class': 'hm-info' }, [
|
||||
E('div', { 'class': 'hm-info-title' }, [_('System Information')]),
|
||||
E('table', {}, [tbody])
|
||||
]);
|
||||
},
|
||||
|
||||
_actionBtns: function() {
|
||||
var self = this;
|
||||
var wrap = E('div', { 'class': 'hm-actions' });
|
||||
|
||||
wrap.appendChild(E('button', {
|
||||
'class': 'hm-btn hm-btn-s', 'id': 'hm-btn-start',
|
||||
'click': function() { self._svcCtl('start'); }
|
||||
}, ['▶ ' + _('Start')]));
|
||||
|
||||
wrap.appendChild(E('button', {
|
||||
'class': 'hm-btn hm-btn-d', 'id': 'hm-btn-stop',
|
||||
'click': function() { self._svcCtl('stop'); }
|
||||
}, ['⏹ ' + _('Stop')]));
|
||||
|
||||
wrap.appendChild(E('button', {
|
||||
'class': 'hm-btn hm-btn-r', 'id': 'hm-btn-restart',
|
||||
'click': function() { self._svcCtl('restart'); }
|
||||
}, ['🔄 ' + _('Restart')]));
|
||||
|
||||
/* More menu */
|
||||
var gearWrap = E('div', { 'class': 'hm-more-wrap' });
|
||||
gearWrap.appendChild(E('button', {
|
||||
'class': 'hm-btn hm-btn-g hm-btn-icon', 'title': _('More'),
|
||||
'click': function(ev) {
|
||||
ev.stopPropagation();
|
||||
var m = document.getElementById('hm-more-menu');
|
||||
if (!m) return;
|
||||
if (m.style.display === 'block') { m.style.display = 'none'; return; }
|
||||
var rect = ev.currentTarget.getBoundingClientRect();
|
||||
m.style.display = 'block';
|
||||
var mh = m.offsetHeight;
|
||||
m.style.top = (rect.top - mh - 6) + 'px';
|
||||
m.style.left = 'auto';
|
||||
m.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
}
|
||||
}, ['⚙']));
|
||||
|
||||
gearWrap.appendChild(E('div', { 'class': 'hm-more-menu', 'id': 'hm-more-menu' }, [
|
||||
E('div', { 'class': 'hm-more-item', 'click': function() { self._closeMenu(); self._showSetupDialog(); } }, ['📦 ' + _('Install Environment')]),
|
||||
E('div', { 'class': 'hm-more-sep' }),
|
||||
E('div', { 'class': 'hm-more-item', 'click': function() { self._closeMenu(); self._uninstall(); } }, ['🗑️ ' + _('Uninstall')])
|
||||
]));
|
||||
|
||||
wrap.appendChild(gearWrap);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
_closeMenu: function() {
|
||||
var m = document.getElementById('hm-more-menu');
|
||||
if (m) m.style.display = 'none';
|
||||
},
|
||||
|
||||
_logViewer: function() {
|
||||
return E('div', { 'class': 'hm-log-wrap', 'id': 'hm-log-wrap' }, [
|
||||
E('div', { 'class': 'hm-log-hdr' }, [
|
||||
E('span', { 'id': 'hm-log-title' }, ['📋 ' + _('Log')]),
|
||||
E('span', { 'class': 'hm-log-st', 'id': 'hm-log-st' })
|
||||
]),
|
||||
E('pre', { 'class': 'hm-log', 'id': 'hm-log' }),
|
||||
E('div', { 'id': 'hm-log-result' })
|
||||
]);
|
||||
},
|
||||
|
||||
/* ═══ Settings Panel ═══ */
|
||||
_settings: function() {
|
||||
var self = this;
|
||||
var enabled = uci.get('hermes', 'main', 'enabled') === '1';
|
||||
var port = uci.get('hermes', 'main', 'port') || '3000';
|
||||
var bind = uci.get('hermes', 'main', 'bind') || 'lan';
|
||||
var ptyPort = uci.get('hermes', 'main', 'pty_port') || '3001';
|
||||
var apiEndpoint = uci.get('hermes', 'main', 'api_endpoint') || 'https://api.boos.lat/v1';
|
||||
var apiKey = uci.get('hermes', 'main', 'api_key') || '';
|
||||
var model = uci.get('hermes', 'main', 'model') || 'opus4.6';
|
||||
|
||||
return E('div', { 'class': 'hm-panel', 'data-panel': 'settings' }, [
|
||||
E('div', { 'class': 'hm-form' }, [
|
||||
E('div', { 'class': 'hm-form-title' }, [_('Basic Settings')]),
|
||||
E('div', { 'class': 'hm-form-body' }, [
|
||||
this._formRow(_('Enable Service'), this._toggle('hm-f-enabled', enabled)),
|
||||
this._formRow(_('Gateway Port'), this._input('hm-f-port', port, 'number', _('Default: 3000'))),
|
||||
this._formRow(_('Listen Interface'), this._select('hm-f-bind', bind, [
|
||||
['lan', 'LAN'],
|
||||
['loopback', 'Loopback'],
|
||||
['all', _('All Interfaces')]
|
||||
])),
|
||||
this._formRow(_('PTY Port'), this._input('hm-f-pty-port', ptyPort, 'number', _('Default: 3001')))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'hm-form' }, [
|
||||
E('div', { 'class': 'hm-form-title' }, [_('API Settings')]),
|
||||
E('div', { 'class': 'hm-form-body' }, [
|
||||
this._formRow(_('API Endpoint'), this._input('hm-f-api-endpoint', apiEndpoint, 'text', _('Default: https://api.boos.lat/v1'))),
|
||||
this._formRow(_('API Key'), this._input('hm-f-api-key', apiKey, 'password', _('Your API key'))),
|
||||
this._formRow(_('Model'), this._input('hm-f-model', model, 'text', _('Default: opus4.6')))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'hm-actions' }, [
|
||||
E('button', { 'class': 'hm-btn hm-btn-p', 'click': function() { self._saveSettings(); } }, ['💾 ' + _('Save & Apply')]),
|
||||
E('button', { 'class': 'hm-btn hm-btn-g', 'click': function() { location.reload(); } }, [_('Reset')])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
_formRow: function(label, control) {
|
||||
return E('div', { 'class': 'hm-form-row' }, [
|
||||
E('div', { 'class': 'hm-form-lbl' }, [label]),
|
||||
E('div', { 'class': 'hm-form-ctl' }, [control])
|
||||
]);
|
||||
},
|
||||
|
||||
_toggle: function(id, checked) {
|
||||
var lbl = E('label', { 'class': 'hm-switch' });
|
||||
lbl.innerHTML = '<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + '><span class="slider"></span>';
|
||||
return lbl;
|
||||
},
|
||||
|
||||
_input: function(id, value, type, hint) {
|
||||
var wrap = E('div');
|
||||
wrap.appendChild(E('input', { 'type': type || 'text', 'id': id, 'value': value }));
|
||||
if (hint) wrap.appendChild(E('div', { 'class': 'hm-form-hint' }, [hint]));
|
||||
return wrap;
|
||||
},
|
||||
|
||||
_select: function(id, value, opts) {
|
||||
var sel = E('select', { 'id': id });
|
||||
opts.forEach(function(o) {
|
||||
var opt = E('option', { 'value': o[0] }, [o[1]]);
|
||||
if (o[0] === value) opt.setAttribute('selected', 'selected');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
return sel;
|
||||
},
|
||||
|
||||
/* ═══ Console Panel ═══ */
|
||||
_console: function(st) {
|
||||
var panel = E('div', { 'class': 'hm-panel', 'data-panel': 'console' });
|
||||
var container = E('div', { 'class': 'hm-iframe-wrap' });
|
||||
|
||||
if (st.gateway_running) {
|
||||
var proto = window.location.protocol;
|
||||
var host = window.location.hostname;
|
||||
var base = proto + '//' + host + ':' + (st.port || '3000') + '/';
|
||||
|
||||
container.innerHTML = '<div class="hm-iframe-msg"><div class="icon">⏳</div><div>' + _('Connecting...') + '</div></div>';
|
||||
callHelper(['get_token']).then(function(tok) {
|
||||
var url = base;
|
||||
if (tok && tok.token) url += '?token=' + encodeURIComponent(tok.token);
|
||||
container.innerHTML = '';
|
||||
container.appendChild(E('iframe', {
|
||||
'src': url,
|
||||
'id': 'hm-console-iframe',
|
||||
'allow': 'clipboard-read; clipboard-write',
|
||||
'style': 'width:100%;height:650px;border:none;display:block',
|
||||
'loading': 'lazy'
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '<div class="hm-iframe-msg">' +
|
||||
'<div class="icon">🖥️</div>' +
|
||||
'<div>' + _('Web console is not available.') + '</div>' +
|
||||
'<div style="margin-top:8px;font-size:12px;color:#aaa">' + _('Please start the Hermes service first.') + '</div></div>';
|
||||
}
|
||||
panel.appendChild(container);
|
||||
return panel;
|
||||
},
|
||||
|
||||
/* ═══ Terminal Panel ═══ */
|
||||
_terminal: function(st) {
|
||||
var panel = E('div', { 'class': 'hm-panel', 'data-panel': 'terminal' });
|
||||
var container = E('div', { 'class': 'hm-iframe-wrap' });
|
||||
|
||||
if (st.pty_running) {
|
||||
var proto = window.location.protocol;
|
||||
var host = window.location.hostname;
|
||||
var ptyPort = st.pty_port || '3001';
|
||||
var url = proto + '//' + host + ':' + ptyPort + '/';
|
||||
|
||||
container.innerHTML = '<div class="hm-iframe-msg">' +
|
||||
'<div class="icon">⏳</div>' +
|
||||
'<div>' + _('Connecting to config terminal...') + '</div></div>';
|
||||
callHelper(['get_token']).then(function(tok) {
|
||||
if (tok && tok.pty_token)
|
||||
url += '?token=' + encodeURIComponent(tok.pty_token);
|
||||
container.innerHTML = '';
|
||||
container.appendChild(E('iframe', {
|
||||
'src': url,
|
||||
'allow': 'clipboard-read; clipboard-write',
|
||||
'style': 'width:100%;height:650px;border:none;display:block',
|
||||
'loading': 'lazy'
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '<div class="hm-iframe-msg">' +
|
||||
'<div class="icon">⌨️</div>' +
|
||||
'<div>' + _('Config terminal is not running.') + '</div>' +
|
||||
'<div style="margin-top:8px;font-size:12px;color:#aaa">' + _('Please start the Hermes service first.') + '</div></div>';
|
||||
}
|
||||
panel.appendChild(container);
|
||||
return panel;
|
||||
},
|
||||
|
||||
/* ═══ Status Polling ═══ */
|
||||
_poll: function() {
|
||||
var self = this;
|
||||
return callHelper(['status']).then(function(st) {
|
||||
self._st = st;
|
||||
self._updateDisplay(st);
|
||||
});
|
||||
},
|
||||
|
||||
_updateDisplay: function(st) {
|
||||
var el;
|
||||
/* Status card */
|
||||
el = document.getElementById('hm-c-status');
|
||||
if (el) el.innerHTML = this._badge(st);
|
||||
/* Port */
|
||||
el = document.getElementById('hm-c-port');
|
||||
if (el) el.textContent = st.port || '3000';
|
||||
/* Memory */
|
||||
el = document.getElementById('hm-c-memory');
|
||||
if (el) el.textContent = fmtMem(st.memory_kb);
|
||||
/* Uptime */
|
||||
el = document.getElementById('hm-c-uptime');
|
||||
if (el) el.textContent = st.uptime || '-';
|
||||
/* Info rows */
|
||||
var map = {
|
||||
'hm-i-python': st.python_version || _('Not installed'),
|
||||
'hm-i-hermes': st.hermes_version || _('Not installed'),
|
||||
'hm-i-plugin': st.plugin_version || '-',
|
||||
'hm-i-model': st.active_model || '-',
|
||||
'hm-i-pid': st.pid || '-',
|
||||
'hm-i-pty': st.pty_running ? '✅ ' + _('Running') + ' (:' + (st.pty_port || '3001') + ')' : '⏹ ' + _('Stopped')
|
||||
};
|
||||
Object.keys(map).forEach(function(id) {
|
||||
el = document.getElementById(id);
|
||||
if (el) el.textContent = map[id];
|
||||
});
|
||||
/* Button states */
|
||||
var startBtn = document.getElementById('hm-btn-start');
|
||||
var stopBtn = document.getElementById('hm-btn-stop');
|
||||
if (startBtn) startBtn.disabled = !!st.gateway_running;
|
||||
if (stopBtn) stopBtn.disabled = !st.gateway_running;
|
||||
},
|
||||
|
||||
/* ═══ Service Control ═══ */
|
||||
_svcCtl: function(action) {
|
||||
var self = this;
|
||||
ui.showModal(_('Service Control'), [
|
||||
E('p', {}, [_('Executing: ') + action + '...']),
|
||||
E('div', { 'class': 'spinning' })
|
||||
]);
|
||||
return fs.exec('/etc/init.d/hermes', [action]).then(function() {
|
||||
return new Promise(function(resolve) { window.setTimeout(resolve, 2500); });
|
||||
}).then(function() {
|
||||
return self._poll();
|
||||
}).then(function() {
|
||||
ui.hideModal();
|
||||
}).catch(function(e) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, [_('Error: ') + (e.message || e)]));
|
||||
});
|
||||
},
|
||||
|
||||
/* ═══ Setup Dialog ═══ */
|
||||
_showSetupDialog: function() {
|
||||
var self = this;
|
||||
|
||||
var overlay = E('div', { 'class': 'hm-dialog-overlay', 'id': 'hm-setup-dlg' });
|
||||
var dlg = E('div', { 'class': 'hm-dialog' }, [
|
||||
E('h3', {}, ['📦 ' + _('Install Environment')]),
|
||||
E('div', { 'class': 'hm-dialog-info' }, [
|
||||
E('p', {}, [_('This will install Python 3 and Hermes Agent via pip.')]),
|
||||
E('p', {}, [_('Requires internet connection and sufficient disk space (~100MB).')])
|
||||
]),
|
||||
E('div', { 'class': 'hm-dialog-btns' }, [
|
||||
E('button', { 'class': 'hm-btn hm-btn-g', 'click': function() { overlay.remove(); } }, [_('Cancel')]),
|
||||
E('button', { 'class': 'hm-btn hm-btn-p', 'click': function() { overlay.remove(); self._doSetup(); } }, [_('Install')])
|
||||
])
|
||||
]);
|
||||
overlay.appendChild(dlg);
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
_doSetup: function() {
|
||||
var self = this;
|
||||
var logWrap = document.getElementById('hm-log-wrap');
|
||||
var logEl = document.getElementById('hm-log');
|
||||
var stEl = document.getElementById('hm-log-st');
|
||||
var resultEl = document.getElementById('hm-log-result');
|
||||
|
||||
if (logWrap) logWrap.style.display = 'block';
|
||||
if (logEl) logEl.textContent = _('Starting installation') + '...\n';
|
||||
if (stEl) stEl.innerHTML = '<span style="color:#1565c0">⏳ ' + _('Installing...') + '</span>';
|
||||
if (resultEl) { resultEl.innerHTML = ''; resultEl.className = ''; }
|
||||
|
||||
callHelper(['setup']).then(function() {
|
||||
self._pollSetupLog();
|
||||
});
|
||||
},
|
||||
|
||||
_pollSetupLog: function() {
|
||||
var self = this;
|
||||
var lastLen = 0;
|
||||
if (this._setupTimer) clearInterval(this._setupTimer);
|
||||
|
||||
this._setupTimer = setInterval(function() {
|
||||
callHelper(['setup_log']).then(function(r) {
|
||||
var logEl = document.getElementById('hm-log');
|
||||
var stEl = document.getElementById('hm-log-st');
|
||||
var resultEl = document.getElementById('hm-log-result');
|
||||
if (!logEl) return;
|
||||
|
||||
if (r.log && r.log.length > lastLen) {
|
||||
logEl.textContent += r.log.substring(lastLen);
|
||||
lastLen = r.log.length;
|
||||
}
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
if (r.state === 'running') {
|
||||
if (stEl) stEl.innerHTML = '<span style="color:#1565c0">⏳ ' + _('Installing...') + '</span>';
|
||||
} else if (r.state === 'success') {
|
||||
clearInterval(self._setupTimer);
|
||||
if (stEl) stEl.innerHTML = '<span style="color:#2e7d32">✅ ' + _('Complete') + '</span>';
|
||||
if (resultEl) {
|
||||
resultEl.className = 'hm-log-result hm-log-ok';
|
||||
resultEl.innerHTML = '<strong>🎉 ' + _('Installation successful!') + '</strong><br>' +
|
||||
'<span style="font-size:12px">' + _('Refresh the page to see updated status.') + '</span>' +
|
||||
'<br><button class="hm-btn hm-btn-p" style="margin-top:10px" onclick="location.reload()">🔄 ' + _('Refresh') + '</button>';
|
||||
}
|
||||
} else if (r.state === 'failed') {
|
||||
clearInterval(self._setupTimer);
|
||||
if (stEl) stEl.innerHTML = '<span style="color:#c62828">❌ ' + _('Failed') + '</span>';
|
||||
if (resultEl) {
|
||||
resultEl.className = 'hm-log-result hm-log-fail';
|
||||
resultEl.textContent = '❌ ' + _('Installation failed. Check the log above for details.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
},
|
||||
|
||||
/* ═══ Uninstall ═══ */
|
||||
_uninstall: function() {
|
||||
var self = this;
|
||||
ui.showModal(_('Confirm Uninstall'), [
|
||||
E('p', { 'style': 'color:#c62828' }, [
|
||||
'⚠️ ' + _('This will remove Python, Hermes Agent, and all related data.')
|
||||
]),
|
||||
E('p', {}, [_('This action cannot be undone.')]),
|
||||
E('div', { 'style': 'display:flex;gap:10px;justify-content:flex-end;margin-top:16px' }, [
|
||||
E('button', { 'class': 'hm-btn hm-btn-g', 'click': function() { ui.hideModal(); } }, [_('Cancel')]),
|
||||
E('button', { 'class': 'hm-btn hm-btn-d', 'click': function() {
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Uninstalling...'), [E('div', { 'class': 'spinning' })]);
|
||||
callHelper(['uninstall']).then(function() {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, ['✅ ' + _('Environment uninstalled successfully')]));
|
||||
return self._poll();
|
||||
});
|
||||
} }, ['🗑️ ' + _('Confirm Uninstall')])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
/* ═══ Save Settings ═══ */
|
||||
_saveSettings: function() {
|
||||
var self = this;
|
||||
var enabled = document.getElementById('hm-f-enabled');
|
||||
var port = document.getElementById('hm-f-port');
|
||||
var bind = document.getElementById('hm-f-bind');
|
||||
var ptyPort = document.getElementById('hm-f-pty-port');
|
||||
var apiEndpoint = document.getElementById('hm-f-api-endpoint');
|
||||
var apiKey = document.getElementById('hm-f-api-key');
|
||||
var model = document.getElementById('hm-f-model');
|
||||
|
||||
uci.set('hermes', 'main', 'enabled', enabled && enabled.checked ? '1' : '0');
|
||||
if (port) uci.set('hermes', 'main', 'port', port.value);
|
||||
if (bind) uci.set('hermes', 'main', 'bind', bind.value);
|
||||
if (ptyPort) uci.set('hermes', 'main', 'pty_port', ptyPort.value);
|
||||
if (apiEndpoint) uci.set('hermes', 'main', 'api_endpoint', apiEndpoint.value);
|
||||
if (apiKey) uci.set('hermes', 'main', 'api_key', apiKey.value);
|
||||
if (model) uci.set('hermes', 'main', 'model', model.value);
|
||||
|
||||
return uci.save().then(function() {
|
||||
return uci.apply();
|
||||
}).then(function() {
|
||||
ui.addNotification(null, E('p', {}, ['✅ ' + _('Settings saved successfully')]));
|
||||
return self._poll();
|
||||
}).catch(function(e) {
|
||||
ui.addNotification(null, E('p', {}, [_('Error: ') + (e.message || e)]));
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -1,6 +1,78 @@
|
||||
-- luci-app-hermes — LuCI Controller (18.06 compat)
|
||||
module("luci.controller.hermes", package.seeall)
|
||||
|
||||
function index()
|
||||
entry({"admin", "services", "hermes"}, alias("admin", "services", "hermes", "config"), _("Hermes"), 30).dependent = true
|
||||
entry({"admin", "services", "hermes", "config"}, cbi("hermes"))
|
||||
-- luci 24.10+ uses menu.d JSON for registration; skip Lua menu
|
||||
if nixio.fs.access("/usr/share/luci/menu.d/luci-app-hermes.json") then
|
||||
return
|
||||
end
|
||||
|
||||
local page = entry({"admin", "services", "hermes"},
|
||||
alias("admin", "services", "hermes", "basic"), _("Hermes Agent"), 90)
|
||||
page.dependent = false
|
||||
|
||||
entry({"admin", "services", "hermes", "basic"},
|
||||
cbi("hermes"), _("Basic Settings"), 10).leaf = true
|
||||
entry({"admin", "services", "hermes", "console"},
|
||||
template("hermes/console"), _("Web Console"), 20).leaf = true
|
||||
entry({"admin", "services", "hermes", "terminal"},
|
||||
template("hermes/terminal"), _("Config Terminal"), 30).leaf = true
|
||||
|
||||
-- API endpoints (used by status.htm XHR)
|
||||
entry({"admin", "services", "hermes", "status_api"},
|
||||
call("action_status"), nil).leaf = true
|
||||
entry({"admin", "services", "hermes", "service_ctl"},
|
||||
call("action_service_ctl"), nil).leaf = true
|
||||
entry({"admin", "services", "hermes", "get_token"},
|
||||
call("action_get_token"), nil).leaf = true
|
||||
end
|
||||
|
||||
-- ── Status API ──
|
||||
function action_status()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local raw = sys.exec("/usr/share/hermes/luci-helper status 2>/dev/null")
|
||||
http.prepare_content("application/json")
|
||||
http.write(raw ~= "" and raw or '{}')
|
||||
end
|
||||
|
||||
-- ── Service Control API ──
|
||||
function action_service_ctl()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local action = http.formvalue("action") or ""
|
||||
local allowed = { start = true, stop = true, restart = true, enable = true, disable = true }
|
||||
|
||||
if not allowed[action] then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "invalid action" })
|
||||
return
|
||||
end
|
||||
|
||||
if action == "stop" then
|
||||
sys.exec("/etc/init.d/hermes stop >/dev/null 2>&1")
|
||||
elseif action == "restart" then
|
||||
sys.exec("/etc/init.d/hermes stop >/dev/null 2>&1")
|
||||
sys.exec("sleep 2")
|
||||
sys.exec("/etc/init.d/hermes start >/dev/null 2>&1 &")
|
||||
else
|
||||
sys.exec("/etc/init.d/hermes " .. action .. " >/dev/null 2>&1 &")
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "ok", action = action })
|
||||
end
|
||||
|
||||
-- ── Get Token API ──
|
||||
function action_get_token()
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
token = uci:get("hermes", "main", "token") or "",
|
||||
pty_token = uci:get("hermes", "main", "pty_token") or ""
|
||||
})
|
||||
end
|
||||
@@ -1,45 +1,52 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
]]--
|
||||
-- luci-app-hermes — CBI Model (18.06 compat)
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local taskd = require "luci.model.tasks"
|
||||
local hermes_model = require "luci.model.hermes"
|
||||
local m, s, o
|
||||
m = Map("hermes", "Hermes Agent",
|
||||
|
||||
m = taskd.docker_map("hermes", "hermes", "/usr/libexec/istorec/hermes.sh",
|
||||
translate("Hermes"),
|
||||
translate("The self-improving AI agent that runs on your server. Layered memory that accumulates across sessions, a cron scheduler that fires while you're offline, and a skills system that saves reusable procedures automatically.")
|
||||
.. translate("Official website:") .. ' <a href=\"https://get-hermes.ai/\" target=\"_blank\">https://get-hermes.ai/</a>')
|
||||
translate("Python-based AI Agent Gateway for OpenWrt routers."))
|
||||
|
||||
s = m:section(SimpleSection, translate("Service Status"), translate("Hermes status:"))
|
||||
s:append(Template("hermes/status"))
|
||||
-- Status panel
|
||||
m:section(SimpleSection).template = "hermes/status"
|
||||
|
||||
s = m:section(TypedSection, "main", translate("Setup"), translate("The following parameters will only take effect during installation or upgrade:"))
|
||||
-- Basic settings
|
||||
s = m:section(NamedSection, "main", "hermes", translate("Basic Settings"))
|
||||
s.addremove = false
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Value, "port", translate("Port") .. "<b>*</b>")
|
||||
o.default = "8787"
|
||||
o = s:option(Flag, "enabled", translate("Enable Service"))
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "port", translate("Gateway Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
local blocks = hermes_model.blocks()
|
||||
|
||||
o = s:option(Value, "data_path", translate("Data path") .. "<b>*</b>")
|
||||
o.default = "3000"
|
||||
o.rmempty = false
|
||||
o.datatype = "string"
|
||||
local data_paths, data_default = hermes_model.find_paths(blocks, "data")
|
||||
for _, val in pairs(data_paths) do
|
||||
o:value(val, val)
|
||||
end
|
||||
o.default = data_default
|
||||
|
||||
o = s:option(Value, "workspace_path", translate("Workspace path") .. "<b>*</b>")
|
||||
o = s:option(ListValue, "bind", translate("Listen Interface"))
|
||||
o:value("lan", "LAN")
|
||||
o:value("loopback", "Loopback")
|
||||
o:value("all", translate("All Interfaces"))
|
||||
o.default = "lan"
|
||||
|
||||
o = s:option(Value, "pty_port", translate("PTY Port"))
|
||||
o.datatype = "port"
|
||||
o.default = "3001"
|
||||
o.rmempty = false
|
||||
|
||||
-- API settings
|
||||
s2 = m:section(NamedSection, "main", "hermes", translate("API Settings"))
|
||||
s2.addremove = false
|
||||
s2.anonymous = true
|
||||
|
||||
o = s2:option(Value, "api_endpoint", translate("API Endpoint"))
|
||||
o.default = "https://api.boos.lat/v1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s2:option(Value, "api_key", translate("API Key"))
|
||||
o.password = true
|
||||
o.rmempty = true
|
||||
|
||||
o = s2:option(Value, "model", translate("Model"))
|
||||
o.default = "opus4.6"
|
||||
o.rmempty = false
|
||||
o.datatype = "string"
|
||||
local ws_paths, ws_default = hermes_model.find_paths(blocks, "workspace")
|
||||
for _, val in pairs(ws_paths) do
|
||||
o:value(val, val)
|
||||
end
|
||||
o.default = ws_default
|
||||
|
||||
return m
|
||||
@@ -1,38 +0,0 @@
|
||||
local util = require "luci.util"
|
||||
local jsonc = require "luci.jsonc"
|
||||
|
||||
local hermes = {}
|
||||
|
||||
hermes.blocks = function()
|
||||
local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r")
|
||||
local vals = {}
|
||||
if f then
|
||||
local ret = f:read("*all")
|
||||
f:close()
|
||||
local obj = jsonc.parse(ret)
|
||||
if obj and obj["blockdevices"] then
|
||||
for _, val in pairs(obj["blockdevices"]) do
|
||||
local fsize = val["fssize"]
|
||||
if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then
|
||||
-- fsize > 1G
|
||||
vals[#vals+1] = val["mountpoint"]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return vals
|
||||
end
|
||||
|
||||
hermes.find_paths = function(blocks, subdir)
|
||||
local configs = {}
|
||||
local default_path = ''
|
||||
for _, mountpoint in pairs(blocks) do
|
||||
table.insert(configs, mountpoint .. "/hermes/" .. subdir)
|
||||
end
|
||||
if #configs > 0 then
|
||||
default_path = configs[1]
|
||||
end
|
||||
return configs, default_path
|
||||
end
|
||||
|
||||
return hermes
|
||||
@@ -0,0 +1,59 @@
|
||||
<%#
|
||||
luci-app-hermes — Web Console (18.06 compat)
|
||||
-%>
|
||||
<%+header%>
|
||||
<%
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local port = uci:get("hermes", "main", "port") or "3000"
|
||||
%>
|
||||
<style>
|
||||
.hm-page-hdr{margin:0 0 16px}
|
||||
.hm-page-hdr h2{font-size:18px;font-weight:600;color:#333;margin:0 0 6px}
|
||||
.hm-page-hdr p{font-size:13px;color:#666;margin:0}
|
||||
.hm-wrap{border:2px solid #e0e0e0;border-radius:8px;overflow:hidden;background:#fafafa}
|
||||
.hm-wrap iframe{width:100%;height:680px;border:none;display:block}
|
||||
.hm-loading{display:flex;align-items:center;justify-content:center;height:200px;color:#666;font-size:14px}
|
||||
</style>
|
||||
|
||||
<div class="hm-page-hdr">
|
||||
<h2>🖥️ <%:Web Console%></h2>
|
||||
<p><%:Hermes Agent Web UI — configure AI models and interact with the AI agent.%></p>
|
||||
</div>
|
||||
|
||||
<div class="hm-wrap" id="hm-console-wrap">
|
||||
<div class="hm-loading" id="hm-console-loading">⏳ <%:Connecting...%></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
var statusUrl='<%=luci.dispatcher.build_url("admin","services","hermes","status_api")%>';
|
||||
var tokenUrl='<%=luci.dispatcher.build_url("admin","services","hermes","get_token")%>';
|
||||
var wrap=document.getElementById('hm-console-wrap');
|
||||
var loading=document.getElementById('hm-console-loading');
|
||||
|
||||
(new XHR()).get(tokenUrl,null,function(tx){
|
||||
var token='';
|
||||
try{token=JSON.parse(tx.responseText).token||'';}catch(e){}
|
||||
|
||||
(new XHR()).get(statusUrl,null,function(x){
|
||||
try{
|
||||
var d=JSON.parse(x.responseText);
|
||||
if(d.gateway_running){
|
||||
loading.style.display='none';
|
||||
var url=location.protocol+'//'+location.hostname+':<%=port%>/';
|
||||
if(token)url+='?token='+encodeURIComponent(token);
|
||||
var iframe=document.createElement('iframe');
|
||||
iframe.src=url;
|
||||
iframe.setAttribute('allow','clipboard-read; clipboard-write');
|
||||
wrap.appendChild(iframe);
|
||||
}else{
|
||||
loading.textContent='❌ <%:Gateway is not running. Please start the service first.%>';
|
||||
}
|
||||
}catch(e){loading.textContent='<%:Failed to check status%>';}
|
||||
});
|
||||
});
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -1,30 +1,86 @@
|
||||
<%
|
||||
local util = require "luci.util"
|
||||
local container_status = util.trim(util.exec("/usr/libexec/istorec/hermes.sh status"))
|
||||
local container_install = (string.len(container_status) > 0)
|
||||
local container_running = container_status == "running"
|
||||
<%#
|
||||
luci-app-hermes — Status Panel (18.06 compat)
|
||||
-%>
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title"><%:Status%></label>
|
||||
<div class="cbi-value-field">
|
||||
<% if container_running then %>
|
||||
<button class="cbi-button cbi-button-success" disabled="true"><%:Hermes is running%></button>
|
||||
<% else %>
|
||||
<button class="cbi-button cbi-button-negative" disabled="true"><%:Hermes is not running%></button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hm-panel{margin:0 0 20px;border:1px solid #e0e0e0;border-radius:8px;background:#fff;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.06)}
|
||||
.hm-panel .hdr{background:linear-gradient(135deg,#1565c0,#0d47a1);color:#fff;padding:10px 16px;font-size:14px;font-weight:600}
|
||||
.hm-panel table{width:100%;border-collapse:collapse}
|
||||
.hm-panel td{padding:8px 16px;border-bottom:1px solid #f2f2f2;font-size:13px}
|
||||
.hm-panel tr:last-child td{border-bottom:none}
|
||||
.hm-panel td:first-child{width:120px;color:#888;font-weight:500}
|
||||
.hm-badge{display:inline-block;padding:2px 12px;border-radius:12px;font-size:12px;font-weight:600}
|
||||
.hm-badge-run{background:#e8f5e9;color:#2e7d32}
|
||||
.hm-badge-stop{background:#ffebee;color:#c62828}
|
||||
.hm-badge-start{background:#fff8e1;color:#f57f17}
|
||||
.hm-badge-off{background:#f5f5f5;color:#9e9e9e}
|
||||
.hm-actions{display:flex;gap:10px;flex-wrap:wrap;margin:16px}
|
||||
.hm-actions .btn{padding:8px 16px;border-radius:6px;font-size:13px;cursor:pointer}
|
||||
</style>
|
||||
|
||||
<div class="hm-panel">
|
||||
<div class="hdr">🤖 Hermes Agent Service Status</div>
|
||||
<table>
|
||||
<tr><td><%:Status%></td><td id="hm-st-status"><span class="hm-badge hm-badge-off">Loading...</span></td></tr>
|
||||
<tr><td><%:Gateway%></td><td id="hm-st-gw">-</td></tr>
|
||||
<tr><td><%:Config PTY%></td><td id="hm-st-pty">-</td></tr>
|
||||
<tr><td>Python</td><td id="hm-st-python">-</td></tr>
|
||||
<tr><td>Hermes</td><td id="hm-st-hermes">-</td></tr>
|
||||
<tr><td><%:Plugin%></td><td id="hm-st-plugin">-</td></tr>
|
||||
<tr><td><%:Model%></td><td id="hm-st-model">-</td></tr>
|
||||
<tr><td><%:Memory%></td><td id="hm-st-mem">-</td></tr>
|
||||
<tr><td><%:Uptime%></td><td id="hm-st-up">-</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<%
|
||||
if container_running then
|
||||
local port = util.trim(util.exec("/usr/libexec/istorec/hermes.sh port"))
|
||||
if port == "" then
|
||||
port = "8787"
|
||||
end
|
||||
-%>
|
||||
<div class="cbi-value cbi-value-last">
|
||||
<label class="cbi-value-title"> </label>
|
||||
<div class="cbi-value-field">
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" name="start" value="<%:Open Hermes%>" onclick="window.open('http://'+location.hostname+':<%=port%>/', '_blank')">
|
||||
</div>
|
||||
|
||||
<div class="hm-actions">
|
||||
<button class="btn cbi-button cbi-button-action" onclick="hmCtl('start')"><%:Start%></button>
|
||||
<button class="btn cbi-button cbi-button-action" onclick="hmCtl('stop')"><%:Stop%></button>
|
||||
<button class="btn cbi-button cbi-button-action" onclick="hmCtl('restart')"><%:Restart%></button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
var apiUrl='<%=luci.dispatcher.build_url("admin","services","hermes","status_api")%>';
|
||||
var ctlUrl='<%=luci.dispatcher.build_url("admin","services","hermes","service_ctl")%>';
|
||||
|
||||
function fmtMem(kb){
|
||||
kb=parseInt(kb)||0;
|
||||
if(kb<=0)return'-';
|
||||
if(kb>=1024)return(kb/1024).toFixed(1)+' MB';
|
||||
return kb+' KB';
|
||||
}
|
||||
|
||||
function update(){
|
||||
(new XHR()).get(apiUrl,null,function(x){
|
||||
try{
|
||||
var d=JSON.parse(x.responseText);
|
||||
var st=document.getElementById('hm-st-status');
|
||||
if(d.enabled!=='1')st.innerHTML='<span class="hm-badge hm-badge-off">Disabled</span>';
|
||||
else if(d.gateway_running)st.innerHTML='<span class="hm-badge hm-badge-run">Running</span>';
|
||||
else if(d.gateway_starting)st.innerHTML='<span class="hm-badge hm-badge-start">Starting...</span>';
|
||||
else st.innerHTML='<span class="hm-badge hm-badge-stop">Stopped</span>';
|
||||
document.getElementById('hm-st-gw').textContent=d.gateway_running?'✅ :'+d.port:'⏹ Stopped';
|
||||
document.getElementById('hm-st-pty').textContent=d.pty_running?'✅ :'+d.pty_port:'⏹ Stopped';
|
||||
document.getElementById('hm-st-python').textContent=d.python_version||'Not installed';
|
||||
document.getElementById('hm-st-hermes').textContent=d.hermes_version||'Not installed';
|
||||
document.getElementById('hm-st-plugin').textContent=d.plugin_version||'-';
|
||||
document.getElementById('hm-st-model').textContent=d.active_model||'-';
|
||||
document.getElementById('hm-st-mem').textContent=fmtMem(d.memory_kb);
|
||||
document.getElementById('hm-st-up').textContent=d.uptime||'-';
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
|
||||
window.hmCtl=function(action){
|
||||
(new XHR()).get(ctlUrl+'?action='+action,null,function(){
|
||||
setTimeout(update,2500);
|
||||
});
|
||||
};
|
||||
|
||||
update();
|
||||
setInterval(update,5000);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<%#
|
||||
luci-app-hermes — Config Terminal (18.06 compat)
|
||||
-%>
|
||||
<%+header%>
|
||||
<%
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local pty_port = uci:get("hermes", "main", "pty_port") or "3001"
|
||||
%>
|
||||
<style>
|
||||
.hm-page-hdr{margin:0 0 16px}
|
||||
.hm-page-hdr h2{font-size:18px;font-weight:600;color:#333;margin:0 0 6px}
|
||||
.hm-page-hdr p{font-size:13px;color:#666;margin:0}
|
||||
.hm-wrap{border:2px solid #2d333b;border-radius:8px;overflow:hidden;background:#1a1b26}
|
||||
.hm-wrap iframe{width:100%;height:650px;border:none;display:block}
|
||||
.hm-loading{display:flex;align-items:center;justify-content:center;height:200px;color:#7aa2f7;font-size:14px;background:#1a1b26}
|
||||
</style>
|
||||
|
||||
<div class="hm-page-hdr">
|
||||
<h2>⌨️ <%:Config Terminal%></h2>
|
||||
<p><%:Interactive terminal for Hermes Agent configuration — AI models, health check, and system management.%></p>
|
||||
</div>
|
||||
|
||||
<div class="hm-wrap" id="hm-term-wrap">
|
||||
<div class="hm-loading" id="hm-term-loading">⏳ <%:Connecting to config terminal...%></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
var statusUrl='<%=luci.dispatcher.build_url("admin","services","hermes","status_api")%>';
|
||||
var tokenUrl='<%=luci.dispatcher.build_url("admin","services","hermes","get_token")%>';
|
||||
var wrap=document.getElementById('hm-term-wrap');
|
||||
var loading=document.getElementById('hm-term-loading');
|
||||
|
||||
(new XHR()).get(tokenUrl,null,function(tx){
|
||||
var ptyToken='';
|
||||
try{ptyToken=JSON.parse(tx.responseText).pty_token||'';}catch(e){}
|
||||
|
||||
(new XHR()).get(statusUrl,null,function(x){
|
||||
try{
|
||||
var d=JSON.parse(x.responseText);
|
||||
if(d.pty_running){
|
||||
loading.style.display='none';
|
||||
var url=location.protocol+'//'+location.hostname+':<%=pty_port%>/';
|
||||
if(ptyToken)url+='?token='+encodeURIComponent(ptyToken);
|
||||
var iframe=document.createElement('iframe');
|
||||
iframe.src=url;
|
||||
iframe.setAttribute('allow','clipboard-read; clipboard-write');
|
||||
iframe.style.cssText='width:100%;height:650px;border:none;display:block';
|
||||
wrap.appendChild(iframe);
|
||||
iframe.addEventListener('load',function(){try{iframe.contentWindow.focus();}catch(e){}});
|
||||
wrap.addEventListener('click',function(){try{iframe.contentWindow.focus();}catch(e){}});
|
||||
}else{
|
||||
loading.innerHTML='❌ <%:Config terminal is not running.%><br><span style="font-size:12px;color:#999"><%:Please start the service first.%></span>';
|
||||
}
|
||||
}catch(e){loading.textContent='<%:Failed to check status%>';}
|
||||
});
|
||||
});
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
<%+footer%>
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
@@ -1,44 +0,0 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
msgid "Official website:"
|
||||
msgstr "官方网站:"
|
||||
|
||||
msgid "Hermes"
|
||||
msgstr "Hermes 消息服务"
|
||||
|
||||
msgid "The self-improving AI agent that runs on your server. Layered memory that accumulates across sessions, a cron scheduler that fires while you're offline, and a skills system that saves reusable procedures automatically."
|
||||
msgstr "运行在您的服务器上的自我改进型人工智能代理。它采用跨会话累积的分层内存、在您离线时运行的定时任务调度程序,以及自动保存可重用程序的技能系统。"
|
||||
|
||||
msgid "Port"
|
||||
msgstr "端口"
|
||||
|
||||
msgid "Data path"
|
||||
msgstr "数据路径"
|
||||
|
||||
msgid "Workspace path"
|
||||
msgstr "工作目录路径"
|
||||
|
||||
msgid "Service Status"
|
||||
msgstr "服务状态"
|
||||
|
||||
msgid "Hermes status:"
|
||||
msgstr "Hermes 状态信息如下:"
|
||||
|
||||
msgid "Setup"
|
||||
msgstr "安装配置"
|
||||
|
||||
msgid "The following parameters will only take effect during installation or upgrade:"
|
||||
msgstr "以下参数只在安装或者升级时才会生效:"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "状态"
|
||||
|
||||
msgid "Hermes is running"
|
||||
msgstr "Hermes 运行中"
|
||||
|
||||
msgid "Hermes is not running"
|
||||
msgstr "Hermes 未运行"
|
||||
|
||||
msgid "Open Hermes"
|
||||
msgstr "打开 Hermes"
|
||||
@@ -1 +0,0 @@
|
||||
zh-cn
|
||||
@@ -0,0 +1,257 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Project-Id-Version: luci-app-hermes\n"
|
||||
"Language: zh_Hans\n"
|
||||
|
||||
msgid "Active Model"
|
||||
msgstr "活跃模型"
|
||||
|
||||
msgid "All Interfaces"
|
||||
msgstr "所有接口"
|
||||
|
||||
msgid "API Endpoint"
|
||||
msgstr "API 端点"
|
||||
|
||||
msgid "API Key"
|
||||
msgstr "API 密钥"
|
||||
|
||||
msgid "API Settings"
|
||||
msgstr "API 设置"
|
||||
|
||||
msgid "Already up to date"
|
||||
msgstr "已是最新版本"
|
||||
|
||||
msgid "Basic Settings"
|
||||
msgstr "基本设置"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
msgid "Check Update"
|
||||
msgstr "检查更新"
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr "检查中..."
|
||||
|
||||
msgid "Complete"
|
||||
msgstr "完成"
|
||||
|
||||
msgid "Config PTY"
|
||||
msgstr "配置终端"
|
||||
|
||||
msgid "Config Terminal"
|
||||
msgstr "配置终端"
|
||||
|
||||
msgid "Config terminal is not running."
|
||||
msgstr "配置终端未运行。"
|
||||
|
||||
msgid "Confirm Uninstall"
|
||||
msgstr "确认卸载"
|
||||
|
||||
msgid "Connecting..."
|
||||
msgstr "正在连接..."
|
||||
|
||||
msgid "Connecting to config terminal..."
|
||||
msgstr "正在连接配置终端..."
|
||||
|
||||
msgid "Default: 3000"
|
||||
msgstr "默认: 3000"
|
||||
|
||||
msgid "Default: 3001"
|
||||
msgstr "默认: 3001"
|
||||
|
||||
msgid "Disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
msgid "Enable Service"
|
||||
msgstr "启用服务"
|
||||
|
||||
msgid "Environment uninstalled successfully"
|
||||
msgstr "环境已成功卸载"
|
||||
|
||||
msgid "Error: "
|
||||
msgstr "错误:"
|
||||
|
||||
msgid "Executing: "
|
||||
msgstr "正在执行:"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr "失败"
|
||||
|
||||
msgid "Failed to check status"
|
||||
msgstr "检查状态失败"
|
||||
|
||||
msgid "Gateway Port"
|
||||
msgstr "网关端口"
|
||||
|
||||
msgid "Gateway is not running. Please start the service first."
|
||||
msgstr "网关未运行。请先启动服务。"
|
||||
|
||||
msgid "Hermes"
|
||||
msgstr "Hermes"
|
||||
|
||||
msgid "Hermes Agent"
|
||||
msgstr "Hermes Agent"
|
||||
|
||||
msgid "Hermes Agent Web UI — configure AI models and interact with the AI agent."
|
||||
msgstr "Hermes Agent Web UI — 配置 AI 模型并与 AI 代理交互。"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "安装"
|
||||
|
||||
msgid "Install Environment"
|
||||
msgstr "安装环境"
|
||||
|
||||
msgid "Installation failed. Check the log above for details."
|
||||
msgstr "安装失败,请查看上方日志了解详情。"
|
||||
|
||||
msgid "Installation successful!"
|
||||
msgstr "安装成功!"
|
||||
|
||||
msgid "Installing..."
|
||||
msgstr "安装中..."
|
||||
|
||||
msgid "Interactive terminal for Hermes Agent configuration — AI models, health check, and system management."
|
||||
msgstr "Hermes Agent 交互式终端 — AI 模型配置、健康检查和系统管理。"
|
||||
|
||||
msgid "Listen Interface"
|
||||
msgstr "监听接口"
|
||||
|
||||
msgid "Log"
|
||||
msgstr "日志"
|
||||
|
||||
msgid "Memory"
|
||||
msgstr "内存"
|
||||
|
||||
msgid "Model"
|
||||
msgstr "模型"
|
||||
|
||||
msgid "More"
|
||||
msgstr "更多"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "未安装"
|
||||
|
||||
msgid "Overview"
|
||||
msgstr "概览"
|
||||
|
||||
msgid "PID"
|
||||
msgstr "进程号"
|
||||
|
||||
msgid "Plugin"
|
||||
msgstr "插件"
|
||||
|
||||
msgid "Please start the Hermes service first."
|
||||
msgstr "请先启动 Hermes 服务。"
|
||||
|
||||
msgid "Please start the service first."
|
||||
msgstr "请先启动服务。"
|
||||
|
||||
msgid "Python"
|
||||
msgstr "Python"
|
||||
|
||||
msgid "Python AI Agent Gateway for OpenWrt"
|
||||
msgstr "OpenWrt 路由器 Python AI 代理网关"
|
||||
|
||||
msgid "Python-based AI Agent Gateway for OpenWrt routers."
|
||||
msgstr "基于 Python 的 OpenWrt 路由器 AI 代理网关。"
|
||||
|
||||
msgid "PTY Port"
|
||||
msgstr "终端端口"
|
||||
|
||||
msgid "Refresh"
|
||||
msgstr "刷新"
|
||||
|
||||
msgid "Refresh the page to see updated status."
|
||||
msgstr "刷新页面查看最新状态。"
|
||||
|
||||
msgid "Requires internet connection and sufficient disk space (~100MB)."
|
||||
msgstr "需要互联网连接和足够的磁盘空间(约100MB)。"
|
||||
|
||||
msgid "Reset"
|
||||
msgstr "重置"
|
||||
|
||||
msgid "Restart"
|
||||
msgstr "重启"
|
||||
|
||||
msgid "Running"
|
||||
msgstr "运行中"
|
||||
|
||||
msgid "Save & Apply"
|
||||
msgstr "保存并应用"
|
||||
|
||||
msgid "Service Control"
|
||||
msgstr "服务控制"
|
||||
|
||||
msgid "Service Status"
|
||||
msgstr "服务状态"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "设置"
|
||||
|
||||
msgid "Settings saved successfully"
|
||||
msgstr "设置已保存"
|
||||
|
||||
msgid "Start"
|
||||
msgstr "启动"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "启动中"
|
||||
|
||||
msgid "Starting installation"
|
||||
msgstr "开始安装"
|
||||
|
||||
msgid "Stop"
|
||||
msgstr "停止"
|
||||
|
||||
msgid "Stopped"
|
||||
msgstr "已停止"
|
||||
|
||||
msgid "System Information"
|
||||
msgstr "系统信息"
|
||||
|
||||
msgid "This action cannot be undone."
|
||||
msgstr "此操作不可撤销。"
|
||||
|
||||
msgid "This will remove Python, Hermes Agent, and all related data."
|
||||
msgstr "这将移除 Python、Hermes Agent 和所有相关数据。"
|
||||
|
||||
msgid "This will install Python 3 and Hermes Agent via pip."
|
||||
msgstr "这将通过 pip 安装 Python 3 和 Hermes Agent。"
|
||||
|
||||
msgid "Uninstall"
|
||||
msgstr "卸载"
|
||||
|
||||
msgid "Uninstalling..."
|
||||
msgstr "正在卸载..."
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "未知"
|
||||
|
||||
msgid "Update Available"
|
||||
msgstr "有可用更新"
|
||||
|
||||
msgid "Upgrade"
|
||||
msgstr "升级"
|
||||
|
||||
msgid "Upgrade complete"
|
||||
msgstr "升级完成"
|
||||
|
||||
msgid "Upgrade successful!"
|
||||
msgstr "升级成功!"
|
||||
|
||||
msgid "Upgrading to "
|
||||
msgstr "正在升级到 "
|
||||
|
||||
msgid "Upgrading..."
|
||||
msgstr "升级中..."
|
||||
|
||||
msgid "Uptime"
|
||||
msgstr "运行时间"
|
||||
|
||||
msgid "Web Console"
|
||||
msgstr "Web 控制台"
|
||||
|
||||
msgid "Web console is not available."
|
||||
msgstr "Web 控制台不可用。"
|
||||
@@ -1,4 +1,10 @@
|
||||
config main
|
||||
option 'port' '8787'
|
||||
option 'data_path' ''
|
||||
option 'workspace_path' ''
|
||||
config hermes 'main'
|
||||
option enabled '0'
|
||||
option port '3000'
|
||||
option bind 'lan'
|
||||
option api_endpoint ''
|
||||
option api_key ''
|
||||
option model ''
|
||||
option pty_port '3001'
|
||||
option token ''
|
||||
option pty_token ''
|
||||
Executable
+419
@@ -0,0 +1,419 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# luci-app-hermes — procd init script
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
EXTRA_COMMANDS="setup status_service restart_gateway"
|
||||
EXTRA_HELP=" setup Install Python and Hermes Agent
|
||||
status_service Show service status
|
||||
restart_gateway Restart only Gateway instance (does not affect Web PTY)"
|
||||
|
||||
HERMES_BASE="/opt/hermes"
|
||||
HERMES_VENV="${HERMES_BASE}/venv"
|
||||
HERMES_DATA="${HERMES_BASE}/data"
|
||||
HERMES_CONFIG="${HERMES_DATA}/config.yaml"
|
||||
HERMES_BIN="${HERMES_VENV}/bin/hermes"
|
||||
PYTHON_BIN="${HERMES_VENV}/bin/python3"
|
||||
|
||||
# ── OverlayFS compatibility fix ──
|
||||
# Docker bind mount (/overlay/upper/opt/docker) makes /opt unwritable
|
||||
# Solution: bind mount upper layer /opt to merged view /opt
|
||||
_hermes_fix_opt() {
|
||||
mkdir -p /opt/hermes/.probe 2>/dev/null && { rmdir /opt/hermes/.probe 2>/dev/null; return 0; }
|
||||
if [ -d /overlay/upper/opt ]; then
|
||||
mkdir -p /overlay/upper/opt/hermes 2>/dev/null
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
_hermes_fix_opt
|
||||
|
||||
# ── Port listening check ──
|
||||
_ensure_port_free() {
|
||||
local p="$1"
|
||||
local i=0 max_wait=3
|
||||
|
||||
# Check if port is already in use
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
|
||||
# Port is occupied, try to clean up
|
||||
for hermes_pid in $(pgrep -f "hermes.*gateway" 2>/dev/null); do
|
||||
kill "$hermes_pid" 2>/dev/null
|
||||
done
|
||||
|
||||
while [ $i -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Last resort: SIGKILL
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1
|
||||
return 0
|
||||
}
|
||||
|
||||
port_listening() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
fi
|
||||
}
|
||||
|
||||
sync_uci_to_yaml() {
|
||||
# Sync UCI config to /root/.hermes/config.yaml
|
||||
local port bind api_endpoint api_key model
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
bind=$(uci -q get hermes.main.bind || echo "lan")
|
||||
api_endpoint=$(uci -q get hermes.main.api_endpoint || echo "https://api.boos.lat/v1")
|
||||
api_key=$(uci -q get hermes.main.api_key || echo "")
|
||||
model=$(uci -q get hermes.main.model || echo "opus4.6")
|
||||
|
||||
# Map UCI bind to listen address
|
||||
local listen_host="127.0.0.1"
|
||||
case "$bind" in
|
||||
lan) listen_host="0.0.0.0" ;;
|
||||
loopback) listen_host="127.0.0.1" ;;
|
||||
all) listen_host="0.0.0.0" ;;
|
||||
*) listen_host="0.0.0.0" ;;
|
||||
esac
|
||||
|
||||
# Ensure config directory exists
|
||||
mkdir -p /root/.hermes 2>/dev/null
|
||||
mkdir -p "$(dirname "$HERMES_CONFIG")" 2>/dev/null
|
||||
|
||||
# Generate token if not set
|
||||
local token
|
||||
token=$(uci -q get hermes.main.token || echo "")
|
||||
if [ -z "$token" ]; then
|
||||
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || echo "hermes_$(date +%s)")
|
||||
uci set hermes.main.token="$token"
|
||||
uci commit hermes 2>/dev/null
|
||||
fi
|
||||
|
||||
# Write config.yaml using shell (no Python dependency at this point)
|
||||
cat > /root/.hermes/config.yaml << YAMLEOF
|
||||
# Hermes Agent configuration — auto-generated by luci-app-hermes
|
||||
# UCI sync: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
gateway:
|
||||
port: ${port}
|
||||
bind: ${listen_host}
|
||||
auth:
|
||||
mode: token
|
||||
token: ${token}
|
||||
|
||||
api:
|
||||
endpoint: "${api_endpoint}"
|
||||
key: "${api_key}"
|
||||
|
||||
model:
|
||||
primary: "${model}"
|
||||
|
||||
tools:
|
||||
profile: coding
|
||||
|
||||
update:
|
||||
checkOnStart: false
|
||||
YAMLEOF
|
||||
|
||||
# Also sync to HERMES_DATA if different
|
||||
if [ -n "$HERMES_CONFIG" ] && [ "$HERMES_CONFIG" != "/root/.hermes/config.yaml" ]; then
|
||||
mkdir -p "$(dirname "$HERMES_CONFIG")" 2>/dev/null
|
||||
cp /root/.hermes/config.yaml "$HERMES_CONFIG" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
chmod 600 /root/.hermes/config.yaml 2>/dev/null || true
|
||||
}
|
||||
|
||||
start_service() {
|
||||
local enabled port bind pty_port api_endpoint api_key model
|
||||
config_load hermes
|
||||
config_get enabled main enabled "0"
|
||||
config_get port main port "3000"
|
||||
config_get bind main bind "lan"
|
||||
config_get pty_port main pty_port "3001"
|
||||
config_get api_endpoint main api_endpoint "https://api.boos.lat/v1"
|
||||
config_get api_key main api_key ""
|
||||
config_get model main model "opus4.6"
|
||||
|
||||
[ "$enabled" = "1" ] || {
|
||||
echo "hermes is disabled. Set enabled to 1 in /etc/config/hermes"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check Python
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
echo "Python not found: $PYTHON_BIN"
|
||||
echo "Please run: hermes-env setup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check hermes binary
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
echo "Hermes not installed. Please run: hermes-env setup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Sync UCI to config.yaml
|
||||
sync_uci_to_yaml
|
||||
|
||||
# Ensure gateway port is not occupied by stale processes
|
||||
_ensure_port_free "$port"
|
||||
|
||||
# Calculate memory limit based on system RAM
|
||||
local mem_limit=""
|
||||
local total_kb
|
||||
total_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
||||
if [ "$total_kb" -ge 3000000 ] 2>/dev/null; then
|
||||
mem_limit="512m"
|
||||
elif [ "$total_kb" -ge 1500000 ] 2>/dev/null; then
|
||||
mem_limit="256m"
|
||||
elif [ "$total_kb" -ge 700000 ] 2>/dev/null; then
|
||||
mem_limit="128m"
|
||||
else
|
||||
mem_limit="64m"
|
||||
fi
|
||||
|
||||
# Start Hermes Gateway (main service, foreground)
|
||||
procd_open_instance "gateway"
|
||||
procd_set_param command "$HERMES_BIN" gateway run \
|
||||
--port "$port" --bind "$bind"
|
||||
procd_set_param env \
|
||||
HOME="/root" \
|
||||
HERMES_HOME="/root/.hermes" \
|
||||
HERMES_CONFIG="/root/.hermes/config.yaml" \
|
||||
PATH="${HERMES_VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
|
||||
PYTHONPATH="${HERMES_VENV}/lib/python3*/site-packages" \
|
||||
HERMES_API_ENDPOINT="$api_endpoint" \
|
||||
HERMES_API_KEY="$api_key" \
|
||||
HERMES_MODEL="$model"
|
||||
procd_set_param respawn 3600 10 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /var/run/hermes.pid
|
||||
procd_close_instance
|
||||
|
||||
# Start Web PTY config terminal (auxiliary service)
|
||||
local pty_token
|
||||
pty_token=$(uci -q get hermes.main.pty_token || echo "")
|
||||
if [ -z "$pty_token" ]; then
|
||||
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
|
||||
uci set hermes.main.pty_token="$pty_token"
|
||||
uci commit hermes 2>/dev/null
|
||||
fi
|
||||
|
||||
procd_open_instance "pty"
|
||||
procd_set_param command "$PYTHON_BIN" /usr/share/hermes/web-pty.py
|
||||
procd_set_param env \
|
||||
HERMES_PTY_PORT="$pty_port" \
|
||||
HERMES_PTY_TOKEN="$pty_token" \
|
||||
HERMES_VENV="$HERMES_VENV" \
|
||||
HERMES_DATA="$HERMES_DATA" \
|
||||
HOME="/root" \
|
||||
PATH="${HERMES_VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /var/run/hermes-pty.pid
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
_ensure_port_free() {
|
||||
local p="$1"
|
||||
local i=0 max_wait=3
|
||||
|
||||
# Check if port is already in use
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
|
||||
# Port is occupied, try to clean up
|
||||
for hermes_pid in $(pgrep -f "hermes.*gateway" 2>/dev/null); do
|
||||
kill "$hermes_pid" 2>/dev/null
|
||||
done
|
||||
|
||||
while [ $i -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Last resort: SIGKILL
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
local port
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
|
||||
# Get all related PIDs
|
||||
local gw_pids=""
|
||||
gw_pids=$(pgrep -f "hermes.*gateway" 2>/dev/null)
|
||||
gw_pids="$gw_pids $(pgrep -f "hermes.*gateway.*run" 2>/dev/null)"
|
||||
|
||||
# Send SIGTERM to all processes
|
||||
for pid in $gw_pids; do
|
||||
[ -n "$pid" ] && kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# Wait for port to be released
|
||||
local wait_count=0
|
||||
local max_wait=4
|
||||
while [ $wait_count -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# Force kill if still running
|
||||
if [ $wait_count -ge $max_wait ]; then
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
if [ -n "$port_pid" ]; then
|
||||
kill -9 "$port_pid" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "hermes"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
stop
|
||||
sleep 1
|
||||
start
|
||||
}
|
||||
|
||||
setup() {
|
||||
echo "Calling hermes-env setup..."
|
||||
/usr/bin/hermes-env setup
|
||||
}
|
||||
|
||||
restart_gateway() {
|
||||
# Restart only the Gateway process, does not affect PTY
|
||||
local port
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
|
||||
# Get gateway PID
|
||||
local port_pid="" gw_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
gw_pid=$(ubus call service list '{"name":"hermes"}' 2>/dev/null | \
|
||||
jsonfilter -e '$.hermes.instances.gateway.pid' 2>/dev/null) || true
|
||||
|
||||
# Send SIGTERM
|
||||
[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null
|
||||
[ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null && kill "$gw_pid" 2>/dev/null
|
||||
|
||||
# Kill any remaining hermes gateway processes
|
||||
for pid in $(pgrep -f "hermes.*gateway" 2>/dev/null) $(pgrep -f "hermes.*gateway.*run" 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# Wait for port to be released
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 3 ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# Force kill if still running
|
||||
if [ $wait_count -ge 3 ]; then
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# If no procd gateway, restart the service
|
||||
if [ -z "$gw_pid" ]; then
|
||||
/etc/init.d/hermes start >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
status_service() {
|
||||
local port pty_port
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
pty_port=$(uci -q get hermes.main.pty_port || echo "3001")
|
||||
|
||||
echo "=== Hermes Agent Service Status ==="
|
||||
|
||||
# Python
|
||||
if [ -x "$PYTHON_BIN" ]; then
|
||||
echo "Python: $($PYTHON_BIN --version 2>/dev/null)"
|
||||
else
|
||||
echo "Python: Not installed"
|
||||
fi
|
||||
|
||||
# Hermes Agent
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
echo "Hermes: $($HERMES_BIN --version 2>/dev/null | head -1)"
|
||||
else
|
||||
echo "Hermes: Not installed"
|
||||
fi
|
||||
|
||||
# Gateway
|
||||
if port_listening "$port"; then
|
||||
echo "Gateway: ✅ Running (:$port)"
|
||||
else
|
||||
echo "Gateway: ⏹ Stopped"
|
||||
fi
|
||||
|
||||
# PTY
|
||||
if port_listening "$pty_port"; then
|
||||
echo "Config PTY: ✅ Running (:$pty_port)"
|
||||
else
|
||||
echo "Config PTY: ⏹ Stopped"
|
||||
fi
|
||||
|
||||
# Config
|
||||
echo "Config: $HERMES_CONFIG"
|
||||
echo "Data Dir: $HERMES_DATA"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# luci-app-hermes — First-boot setup (uci-defaults)
|
||||
# This runs once after package installation
|
||||
|
||||
# Set default values if not already set
|
||||
uci -q set hermes.main.enabled='0' 2>/dev/null
|
||||
uci -q set hermes.main.port='3000' 2>/dev/null
|
||||
uci -q set hermes.main.bind='lan' 2>/dev/null
|
||||
uci -q set hermes.main.api_endpoint='https://api.boos.lat/v1' 2>/dev/null
|
||||
uci -q set hermes.main.api_key='' 2>/dev/null
|
||||
uci -q set hermes.main.model='opus4.6' 2>/dev/null
|
||||
uci -q set hermes.main.pty_port='3001' 2>/dev/null
|
||||
|
||||
# Generate auth token if not set
|
||||
token=$(uci -q get hermes.main.token || echo "")
|
||||
if [ -z "$token" ]; then
|
||||
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || echo "hermes_$(date +%s)")
|
||||
uci -q set hermes.main.token="$token"
|
||||
fi
|
||||
|
||||
# Generate PTY token if not set
|
||||
pty_token=$(uci -q get hermes.main.pty_token || echo "")
|
||||
if [ -z "$pty_token" ]; then
|
||||
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
|
||||
uci -q set hermes.main.pty_token="$pty_token"
|
||||
fi
|
||||
|
||||
uci -q commit hermes 2>/dev/null
|
||||
|
||||
# Create required directories
|
||||
mkdir -p /root/.hermes 2>/dev/null
|
||||
mkdir -p /opt/hermes/data 2>/dev/null
|
||||
|
||||
exit 0
|
||||
Executable
+447
@@ -0,0 +1,447 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# hermes-env — Hermes Agent environment installation management script
|
||||
# Usage: hermes-env setup | check | upgrade
|
||||
# Environment variables: HERMES_VERSION — specify version to install
|
||||
# ============================================================================
|
||||
|
||||
# ── Version and path constants ─────────────────────────────────────────────
|
||||
HERMES_BASE="/opt/hermes"
|
||||
HERMES_VENV="${HERMES_BASE}/venv"
|
||||
HERMES_DATA="${HERMES_BASE}/data"
|
||||
HERMES_BIN="${HERMES_VENV}/bin/hermes"
|
||||
PYTHON_BIN="${HERMES_VENV}/bin/python3"
|
||||
HERMES_CONFIG="/root/.hermes/config.yaml"
|
||||
|
||||
# PyPI mirrors
|
||||
HERMES_PYPI_CN="https://mirrors.aliyun.com/pypi/simple/"
|
||||
HERMES_PYPI_OFFICIAL="https://pypi.org/simple/"
|
||||
HERMES_PYPI_TSINGHUA="https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||
|
||||
# Default API endpoint
|
||||
HERMES_DEFAULT_API="https://api.boos.lat/v1"
|
||||
HERMES_DEFAULT_MODEL="opus4.6"
|
||||
|
||||
export PATH="${HERMES_VENV}/bin:$PATH"
|
||||
|
||||
# ── Logging tools ──────────────────────────────────────────────────────────
|
||||
log_ok() { echo " [✓] $*"; }
|
||||
log_warn() { echo " [!] $*"; }
|
||||
log_err() { echo " [✗] $*"; }
|
||||
log_step() { echo ""; echo "=== $* ==="; }
|
||||
|
||||
# ── OverlayFS fix (iStoreOS Docker /opt conflict) ─────────────────────────
|
||||
_fix_opt() {
|
||||
mkdir -p /opt/hermes/.probe 2>/dev/null && { rmdir /opt/hermes/.probe 2>/dev/null; return 0; }
|
||||
[ -d /overlay/upper/opt ] && mkdir -p /overlay/upper/opt/hermes 2>/dev/null && \
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
_fix_opt
|
||||
|
||||
# ── Basic tools ────────────────────────────────────────────────────────────
|
||||
ensure_dir() { [ -d "$1" ] && return 0; mkdir -p "$1" 2>/dev/null || { log_err "Cannot create directory: $1"; return 1; }; }
|
||||
|
||||
# Check available disk space
|
||||
_df_avail() {
|
||||
local p="$1"
|
||||
while [ -n "$p" ] && [ "$p" != "/" ]; do
|
||||
[ -e "$p" ] && break
|
||||
p="${p%/*}"
|
||||
done
|
||||
[ -z "$p" ] && p="/"
|
||||
df -m "$p" 2>/dev/null | tail -1 | awk '{print $(NF-2)}'
|
||||
}
|
||||
|
||||
check_space() {
|
||||
local path="$1" need_mb="$2" label="$3"
|
||||
local avail
|
||||
avail=$(_df_avail "$path")
|
||||
[ -z "$avail" ] && return 0
|
||||
if [ "$avail" -lt "$need_mb" ]; then
|
||||
log_warn "${label} available space insufficient: ${avail}MB < ${need_mb}MB recommended"
|
||||
else
|
||||
log_ok "${label} available space: ${avail}MB"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download: wget first, curl fallback
|
||||
_download() {
|
||||
local url="$1" out="$2"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q --no-check-certificate -O "$out" "$url" 2>/dev/null
|
||||
elif command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --connect-timeout 15 --max-time 120 -o "$out" "$url" 2>/dev/null
|
||||
else
|
||||
log_err "No download tool available (wget/curl)"; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Try multiple mirrors, return first success
|
||||
_download_first() {
|
||||
local out="$1"; shift
|
||||
for url in "$@"; do
|
||||
[ -z "$url" ] && continue
|
||||
if _download "$url" "$out"; then
|
||||
local sz; sz=$(wc -c < "$out" 2>/dev/null || echo 0)
|
||||
[ "$sz" -gt 1000 ] && return 0
|
||||
fi
|
||||
rm -f "$out"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Architecture detection ─────────────────────────────────────────────────
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) echo "x86_64" ;;
|
||||
aarch64) echo "aarch64" ;;
|
||||
armv7l) echo "armv7l" ;;
|
||||
armv6l) echo "armv6l" ;;
|
||||
*) log_err "Unsupported architecture: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_libc() {
|
||||
[ -f /etc/openwrt_release ] && echo "musl" && return
|
||||
for f in /lib/ld-musl-*.so.1; do [ -f "$f" ] && echo "musl" && return; done
|
||||
echo "glibc"
|
||||
}
|
||||
|
||||
# ── Install Python 3 ──────────────────────────────────────────────────────
|
||||
install_python() {
|
||||
# Check if system python3 is available
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
local ver
|
||||
ver=$(python3 --version 2>/dev/null | awk '{print $2}')
|
||||
log_ok "System Python3 v${ver} found"
|
||||
# Check if it's recent enough (3.8+)
|
||||
local major minor
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
if [ "$major" -ge 3 ] && [ "$minor" -ge 8 ] 2>/dev/null; then
|
||||
log_ok "Python3 v${ver} meets requirements (>= 3.8)"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Python3 v${ver} is too old, need >= 3.8"
|
||||
fi
|
||||
|
||||
log_step "Installing Python 3"
|
||||
|
||||
# Try opkg first
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
opkg install python3 python3-pip python3-venv 2>/dev/null | tail -5 || true
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
log_ok "Python3 installed via opkg: $(python3 --version 2>/dev/null)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: try to install minimal python3
|
||||
opkg install python3-base python3-light 2>/dev/null | tail -5 || true
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
log_ok "Python3 (minimal) installed: $(python3 --version 2>/dev/null)"
|
||||
# Also need pip
|
||||
opkg install python3-pip 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_err "Python3 installation failed"
|
||||
log_err "Please install manually: opkg install python3 python3-pip python3-venv"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Create virtual environment ─────────────────────────────────────────────
|
||||
create_venv() {
|
||||
log_step "Creating Python virtual environment"
|
||||
|
||||
ensure_dir "$HERMES_BASE" || exit 1
|
||||
|
||||
# Remove old venv if exists
|
||||
if [ -d "$HERMES_VENV" ]; then
|
||||
log_warn "Removing old virtual environment..."
|
||||
rm -rf "$HERMES_VENV"
|
||||
fi
|
||||
|
||||
# Create venv using system python3
|
||||
local sys_python
|
||||
sys_python=$(command -v python3 2>/dev/null)
|
||||
if [ -z "$sys_python" ]; then
|
||||
log_err "python3 not found in PATH"
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$sys_python" -m venv "$HERMES_VENV" 2>/dev/null
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
# Try without -m venv (older python)
|
||||
log_warn "python3 -m venv failed, trying virtualenv..."
|
||||
pip3 install virtualenv 2>/dev/null || true
|
||||
"$sys_python" -m virtualenv "$HERMES_VENV" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
log_err "Failed to create virtual environment"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_ok "Virtual environment created at $HERMES_VENV"
|
||||
|
||||
# Upgrade pip
|
||||
log_ok "Upgrading pip..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade pip 2>/dev/null | tail -2 || true
|
||||
|
||||
log_ok "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
}
|
||||
|
||||
# ── Install Hermes Agent ───────────────────────────────────────────────────
|
||||
install_hermes() {
|
||||
log_step "Installing Hermes Agent"
|
||||
|
||||
# Check if already installed
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
local cur
|
||||
cur=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
log_ok "Hermes Agent v${cur} already installed"
|
||||
echo " To reinstall: hermes-env setup --force"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_dir "$HERMES_VENV" || return 1
|
||||
|
||||
# Try PyPI mirrors in order
|
||||
local install_ok=false
|
||||
|
||||
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
|
||||
log_warn "Trying: ${mirror}"
|
||||
if "$PYTHON_BIN" -m pip install hermes-agent \
|
||||
-i "$mirror" \
|
||||
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
|
||||
2>&1 | tail -10; then
|
||||
install_ok=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$install_ok" = "false" ] || [ ! -x "$HERMES_BIN" ]; then
|
||||
# Try without mirror flags
|
||||
log_warn "Mirror install failed, trying default PyPI..."
|
||||
"$PYTHON_BIN" -m pip install hermes-agent 2>&1 | tail -10 || true
|
||||
[ -x "$HERMES_BIN" ] && install_ok=true
|
||||
fi
|
||||
|
||||
if [ "$install_ok" = "false" ]; then
|
||||
log_err "Hermes Agent installation failed"
|
||||
log_err "Please check network connection and try again"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_ok "Hermes Agent installed: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
# ── Initialize Hermes configuration ────────────────────────────────────────
|
||||
init_hermes() {
|
||||
log_step "Initializing Hermes configuration"
|
||||
|
||||
mkdir -p /root/.hermes 2>/dev/null
|
||||
|
||||
# Generate auth token
|
||||
local token
|
||||
token=$(uci -q get hermes.main.token 2>/dev/null || echo "")
|
||||
if [ -z "$token" ]; then
|
||||
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || echo "hermes_$(date +%s)")
|
||||
uci set hermes.main.token="$token" 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Generate PTY token
|
||||
local pty_token
|
||||
pty_token=$(uci -q get hermes.main.pty_token 2>/dev/null || echo "")
|
||||
if [ -z "$pty_token" ]; then
|
||||
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
|
||||
uci set hermes.main.pty_token="$pty_token" 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write default config.yaml
|
||||
local port bind api_endpoint api_key model
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
bind=$(uci -q get hermes.main.bind || echo "lan")
|
||||
api_endpoint=$(uci -q get hermes.main.api_endpoint || echo "$HERMES_DEFAULT_API")
|
||||
api_key=$(uci -q get hermes.main.api_key || echo "")
|
||||
model=$(uci -q get hermes.main.model || echo "$HERMES_DEFAULT_MODEL")
|
||||
|
||||
local listen_host="0.0.0.0"
|
||||
[ "$bind" = "loopback" ] && listen_host="127.0.0.1"
|
||||
|
||||
cat > /root/.hermes/config.yaml << YAMLEOF
|
||||
# Hermes Agent configuration
|
||||
# Initialized by luci-app-hermes on $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
gateway:
|
||||
port: ${port}
|
||||
bind: ${listen_host}
|
||||
auth:
|
||||
mode: token
|
||||
token: ${token}
|
||||
|
||||
api:
|
||||
endpoint: "${api_endpoint}"
|
||||
key: "${api_key}"
|
||||
|
||||
model:
|
||||
primary: "${model}"
|
||||
|
||||
tools:
|
||||
profile: coding
|
||||
|
||||
update:
|
||||
checkOnStart: false
|
||||
YAMLEOF
|
||||
|
||||
chmod 600 /root/.hermes/config.yaml 2>/dev/null || true
|
||||
log_ok "Configuration written to /root/.hermes/config.yaml"
|
||||
}
|
||||
|
||||
# ── Command: setup ─────────────────────────────────────────────────────────
|
||||
cmd_setup() {
|
||||
check_space /opt 100 "Install directory (/opt)"
|
||||
check_space /tmp 100 "Temp directory (/tmp)"
|
||||
|
||||
# Already installed?
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ [!] Hermes Agent already installed ║"
|
||||
echo "║ To reinstall, run: hermes-env setup --force ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Hermes Agent Environment Setup ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Architecture: $(uname -m)"
|
||||
echo " Libc: $(detect_libc)"
|
||||
echo ""
|
||||
|
||||
install_python || exit 1
|
||||
create_venv || exit 1
|
||||
install_hermes || exit 1
|
||||
init_hermes || exit 1
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Setup Complete! ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
echo " Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
echo " Config: /root/.hermes/config.yaml"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Edit /etc/config/hermes to set your API key"
|
||||
echo " 2. Run: /etc/init.d/hermes enable"
|
||||
echo " 3. Run: /etc/init.d/hermes start"
|
||||
echo ""
|
||||
echo " Or use the LuCI web interface to configure and start."
|
||||
}
|
||||
|
||||
# ── Command: check ─────────────────────────────────────────────────────────
|
||||
cmd_check() {
|
||||
echo "=== Hermes Environment Check ==="
|
||||
|
||||
if [ -x "$PYTHON_BIN" ]; then
|
||||
echo "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
else
|
||||
echo "Python: Not installed (virtual env)"
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "System Py: $(python3 --version 2>/dev/null)"
|
||||
else
|
||||
echo "System Py: Not found"
|
||||
fi
|
||||
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
echo "Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
else
|
||||
echo "Hermes: Not installed"
|
||||
fi
|
||||
|
||||
echo "VENV: $HERMES_VENV"
|
||||
echo "Config: $HERMES_CONFIG"
|
||||
|
||||
if [ -f "$HERMES_CONFIG" ]; then
|
||||
echo "Config OK: Yes"
|
||||
else
|
||||
echo "Config OK: No (will be created on start)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: upgrade ───────────────────────────────────────────────────────
|
||||
cmd_upgrade() {
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
log_err "Hermes Agent not installed. Run: hermes-env setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "Upgrading Hermes Agent"
|
||||
|
||||
local upgrade_ok=false
|
||||
|
||||
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
|
||||
log_warn "Trying: ${mirror}"
|
||||
if "$PYTHON_BIN" -m pip install --upgrade hermes-agent \
|
||||
-i "$mirror" \
|
||||
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
|
||||
2>&1 | tail -10; then
|
||||
upgrade_ok=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$upgrade_ok" = "false" ]; then
|
||||
log_warn "Mirror install failed, trying default PyPI..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade hermes-agent 2>&1 | tail -10 || true
|
||||
[ -x "$HERMES_BIN" ] && upgrade_ok=true
|
||||
fi
|
||||
|
||||
if [ "$upgrade_ok" = "true" ] && [ -x "$HERMES_BIN" ]; then
|
||||
log_ok "Hermes upgraded: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
else
|
||||
log_err "Upgrade failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: uninstall ─────────────────────────────────────────────────────
|
||||
cmd_uninstall() {
|
||||
log_step "Uninstalling Hermes Agent"
|
||||
|
||||
/etc/init.d/hermes stop 2>/dev/null || true
|
||||
/etc/init.d/hermes disable 2>/dev/null || true
|
||||
uci set hermes.main.enabled=0 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
|
||||
[ -d "$HERMES_BASE" ] && { rm -rf "$HERMES_BASE" 2>/dev/null || true; }
|
||||
[ -d "/root/.hermes" ] && { rm -rf "/root/.hermes" 2>/dev/null || true; }
|
||||
|
||||
log_ok "Hermes Agent uninstalled"
|
||||
}
|
||||
|
||||
# ── Main entry ─────────────────────────────────────────────────────────────
|
||||
case "${1:-setup}" in
|
||||
setup) cmd_setup ;;
|
||||
check) cmd_check ;;
|
||||
upgrade) cmd_upgrade ;;
|
||||
uninstall) cmd_uninstall ;;
|
||||
*)
|
||||
echo "Usage: hermes-env {setup|check|upgrade|uninstall}"
|
||||
echo ""
|
||||
echo " setup Install Python + Hermes Agent (default)"
|
||||
echo " check Check installation status"
|
||||
echo " upgrade Upgrade Hermes Agent to latest version"
|
||||
echo " uninstall Remove all Hermes files"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
ACTION=${1}
|
||||
shift 1
|
||||
|
||||
do_install() {
|
||||
local port=$(uci get hermes.@main[0].port 2>/dev/null | tr -d '\n\r')
|
||||
local data=$(uci get hermes.@main[0].data_path 2>/dev/null | tr -d '\n\r')
|
||||
local workspace=$(uci get hermes.@main[0].workspace_path 2>/dev/null | tr -d '\n\r')
|
||||
|
||||
local image_name="linkease/hermes:latest"
|
||||
echo "docker pull ${image_name}"
|
||||
docker pull ${image_name}
|
||||
docker rm -f hermes
|
||||
|
||||
[ -z "$port" ] && port=8787
|
||||
|
||||
local cmd="docker run --restart=unless-stopped -d"
|
||||
|
||||
[ -n "$data" ] && cmd="$cmd -v \"$data:/data/.hermes\""
|
||||
[ -n "$workspace" ] && cmd="$cmd -v \"$workspace:/workspace\""
|
||||
|
||||
cmd="$cmd -p $port:8787"
|
||||
|
||||
local tz=$(uci get system.@system[0].zonename 2>/dev/null | sed 's/ /_/g')
|
||||
[ -z "$tz" ] || cmd="$cmd -e TZ=$tz"
|
||||
|
||||
cmd="$cmd --name hermes \"$image_name\""
|
||||
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 sub-command"
|
||||
echo "where sub-command is one of:"
|
||||
echo " install Install the hermes"
|
||||
echo " upgrade Upgrade the hermes"
|
||||
echo " rm/start/stop/restart Remove/Start/Stop/Restart the hermes"
|
||||
echo " status Hermes status"
|
||||
echo " port Hermes port"
|
||||
}
|
||||
|
||||
case ${ACTION} in
|
||||
"install")
|
||||
do_install
|
||||
;;
|
||||
"upgrade")
|
||||
do_install
|
||||
;;
|
||||
"rm")
|
||||
docker rm -f hermes
|
||||
;;
|
||||
"start" | "stop" | "restart")
|
||||
docker ${ACTION} hermes
|
||||
;;
|
||||
"status")
|
||||
docker ps --all -f 'name=^/hermes$' --format '{{.State}}'
|
||||
;;
|
||||
"port")
|
||||
docker ps --all -f 'name=^/hermes$' --format '{{.Ports}}' | grep -om1 '0.0.0.0:[0-9]*->8787/tcp' | sed 's/0.0.0.0:\([0-9]*\)->.*/\1/'
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
#!/bin/sh
|
||||
# luci-app-hermes — Backend helper for JS view
|
||||
# Called via ubus file.exec from the LuCI frontend
|
||||
|
||||
. /lib/functions.sh 2>/dev/null || true
|
||||
|
||||
HERMES_VENV="/opt/hermes/venv"
|
||||
HERMES_BIN="${HERMES_VENV}/bin/hermes"
|
||||
HERMES_DATA="/opt/hermes/data"
|
||||
HERMES_CONFIG="/root/.hermes/config.yaml"
|
||||
|
||||
# ── JSON helper: escape string for JSON output ──
|
||||
json_esc() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' ' '
|
||||
}
|
||||
|
||||
# ── Port listening check (ss or netstat) ──
|
||||
port_listening() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
fi
|
||||
}
|
||||
|
||||
pid_by_port() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STATUS — gather all status info as JSON
|
||||
# ═══════════════════════════════════════════
|
||||
status)
|
||||
enabled=$(uci -q get hermes.main.enabled || echo "0")
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
pty_port=$(uci -q get hermes.main.pty_port || echo "3001")
|
||||
|
||||
# Validate ports
|
||||
case "$port" in *[!0-9]*) port="3000" ;; esac
|
||||
case "$pty_port" in *[!0-9]*) pty_port="3001" ;; esac
|
||||
|
||||
# Gateway check
|
||||
gw_running=false
|
||||
gw_starting=false
|
||||
port_listening "$port" && gw_running=true
|
||||
if [ "$gw_running" = "false" ] && [ "$enabled" = "1" ]; then
|
||||
pgrep -f 'hermes.*gateway' >/dev/null 2>&1 && gw_starting=true
|
||||
fi
|
||||
|
||||
# PTY check
|
||||
pty_running=false
|
||||
port_listening "$pty_port" && pty_running=true
|
||||
|
||||
# Python version
|
||||
python_ver=""
|
||||
if [ -x "${HERMES_VENV}/bin/python3" ]; then
|
||||
python_ver=$("${HERMES_VENV}/bin/python3" --version 2>/dev/null | tr -d '[:space:]')
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python_ver=$(python3 --version 2>/dev/null | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
# Hermes version
|
||||
hermes_ver=""
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
hermes_ver=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
# Plugin version
|
||||
plugin_ver=""
|
||||
[ -f "/usr/share/hermes/VERSION" ] && plugin_ver=$(cat /usr/share/hermes/VERSION 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
# PID, memory, uptime
|
||||
pid="" mem_kb=0 uptime_str=""
|
||||
if [ "$gw_running" = "true" ]; then
|
||||
pid=$(pid_by_port "$port")
|
||||
if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then
|
||||
mem_kb=$(awk '/VmRSS/{print $2}' "/proc/$pid/status" 2>/dev/null)
|
||||
[ -z "$mem_kb" ] && mem_kb=0
|
||||
# Calculate uptime from /proc/$pid directory mtime
|
||||
start_ts=$(date -r "/proc/$pid" +%s 2>/dev/null)
|
||||
if [ -n "$start_ts" ]; then
|
||||
now=$(date +%s)
|
||||
diff=$((now - start_ts))
|
||||
h=$((diff / 3600)) m=$(((diff % 3600) / 60)) s=$((diff % 60))
|
||||
if [ $h -gt 0 ]; then uptime_str="${h}h ${m}m"
|
||||
elif [ $m -gt 0 ]; then uptime_str="${m}m ${s}s"
|
||||
else uptime_str="${s}s"; fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Active model (from config.yaml)
|
||||
active_model=""
|
||||
if [ -f "$HERMES_CONFIG" ]; then
|
||||
active_model=$(sed -n 's/.*primary: *"\{0,1\}\([^"]*\)"\{0,1\}/\1/p' "$HERMES_CONFIG" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
cat <<JSON
|
||||
{
|
||||
"enabled": "$enabled",
|
||||
"port": "$port",
|
||||
"pty_port": "$pty_port",
|
||||
"gateway_running": $gw_running,
|
||||
"gateway_starting": $gw_starting,
|
||||
"pty_running": $pty_running,
|
||||
"python_version": "$python_ver",
|
||||
"hermes_version": "$hermes_ver",
|
||||
"plugin_version": "$plugin_ver",
|
||||
"pid": "$pid",
|
||||
"memory_kb": ${mem_kb:-0},
|
||||
"uptime": "$uptime_str",
|
||||
"active_model": "$(json_esc "$active_model")"
|
||||
}
|
||||
JSON
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# SETUP — install Python + Hermes Agent
|
||||
# ═══════════════════════════════════════════
|
||||
setup)
|
||||
version="${2:-stable}"
|
||||
# Clean old state
|
||||
: > /tmp/hermes-setup.log
|
||||
: > /tmp/hermes-setup.exit
|
||||
: > /tmp/hermes-setup.pid
|
||||
|
||||
( /usr/bin/hermes-env setup > /tmp/hermes-setup.log 2>&1
|
||||
RC=$?
|
||||
echo $RC > /tmp/hermes-setup.exit
|
||||
if [ $RC -eq 0 ]; then
|
||||
uci set hermes.main.enabled=1
|
||||
uci commit hermes
|
||||
/etc/init.d/hermes enable 2>/dev/null
|
||||
sleep 1
|
||||
/etc/init.d/hermes start >> /tmp/hermes-setup.log 2>&1
|
||||
fi
|
||||
) &
|
||||
echo $! > /tmp/hermes-setup.pid
|
||||
|
||||
echo '{"status":"ok","message":"setup started"}'
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# SETUP_LOG — poll setup progress
|
||||
# ═══════════════════════════════════════════
|
||||
setup_log)
|
||||
log=""
|
||||
[ -f /tmp/hermes-setup.log ] && log=$(cat /tmp/hermes-setup.log 2>/dev/null)
|
||||
|
||||
running=false
|
||||
if [ -f /tmp/hermes-setup.pid ]; then
|
||||
pid=$(cat /tmp/hermes-setup.pid 2>/dev/null | tr -d '[:space:]')
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && running=true
|
||||
fi
|
||||
|
||||
exit_code=-1
|
||||
if [ "$running" = "false" ] && [ -f /tmp/hermes-setup.exit ]; then
|
||||
exit_code=$(cat /tmp/hermes-setup.exit 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$exit_code" ] && exit_code=-1
|
||||
fi
|
||||
|
||||
state="idle"
|
||||
if [ "$running" = "true" ]; then state="running"
|
||||
elif [ "$exit_code" = "0" ]; then state="success"
|
||||
elif [ "$exit_code" -gt 0 ] 2>/dev/null; then state="failed"
|
||||
fi
|
||||
|
||||
# Escape log for JSON
|
||||
log_escaped=$(printf '%s' "$log" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' '\\' | sed 's/\\/\\n/g')
|
||||
|
||||
printf '{"state":"%s","exit_code":%s,"log":"%s"}\n' "$state" "${exit_code}" "$log_escaped"
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# GET_TOKEN — return gateway and PTY tokens
|
||||
# ═══════════════════════════════════════════
|
||||
get_token)
|
||||
token=$(uci -q get hermes.main.token || echo "")
|
||||
pty_token=$(uci -q get hermes.main.pty_token || echo "")
|
||||
printf '{"token":"%s","pty_token":"%s"}\n' "$token" "$pty_token"
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# UNINSTALL — remove environment
|
||||
# ═══════════════════════════════════════════
|
||||
uninstall)
|
||||
/etc/init.d/hermes stop >/dev/null 2>&1
|
||||
/etc/init.d/hermes disable 2>/dev/null
|
||||
uci set hermes.main.enabled=0
|
||||
uci commit hermes 2>/dev/null
|
||||
[ -d /opt/hermes ] && { find /opt/hermes -delete 2>/dev/null || true; }
|
||||
[ -d /root/.hermes ] && { find /root/.hermes -delete 2>/dev/null || true; }
|
||||
: > /tmp/hermes-setup.log 2>/dev/null
|
||||
: > /tmp/hermes-setup.pid 2>/dev/null
|
||||
: > /tmp/hermes-setup.exit 2>/dev/null
|
||||
echo '{"status":"ok","message":"uninstalled"}'
|
||||
;;
|
||||
|
||||
*)
|
||||
echo '{"status":"error","message":"unknown command"}'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Hermes Agent Terminal</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:#1a1b26;font-family:system-ui,-apple-system,sans-serif}
|
||||
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#7aa2f7;z-index:100;background:#1a1b26;transition:opacity .4s}
|
||||
#loading.hidden{opacity:0;pointer-events:none}
|
||||
#loading .spinner{width:40px;height:40px;border:3px solid #2a2b3d;border-top:3px solid #ff9e64;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:16px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
#loading h2{font-size:18px;font-weight:500;margin-bottom:8px}
|
||||
#loading p{font-size:13px;color:#565f89}
|
||||
#loading .debug{display:none}
|
||||
#terminal-container{width:100%;height:calc(100% - 36px);position:absolute;top:36px;left:0;right:0;bottom:0;padding:4px;overflow:hidden}
|
||||
.xterm{height:100%!important}
|
||||
.xterm-viewport::-webkit-scrollbar{width:8px}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb{background:#2a2b3d;border-radius:4px}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover{background:#3b3d57}
|
||||
#topbar{position:fixed;top:0;left:0;right:0;height:36px;background:#16161e;border-bottom:1px solid #2a2b3d;display:flex;align-items:center;padding:0 12px;z-index:50;gap:8px}
|
||||
#topbar .logo{font-size:14px;color:#ff9e64;font-weight:600}
|
||||
#topbar .status{font-size:11px;padding:2px 8px;border-radius:10px;background:#2a2b3d}
|
||||
#topbar .status.connected{color:#9ece6a}
|
||||
#topbar .status.disconnected{color:#f7768e}
|
||||
#topbar .btn{font-size:12px;color:#7aa2f7;background:none;border:1px solid #3b3d57;border-radius:4px;padding:3px 10px;cursor:pointer;margin-left:auto}
|
||||
#topbar .btn:hover{background:#2a2b3d}
|
||||
#reconnect-overlay{display:none;position:absolute;inset:0;background:rgba(26,27,38,.9);z-index:80;flex-direction:column;align-items:center;justify-content:center;color:#c0caf5}
|
||||
#reconnect-overlay.show{display:flex}
|
||||
#reconnect-overlay button{margin-top:16px;padding:8px 24px;background:#ff9e64;color:#1a1b26;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:600}
|
||||
#reconnect-overlay button:hover{background:#e0884a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<span class="logo">🤖 Hermes Agent Terminal</span>
|
||||
<span id="status" class="status disconnected">● Connecting...</span>
|
||||
<button class="btn" onclick="location.reload()" title="Restart terminal">🔄 Restart</button>
|
||||
</div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<h2>🤖 Hermes Agent Terminal</h2>
|
||||
<p id="loading-msg">Connecting to terminal...</p>
|
||||
<div id="loading-debug" class="debug"></div>
|
||||
</div>
|
||||
|
||||
<div id="terminal-container"></div>
|
||||
|
||||
<div id="reconnect-overlay">
|
||||
<p style="font-size:16px;margin-bottom:4px">⚡ Connection Lost</p>
|
||||
<p style="font-size:13px;color:#565f89">Terminal session ended or connection interrupted</p>
|
||||
<button onclick="location.reload()">🔄 Restart</button>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="/lib/xterm.min.css">
|
||||
<script src="/lib/xterm.min.js"></script>
|
||||
<script src="/lib/addon-fit.min.js"></script>
|
||||
<script src="/lib/addon-web-links.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const statusEl = document.getElementById('status');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const reconnectEl = document.getElementById('reconnect-overlay');
|
||||
const containerEl = document.getElementById('terminal-container');
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const loadingDebug = document.getElementById('loading-debug');
|
||||
|
||||
const term = new window.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 13,
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", "Source Code Pro", Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#ff9e64',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
}
|
||||
});
|
||||
|
||||
const fitAddon = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
try {
|
||||
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
} catch(e) {}
|
||||
|
||||
term.open(containerEl);
|
||||
|
||||
containerEl.addEventListener('click', function() { term.focus(); });
|
||||
document.addEventListener('click', function() { term.focus(); });
|
||||
|
||||
function doFit() {
|
||||
try { fitAddon.fit(); } catch(e) {}
|
||||
try { term.scrollToBottom(); } catch(e) {}
|
||||
}
|
||||
doFit();
|
||||
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const ptyToken = urlParams.get('token') || urlParams.get('pty_token') || '';
|
||||
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY = Infinity;
|
||||
let retryTimer = null;
|
||||
let wasEverConnected = false;
|
||||
let pingTimer = null;
|
||||
|
||||
var _readyToConnect = false;
|
||||
function _tryConnect() {
|
||||
if (_readyToConnect) return;
|
||||
try { fitAddon.fit(); } catch(e) {}
|
||||
if (term.cols > 40) {
|
||||
_readyToConnect = true;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(function() {
|
||||
setTimeout(function() { doFit(); _tryConnect(); }, 50);
|
||||
}).observe(containerEl);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
||||
if (ws) { try { ws.close(); } catch(e){} ws = null; }
|
||||
|
||||
retryCount++;
|
||||
loadingMsg.textContent = retryCount > 1
|
||||
? 'Reconnecting... (attempt ' + retryCount + ')'
|
||||
: 'Connecting to terminal...';
|
||||
loadingDebug.textContent = wsUrl;
|
||||
|
||||
fetch('/health').then(function(r) {
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
doWebSocket();
|
||||
}).catch(function(err) {
|
||||
scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
function doWebSocket() {
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch(e) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
var connectTimeout = setTimeout(function() {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
try { ws.close(); } catch(e){}
|
||||
scheduleRetry();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = function() {
|
||||
clearTimeout(connectTimeout);
|
||||
connected = true;
|
||||
var isReconnect = wasEverConnected;
|
||||
wasEverConnected = true;
|
||||
retryCount = 0;
|
||||
loadingEl.classList.add('hidden');
|
||||
reconnectEl.classList.remove('show');
|
||||
statusEl.textContent = '● Connected';
|
||||
statusEl.className = 'status connected';
|
||||
if (isReconnect) {
|
||||
term.clear();
|
||||
term.write('\x1b[33m⚡ Reconnected\x1b[0m\r\n\r\n');
|
||||
}
|
||||
setTimeout(doFit, 50);
|
||||
term.focus();
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch(e) {}
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
pingTimer = setInterval(function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
|
||||
}
|
||||
}, 20000);
|
||||
};
|
||||
|
||||
ws.onmessage = function(ev) {
|
||||
if (typeof ev.data === 'string') {
|
||||
if (ev.data.indexOf('"type":"pong"') !== -1) return;
|
||||
term.write(ev.data, function() { term.scrollToBottom(); });
|
||||
} else if (ev.data instanceof Blob) {
|
||||
ev.data.text().then(function(text) {
|
||||
if (text.indexOf('"type":"pong"') !== -1) return;
|
||||
term.write(text, function() { term.scrollToBottom(); });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
||||
connected = false;
|
||||
statusEl.textContent = '● Disconnected';
|
||||
statusEl.className = 'status disconnected';
|
||||
if (wasEverConnected) {
|
||||
statusEl.textContent = '● Reconnecting...';
|
||||
retryCount = 0;
|
||||
setTimeout(function() { connect(); }, 2000);
|
||||
} else {
|
||||
scheduleRetry();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
connected = false;
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
|
||||
loadingMsg.textContent = 'Waiting for service... retrying in ' + Math.ceil(delay/1000) + 's';
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stdin', data: data }));
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(function(size) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
function _onLoad() {
|
||||
doFit();
|
||||
_tryConnect();
|
||||
}
|
||||
if (document.readyState === 'complete') {
|
||||
_onLoad();
|
||||
} else {
|
||||
window.addEventListener('load', _onLoad);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
luci-app-hermes — Web PTY Server (Python implementation)
|
||||
Provides WebSocket-based terminal access for Hermes Agent configuration.
|
||||
Replaces the Node.js web-pty.js from luci-app-openclaw.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import struct
|
||||
import hashlib
|
||||
import base64
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import select
|
||||
import pty
|
||||
import fcntl
|
||||
import termios
|
||||
import urllib.parse
|
||||
|
||||
# ── Configuration ──
|
||||
PORT = int(os.environ.get('HERMES_PTY_PORT', '3001'))
|
||||
HOST = os.environ.get('HERMES_PTY_HOST', '0.0.0.0')
|
||||
AUTH_TOKEN = os.environ.get('HERMES_PTY_TOKEN', '')
|
||||
HERMES_VENV = os.environ.get('HERMES_VENV', '/opt/hermes/venv')
|
||||
HERMES_DATA = os.environ.get('HERMES_DATA', '/opt/hermes/data')
|
||||
MAX_SESSIONS = int(os.environ.get('HERMES_MAX_SESSIONS', '5'))
|
||||
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ui')
|
||||
|
||||
# Load token from UCI if not set via env
|
||||
if not AUTH_TOKEN:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['uci', '-q', 'get', 'hermes.main.pty_token'],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
AUTH_TOKEN = result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── WebSocket helpers (RFC 6455) ──
|
||||
|
||||
def decode_ws_frame(data):
|
||||
"""Decode a WebSocket frame. Returns (opcode, payload, total_consumed) or None."""
|
||||
if len(data) < 2:
|
||||
return None
|
||||
opcode = data[0] & 0x0f
|
||||
masked = bool(data[1] & 0x80)
|
||||
payload_len = data[1] & 0x7f
|
||||
offset = 2
|
||||
|
||||
if payload_len == 126:
|
||||
if len(data) < 4:
|
||||
return None
|
||||
payload_len = struct.unpack('>H', data[2:4])[0]
|
||||
offset = 4
|
||||
elif payload_len == 127:
|
||||
if len(data) < 10:
|
||||
return None
|
||||
payload_len = struct.unpack('>Q', data[2:10])[0]
|
||||
offset = 10
|
||||
|
||||
mask = None
|
||||
if masked:
|
||||
if len(data) < offset + 4:
|
||||
return None
|
||||
mask = data[offset:offset + 4]
|
||||
offset += 4
|
||||
|
||||
if len(data) < offset + payload_len:
|
||||
return None
|
||||
|
||||
payload = bytearray(data[offset:offset + payload_len])
|
||||
if mask:
|
||||
for i in range(len(payload)):
|
||||
payload[i] ^= mask[i & 3]
|
||||
|
||||
return (opcode, bytes(payload), offset + payload_len)
|
||||
|
||||
|
||||
def encode_ws_frame(data, opcode=0x01):
|
||||
"""Encode data into a WebSocket frame."""
|
||||
if isinstance(data, str):
|
||||
payload = data.encode('utf-8')
|
||||
else:
|
||||
payload = data
|
||||
length = len(payload)
|
||||
|
||||
if length < 126:
|
||||
header = struct.pack('>BB', 0x80 | opcode, length)
|
||||
elif length < 65536:
|
||||
header = struct.pack('>BBH', 0x80 | opcode, 126, length)
|
||||
else:
|
||||
header = struct.pack('>BBQ', 0x80 | opcode, 127, length)
|
||||
|
||||
return header + payload
|
||||
|
||||
|
||||
# ── PTY Session ──
|
||||
|
||||
active_sessions = 0
|
||||
|
||||
|
||||
class PtySession:
|
||||
def __init__(self, request_handler):
|
||||
global active_sessions
|
||||
self.handler = request_handler
|
||||
self.proc = None
|
||||
self.cols = 80
|
||||
self.rows = 24
|
||||
self.alive = True
|
||||
self.buffer = b''
|
||||
self._spawn_fail_count = 0
|
||||
self._MAX_SPAWN_RETRIES = 5
|
||||
self._ping_timer = None
|
||||
self._pong_received = True
|
||||
|
||||
active_sessions += 1
|
||||
print(f"[hermes-pty] Session created (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
|
||||
self._spawn_pty()
|
||||
|
||||
def _spawn_pty(self):
|
||||
"""Spawn the shell process with a PTY."""
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
'TERM': 'xterm-256color',
|
||||
'COLUMNS': str(self.cols),
|
||||
'LINES': str(self.rows),
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'HERMES_VENV': HERMES_VENV,
|
||||
'HERMES_DATA': HERMES_DATA,
|
||||
'HOME': '/root',
|
||||
'PATH': f"{HERMES_VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
})
|
||||
|
||||
try:
|
||||
# Create PTY
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Set terminal size
|
||||
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
# Spawn shell
|
||||
self.proc = subprocess.Popen(
|
||||
['/bin/sh', '-l'],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
self.master_fd = master_fd
|
||||
self._spawn_fail_count = 0
|
||||
|
||||
# Set master_fd to non-blocking
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Start reader thread
|
||||
self._reader_thread = threading.Thread(target=self._read_pty, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
print(f"[hermes-pty] Shell spawned (PID: {self.proc.pid})", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[hermes-pty] Failed to spawn shell: {e}", flush=True)
|
||||
self._cleanup()
|
||||
|
||||
def _read_pty(self):
|
||||
"""Read from PTY and send to WebSocket."""
|
||||
while self.alive and self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
r, _, _ = select.select([self.master_fd], [], [], 0.1)
|
||||
if r:
|
||||
data = os.read(self.master_fd, 4096)
|
||||
if data:
|
||||
self._send_to_client(data)
|
||||
except (OSError, IOError):
|
||||
break
|
||||
except Exception as e:
|
||||
if self.alive:
|
||||
print(f"[hermes-pty] Read error: {e}", flush=True)
|
||||
break
|
||||
|
||||
if self.alive:
|
||||
print(f"[hermes-pty] Shell exited", flush=True)
|
||||
self._spawn_fail_count += 1
|
||||
if self._spawn_fail_count > self._MAX_SPAWN_RETRIES:
|
||||
print(f"[hermes-pty] Shell failed {self._spawn_fail_count} times, stopping", flush=True)
|
||||
self._send_to_client(b'\r\n\x1b[31mShell failed to start after multiple attempts.\x1b[0m\r\n')
|
||||
return
|
||||
|
||||
# Auto-restart
|
||||
self._send_to_client(b'\r\n\x1b[33mShell exited, restarting...\x1b[0m\r\n')
|
||||
time.sleep(1)
|
||||
if self.alive:
|
||||
self._spawn_pty()
|
||||
|
||||
def _send_to_client(self, data):
|
||||
"""Send data to WebSocket client."""
|
||||
if not self.alive:
|
||||
return
|
||||
try:
|
||||
frame = encode_ws_frame(data, 0x01)
|
||||
self.handler.request.sendall(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_message(self, text):
|
||||
"""Handle incoming WebSocket text message."""
|
||||
try:
|
||||
msg = json.loads(text)
|
||||
msg_type = msg.get('type', '')
|
||||
|
||||
if msg_type == 'stdin':
|
||||
data = msg.get('data', '')
|
||||
# Clean bracketed paste escape sequences
|
||||
data = data.replace('\x1b[?2004l', '').replace('\x1b[?2004h', '')
|
||||
data = data.replace('\x1b[200~', '').replace('\x1b[201~', '')
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, data.encode('utf-8'))
|
||||
|
||||
elif msg_type == 'resize':
|
||||
self.cols = msg.get('cols', 80)
|
||||
self.rows = msg.get('rows', 24)
|
||||
if self.master_fd:
|
||||
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif msg_type == 'ping':
|
||||
self._send_to_client(json.dumps({'type': 'pong'}))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Plain text, send directly to PTY
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, text.encode('utf-8'))
|
||||
|
||||
def handle_binary(self, data):
|
||||
"""Handle incoming binary WebSocket data."""
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, data)
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up session resources."""
|
||||
if not self.alive:
|
||||
return
|
||||
self.alive = False
|
||||
|
||||
global active_sessions
|
||||
active_sessions = max(0, active_sessions - 1)
|
||||
print(f"[hermes-pty] Session ended (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
os.kill(-self.proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, 'master_fd'):
|
||||
os.close(self.master_fd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
self._cleanup()
|
||||
|
||||
|
||||
# ── WebSocket Upgrade Handler ──
|
||||
|
||||
class WebSocketHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP + WebSocket upgrade handler."""
|
||||
|
||||
sessions = []
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Override to add prefix."""
|
||||
print(f"[hermes-pty] {args[0] if args else format}", flush=True)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', '*')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
|
||||
if path == '/health':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({
|
||||
'status': 'ok',
|
||||
'port': PORT,
|
||||
'uptime': time.time()
|
||||
}).encode())
|
||||
return
|
||||
|
||||
# Serve static files from UI_DIR
|
||||
if path == '/' or path == '':
|
||||
path = '/index.html'
|
||||
|
||||
# Security: prevent directory traversal
|
||||
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
|
||||
if not full_path.startswith(UI_DIR):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Determine content type
|
||||
ext = os.path.splitext(full_path)[1]
|
||||
content_types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
ct = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ct)
|
||||
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('X-Frame-Options', 'ALLOWALL')
|
||||
self.send_header('Content-Security-Policy',
|
||||
"default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
except FileNotFoundError:
|
||||
# Fallback to index.html for SPA routing
|
||||
try:
|
||||
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET_ws(self):
|
||||
"""Handle WebSocket upgrade for GET requests to /ws."""
|
||||
self._do_websocket_upgrade()
|
||||
|
||||
def _do_websocket_upgrade(self):
|
||||
"""Perform WebSocket handshake and start PTY session."""
|
||||
# Authenticate
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
client_token = params.get('token', [''])[0]
|
||||
|
||||
if AUTH_TOKEN and client_token != AUTH_TOKEN:
|
||||
print(f"[hermes-pty] Auth failed from {self.client_address[0]}", flush=True)
|
||||
self.send_error(403, 'Forbidden')
|
||||
return
|
||||
|
||||
# Check session limit
|
||||
if active_sessions >= MAX_SESSIONS:
|
||||
print(f"[hermes-pty] Max sessions reached ({active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
self.send_error(503, 'Service Unavailable')
|
||||
return
|
||||
|
||||
# WebSocket handshake
|
||||
key = self.headers.get('Sec-WebSocket-Key', '')
|
||||
if not key:
|
||||
self.send_error(400, 'Missing Sec-WebSocket-Key')
|
||||
return
|
||||
|
||||
accept = base64.b64encode(
|
||||
hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()
|
||||
).decode()
|
||||
|
||||
response = (
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
'Upgrade: websocket\r\n'
|
||||
'Connection: Upgrade\r\n'
|
||||
f'Sec-WebSocket-Accept: {accept}\r\n'
|
||||
'\r\n'
|
||||
)
|
||||
self.wfile.write(response.encode())
|
||||
self.wfile.flush()
|
||||
|
||||
# Create PTY session
|
||||
session = PtySession(self)
|
||||
self.__class__.sessions.append(session)
|
||||
|
||||
# Read WebSocket frames
|
||||
buffer = b''
|
||||
while session.alive:
|
||||
try:
|
||||
self.request.setblocking(True)
|
||||
self.request.settimeout(1.0)
|
||||
data = self.request.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
|
||||
while len(buffer) > 0:
|
||||
result = decode_ws_frame(buffer)
|
||||
if result is None:
|
||||
break
|
||||
opcode, payload, consumed = result
|
||||
buffer = buffer[consumed:]
|
||||
|
||||
if opcode == 0x01: # Text
|
||||
session.handle_message(payload.decode('utf-8', errors='replace'))
|
||||
elif opcode == 0x02: # Binary
|
||||
session.handle_binary(payload)
|
||||
elif opcode == 0x08: # Close
|
||||
print("[hermes-pty] WS close frame received", flush=True)
|
||||
session._cleanup()
|
||||
return
|
||||
elif opcode == 0x09: # Ping
|
||||
self.request.sendall(encode_ws_frame(payload, 0x0a))
|
||||
elif opcode == 0x0a: # Pong
|
||||
session._pong_received = True
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except (ConnectionResetError, BrokenPipeError, OSError):
|
||||
break
|
||||
except Exception as e:
|
||||
if session.alive:
|
||||
print(f"[hermes-pty] Read error: {e}", flush=True)
|
||||
break
|
||||
|
||||
session._cleanup()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if path == '/ws' or path.startswith('/ws?'):
|
||||
self._do_websocket_upgrade()
|
||||
else:
|
||||
# Serve static files
|
||||
if path == '/' or path == '':
|
||||
path = '/index.html'
|
||||
|
||||
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
|
||||
if not full_path.startswith(UI_DIR):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
ext = os.path.splitext(full_path)[1]
|
||||
content_types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
ct = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ct)
|
||||
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
# ── Main ──
|
||||
|
||||
if __name__ == '__main__':
|
||||
import socket
|
||||
|
||||
socketserver.TCPServer.allow_reuse_address = True
|
||||
|
||||
class ReusableTCPServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def handle_request_noblock(self):
|
||||
try:
|
||||
request, client_address = self.get_request()
|
||||
except OSError:
|
||||
return
|
||||
if self.verify_request(request, client_address):
|
||||
try:
|
||||
self.process_request(request, client_address)
|
||||
except Exception:
|
||||
self.handle_error(request, client_address)
|
||||
self.shutdown_request(request)
|
||||
else:
|
||||
self.shutdown_request(request)
|
||||
|
||||
server = ReusableTCPServer((HOST, PORT), WebSocketHandler)
|
||||
print(f"[hermes-pty] HTTP listening on {HOST}:{PORT}", flush=True)
|
||||
print(f"[hermes-pty] UI directory: {UI_DIR}", flush=True)
|
||||
|
||||
def shutdown(signum, frame):
|
||||
print("[hermes-pty] Shutdown", flush=True)
|
||||
server.shutdown()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
shutdown(None, None)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"admin/services/hermes": {
|
||||
"title": "Hermes Agent",
|
||||
"order": 90,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hermes"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-hermes"],
|
||||
"uci": { "hermes": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
{
|
||||
"luci-app-hermes": {
|
||||
"description": "Grant UCI access for luci-app-hermes",
|
||||
"description": "Grant access for luci-app-hermes",
|
||||
"read": {
|
||||
"uci": [ "hermes" ]
|
||||
"uci": ["hermes"],
|
||||
"ubus": {
|
||||
"service": ["list"],
|
||||
"file": ["exec", "read", "stat"]
|
||||
},
|
||||
"file": {
|
||||
"/usr/share/hermes/luci-helper": ["exec"],
|
||||
"/etc/init.d/hermes": ["exec"],
|
||||
"/usr/share/hermes/VERSION": ["read"],
|
||||
"/tmp/hermes-setup.log": ["read"],
|
||||
"/tmp/hermes-setup.exit": ["read"]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "hermes" ]
|
||||
"uci": ["hermes"],
|
||||
"ubus": {
|
||||
"file": ["exec", "write"]
|
||||
},
|
||||
"file": {
|
||||
"/usr/share/hermes/luci-helper": ["exec"],
|
||||
"/etc/init.d/hermes": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Hermes Agent LuCI 插件一键安装脚本
|
||||
# 用法: sh -c "$(wget -qO- https://cdn.jsdelivr.net/gh/Boos4721/luci-app-hermes@main/scripts/install.sh)"
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
REPO="Boos4721/luci-app-hermes"
|
||||
GITHUB_RELEASE="https://github.com/${REPO}/releases/latest/download"
|
||||
|
||||
_ok() { echo " [✓] $*"; }
|
||||
_warn() { echo " [!] $*"; }
|
||||
_err() { echo " [✗] $*"; echo ""; exit 1; }
|
||||
|
||||
_download() {
|
||||
local url="$1" out="$2"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q --no-check-certificate -O "$out" "$url" 2>/dev/null
|
||||
elif command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --connect-timeout 15 -o "$out" "$url" 2>/dev/null
|
||||
else
|
||||
_err "未找到 wget 或 curl"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Hermes Agent LuCI 插件一键安装 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# ── 检查环境 ──
|
||||
[ -f /etc/openwrt_release ] || _err "仅支持 OpenWrt / ImmortalWrt 系统"
|
||||
command -v opkg >/dev/null 2>&1 || _err "未找到 opkg,仅支持 OpenWrt 系统"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
DISTRIB=$(grep -o 'DISTRIB_DESCRIPTION="[^"]*"' /etc/openwrt_release 2>/dev/null | cut -d'"' -f2 || echo "OpenWrt")
|
||||
_ok "系统: ${DISTRIB}"
|
||||
_ok "架构: ${ARCH}"
|
||||
echo ""
|
||||
|
||||
# ── 下载 ipk ──
|
||||
IPK="/tmp/luci-app-hermes_$$.ipk"
|
||||
_warn "正在下载 luci-app-hermes..."
|
||||
|
||||
# 主源:GitHub Releases
|
||||
if ! _download "${GITHUB_RELEASE}/luci-app-hermes_all.ipk" "${IPK}"; then
|
||||
_warn "GitHub 下载失败,尝试备用源..."
|
||||
_download "https://github.com/${REPO}/releases/latest/download/luci-app-hermes_all.ipk" "${IPK}" || \
|
||||
_err "下载失败,请检查网络连接后重试"
|
||||
fi
|
||||
|
||||
SIZE=$(wc -c < "${IPK}" 2>/dev/null || echo 0)
|
||||
[ "${SIZE}" -gt 5000 ] || _err "下载文件无效 (${SIZE} 字节),请稍后重试"
|
||||
_ok "下载完成 ($(du -sh "${IPK}" 2>/dev/null | cut -f1))"
|
||||
|
||||
# ── 安装 ──
|
||||
_warn "正在安装..."
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
|
||||
if ! opkg install --force-reinstall "${IPK}" 2>&1; then
|
||||
_warn "首次安装失败,尝试强制安装..."
|
||||
opkg install --force-reinstall --force-depends "${IPK}" || \
|
||||
_err "安装失败,请查看上方错误信息"
|
||||
fi
|
||||
|
||||
rm -f "${IPK}"
|
||||
rm -f /tmp/luci-indexcache 2>/dev/null || true
|
||||
rm -rf /tmp/luci-modulecache 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ [OK] 安装完成! ║"
|
||||
echo "║ ║"
|
||||
echo "║ → LuCI → 服务 → Hermes Agent ║"
|
||||
echo "║ → 点击「更多」→「安装环境」完成 Python + Hermes 安装 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
Reference in new issue
Block a user