mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
Compare commits
80 Commits
v0.8.0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d14afa8d6 | |||
| 87241bc4ff | |||
| 535922c6a0 | |||
| fe66f72b9c | |||
| 0a4eb76542 | |||
| da046b2520 | |||
| 4cb3a0bda4 | |||
| e50bffa34f | |||
| 934eb4da7d | |||
| 4f4a03d368 | |||
| 4482235312 | |||
| 4cf2df5be6 | |||
| ebd700eaf9 | |||
| 7c0b7a1fb0 | |||
| 8c10b7ecc7 | |||
| fcb7490f63 | |||
| 9206353858 | |||
| 83a54f321e | |||
| b93fe6b878 | |||
| 140cd34b56 | |||
| b237787c90 | |||
| 69ff3089a4 | |||
| e839eb8220 | |||
| d893bfa76f | |||
| 93ab794a82 | |||
| 12cabcb290 | |||
| 44bd915fbf | |||
| ba49dfda44 | |||
| b2eafdf885 | |||
| 8c397ecf65 | |||
| 632f6743d3 | |||
| 9d930db4ea | |||
| 0a164fe4b9 | |||
| fd5a291df1 | |||
| 5175e7d6e0 | |||
| 71e3325439 | |||
| 18fec9b003 | |||
| fc236120e3 | |||
| a66739e59b | |||
| da01af1ad7 | |||
| 741e284cd4 | |||
| ce5bff9275 | |||
| e256023399 | |||
| 092d8f4ab0 | |||
| 5ed8d65479 | |||
| 9f844123fe | |||
| ef0e58ee23 | |||
| 09b64dcb53 | |||
| 56485194df | |||
| 61f307be1a | |||
| 59c6e1844c | |||
| 5c1574be25 | |||
| 68497b4092 | |||
| 130f805b6e | |||
| cd1984e26e | |||
| 6184736fd4 | |||
| 81bfdfce1d | |||
| e3f93e254b | |||
| d57c5eaf3e | |||
| 16b4a56ee8 | |||
| 2e6d037bb9 | |||
| cf77bf389e | |||
| fc876c5554 | |||
| 5c5df9cfb9 | |||
| c91739d670 | |||
| 6527a40539 | |||
| 392f391f68 | |||
| cca9fdce9c | |||
| b6e30f9279 | |||
| 8e5610c494 | |||
| 1796395668 | |||
| f3d87ec35f | |||
| aee63a4f98 | |||
| fb07081929 | |||
| db5d3133ea | |||
| 12a8e99892 | |||
| 62447e3952 | |||
| bc1a005885 | |||
| 2b54814037 | |||
| 429fe9e1b9 |
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
# Build artifacts
|
||||
*.so
|
||||
*.dll
|
||||
*.o
|
||||
*.exe
|
||||
*.hmap
|
||||
plugin.json
|
||||
|
||||
@ -8,6 +10,11 @@ plugin.json
|
||||
build/
|
||||
dist/
|
||||
|
||||
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
|
||||
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB),每次重编都在历史里
|
||||
# 再叠一份,而它们本质是可从源码复现的产物。
|
||||
bin/
|
||||
|
||||
# Test artifacts
|
||||
testdist/
|
||||
|
||||
@ -25,3 +32,6 @@ z_entry.c
|
||||
# Pre-built plugindev binaries in bin/ should be tracked
|
||||
!bin/plugindev*
|
||||
!bin/*.exe
|
||||
|
||||
# plugindev binary in tools/
|
||||
tools/plugindev/plugindev
|
||||
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
721
README.md
721
README.md
@ -2,6 +2,108 @@
|
||||
|
||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||
|
||||
## 版本与兼容性
|
||||
|
||||
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||
|
||||
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||
|
||||
| 内核版本 | 对应 SDK |
|
||||
|---|---|
|
||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||
| 1.3.0 起 | **1.3.0** |
|
||||
|
||||
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||
|
||||
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
|
||||
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
|
||||
|
||||
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
||||
想用新字段时重编即可。
|
||||
|
||||
**1.1.x 插件升到 1.2.x:接口纯追加,但必须重编。** 公开接口没有签名变更(新增
|
||||
`InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响;
|
||||
但内核的**插件运行协议升到了 2**(统一共享内存区的 fd3 布局改变,**不支持滚动升级**),
|
||||
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
||||
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
||||
|
||||
## 1.3.0 新增:注入优先级与动态输出通道
|
||||
|
||||
### 注入优先级(`InjectOptions.Priority`)
|
||||
|
||||
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
|
||||
|
||||
| 级别 | 常量 | 谁用 |
|
||||
|---|---|---|
|
||||
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
|
||||
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
|
||||
|
||||
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
|
||||
⇒ 存量插件不需要改一行、也不需要重编。
|
||||
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
|
||||
(现场保存/恢复对插件透明)。
|
||||
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
|
||||
|
||||
### 动态输出通道(`UnregisterOutputChannel`)
|
||||
|
||||
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
|
||||
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
|
||||
|
||||
1.3.0 起成对提供:
|
||||
|
||||
| API | 用途 |
|
||||
|---|---|
|
||||
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
|
||||
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
|
||||
|
||||
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
|
||||
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
|
||||
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
|
||||
派生规则与约束见下方「输出通道」一节。
|
||||
|
||||
## 注入行为与上下文裁剪(1.2.0)
|
||||
|
||||
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
||||
1.2.0 起**注入侧也能声明**,并且二者共用同一套语义与取值。
|
||||
|
||||
```go
|
||||
type InjectOptions struct {
|
||||
NoMemory bool // true = 不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||
ContextPolicy string // ""/none = 不裁剪(默认);prune = 据此裁剪上下文
|
||||
CleanerName string // 计算层过滤函数名:先经 Cleaner 得到实际有效内容,再计算/裁剪
|
||||
}
|
||||
|
||||
const (
|
||||
ContextPolicyNone = "none"
|
||||
ContextPolicyPrune = "prune"
|
||||
)
|
||||
|
||||
// 六个变体,与旧的三参数方法一一对应,只多一个 opts
|
||||
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **零值 `InjectOptions{}` 与旧的三参数方法逐键等价**(记入记忆 + 不裁剪)。旧方法保留为
|
||||
零值糖(`InjectText` / `InjectInterruptText` / `InjectTextNoMemory` …),存量插件不改一行、
|
||||
不需重编即可继续调用。
|
||||
- **裁剪(`prune`)必须显式声明**:它会归档丢弃低相关事件,是有副作用的行为,故默认关闭。
|
||||
内核只放行 `""` / `none` / `prune`(`ValidContextPolicy`),未声明的取值会被拒。
|
||||
- 裁剪前先经该插件注册的 **`Cleaner`**(由 `CleanerName` 指定)拿到实际有效内容,
|
||||
避开「按原文裁剪、按清洗后计算」这种不一致。
|
||||
- `ChannelDef` 也有同名 `context_policy`(并且 1.2.0 给它补上了 JSON tag——通道定义要跨进程
|
||||
传给内核,而 `Cleaner` 是函数必须忽略;无 tag 时新增字段会被静默丢掉)。
|
||||
|
||||
## SDK API 接口
|
||||
|
||||
### Plugin 接口
|
||||
@ -20,11 +122,15 @@ type Plugin interface {
|
||||
|
||||
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||
|
||||
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
|
||||
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
|
||||
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
|
||||
|
||||
| 分类 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 |
|
||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
|
||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||
@ -36,6 +142,7 @@ type Plugin interface {
|
||||
| 设置 | `Settings()` | 访问设置 API |
|
||||
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||
| 多模态注入 | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | 注入带图片/音频的输入(1.1.0 新增) |
|
||||
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||
|
||||
### 阶段钩子
|
||||
@ -70,6 +177,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
|
||||
|
||||
### 输出通道
|
||||
|
||||
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
|
||||
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
|
||||
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
|
||||
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
|
||||
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
|
||||
> 内核**不会**替你净化。
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||
```
|
||||
@ -106,6 +221,30 @@ type 枚举值:
|
||||
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||
|
||||
### 多模态注入(1.1.0 新增)
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `InjectInputMedia(source, channel, text, blocks)` | 注入带媒体的输入,异步 |
|
||||
| `InjectInputMediaSync(source, channel, text, blocks)` | 注入带媒体的输入并同步等待回复文本 |
|
||||
| `InjectInterruptMedia(source, channel, text, blocks)` | 注入带媒体的中断,可抢占当前处理 |
|
||||
|
||||
`blocks` 是 `[]sdk.ContentBlock`,与 `SetToolBlocks` 用同一类型:
|
||||
|
||||
```go
|
||||
s.InjectInputMedia("myplugin", "webui", "帮我看看这张图", []sdk.ContentBlock{{
|
||||
Type: "image_url",
|
||||
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||
}})
|
||||
```
|
||||
|
||||
与 `SetToolBlocks` 的区别:`SetToolBlocks` 只能在工具处理函数内部调用,媒体要等到
|
||||
下一条 tool message 才到模型手上;这三个方法是插件**主动发起一轮带媒体的对话**,
|
||||
媒体在本轮就随消息发给模型,并自动落进媒体存储、挂上媒体记忆引用。
|
||||
|
||||
媒体块里的 `data:` URL 会被内核落盘去重;`http(s)` URL 只透传给模型,不入库
|
||||
(入库需要内核发起网络请求,涉及超时、鉴权与 SSRF)。
|
||||
|
||||
`source` 标识来源,`channel` 指定目标输出通道。
|
||||
|
||||
### Triple 扩展字段
|
||||
@ -115,6 +254,61 @@ Triple 数据结构新增字段:
|
||||
- `Confidence` — 置信度(0.0~1.0)
|
||||
- `SubjectType` — 主体类型
|
||||
- `ObjectType` — 客体类型
|
||||
- `SentenceText` — 原始句子文本(1.1.0 新增),写入 `sentences` 表;媒体引用挂在句子上
|
||||
- `MediaDigests` — 关联的媒体 digest 列表(1.1.0 新增)
|
||||
|
||||
### 记忆里的媒体(1.1.0 新增)
|
||||
|
||||
媒体在纯文本记忆里以**标记**形式存在,格式 `[<mime> <短digest>] <描述>`:
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
```
|
||||
|
||||
描述文本是持久的语义记忆(检索靠它),digest 是回到字节的钥匙(反查靠它)。
|
||||
标记由内核生成,插件不必自己拼——**填 digest 就够**。
|
||||
|
||||
#### 图记忆
|
||||
|
||||
```go
|
||||
s.Memory().Commit([]sdk.Triple{{
|
||||
Subject: "配色图", Relation: "包含", Object: "三色带",
|
||||
MediaDigests: []string{"a1b2c3d4e5f6"}, // 短 digest 即可,内核补全
|
||||
}})
|
||||
```
|
||||
|
||||
没给 `SentenceText` 时内核会用标记本身充当句子——媒体必须有句子落点,
|
||||
否则引用无从挂起。
|
||||
|
||||
#### 知识库
|
||||
|
||||
```go
|
||||
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||
Title: "带图笔记",
|
||||
Content: "正文",
|
||||
}, []sdk.MediaAttachment{
|
||||
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // 新内容,落盘去重
|
||||
{Digest: "a1b2c3d4e5f6"}, // 引用已有内容
|
||||
})
|
||||
```
|
||||
|
||||
`Insert` 保持原签名不变,正文里已有的标记同样会被挂成文档级引用。
|
||||
`Query` 返回的 `Doc` 带 `MediaDigests` 与 `Attachments`(mime + 描述,
|
||||
**不含字节**——一次检索可能命中几十份媒体)。删除文档时引用自动释放。
|
||||
|
||||
#### 文本记忆
|
||||
|
||||
```go
|
||||
s.TextMemory().Append(sdk.TextEvent{
|
||||
Role: "user", Content: "看这张图",
|
||||
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||
})
|
||||
```
|
||||
|
||||
`RecentEvents` 读回时正文里的标记会被反解成 `Attachments`。
|
||||
|
||||
媒体存储可在内核侧关闭(`core.memory.media.enabled=false`),此时以上接口
|
||||
全部退化为纯文本行为:不报错、不 panic,与本特性上线前一致。
|
||||
|
||||
### ToolDef 字段说明
|
||||
|
||||
@ -140,17 +334,35 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
|
||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
||||
|
||||
## plugindev 工具链
|
||||
## hmapdev 工具链
|
||||
|
||||
`plugindev` 提供插件开发全流程支持:
|
||||
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
||||
|
||||
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||
|
||||
```bash
|
||||
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
||||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||
chmod +x hmapdev
|
||||
|
||||
# 或从源码自己编
|
||||
cd tools/hmapdev && go build -o hmapdev .
|
||||
```
|
||||
|
||||
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
||||
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||
|
||||
支持 **Go** 和 **Lua** 两种插件语言。
|
||||
|
||||
@ -175,7 +387,7 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
"version": "1.0.0",
|
||||
"description": "天气查询插件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"entry": "plugin.bin",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
@ -197,7 +409,7 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
| `version` | string | 版本号 |
|
||||
| `description` | string | 插件描述 |
|
||||
| `author` | string | 作者 |
|
||||
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua`) |
|
||||
| `entry` | string | 入口文件(`plugin.bin` / `main.lua`)。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
|
||||
| `tags` | string[] | 标签 |
|
||||
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
||||
| `outdir` | string | 输出目录(默认 `dist`) |
|
||||
@ -212,11 +424,14 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
`.hmap` 为 ZIP 归档,包含:
|
||||
|
||||
- `plugin.json` — 插件元数据
|
||||
- `plugin.so` — Go 编译产物(Linux)
|
||||
- `plugin.dll` — Go 编译产物(Windows)
|
||||
- `plugin.dylib` — Go 编译产物(macOS,bundle 模式)
|
||||
- `plugin.bin` — Go 编译产物(单平台构建)
|
||||
- `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
|
||||
安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
|
||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||
|
||||
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
|
||||
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
### 入口函数
|
||||
@ -246,6 +461,21 @@ return plugin
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||
- `Stop() error` — 插件停止,释放资源
|
||||
- `sdk.RegisterStopHandler(fn func())` — 注册停止清理回调。内核(内置插件)或 z_bridge(外部插件)会在调用插件 `Stop()` **之前**统一执行已注册的 handler(后注册先执行,执行后清空、幂等)。适合做持久化落盘、取消后台任务等清理:此时插件内存状态仍然新鲜,避免在 `Stop()` 阶段以陈旧状态写回导致数据复活。
|
||||
|
||||
### 删除清理(onRemove)
|
||||
|
||||
`Stop`/`RegisterStopHandler` 在插件**停止**(含重载、禁用)时执行;`RegisterOnRemoveHandler` 仅在插件被**卸载(删除)**时执行一次,重载/禁用不触发:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
||||
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
||||
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### 自动重启
|
||||
|
||||
@ -257,6 +487,31 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
插件崩溃时平台自动拉起,保障服务可用性。
|
||||
|
||||
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||
> 生产表现是插件重载瞬间偶发 nil 解引用崩溃)。早于 1.1.0 的版本建议升级。
|
||||
|
||||
## 插件开发者的并发约定
|
||||
|
||||
`PluginSDK` 是**被多个 goroutine 同时使用的共享对象**:你在 `Start()` 里起的轮询、
|
||||
监听、定时器都拿着同一份 `*PluginSDK` 往里注消息,而内核会在加载/重载时写它的
|
||||
API 字段。因此:
|
||||
|
||||
- **SDK 侧已保证的**:全部 API 访问器(`Memory()`/`DocMemory()`/…)、全部注入方法、
|
||||
`SetAutoRestart`/`AutoRestart`、`RegisterTool`/`RegisterStage`、
|
||||
`RunStopHandlers`/`RunOnRemoveHandlers`(幂等,并发调也只执行一次)。
|
||||
- **你需要自己保证的**:`StageContext` 的字段全部导出,并发读写必须自己持
|
||||
`ctx.Lock()`/`ctx.RLock()`。尤其是 `ctx.Extra`——**map 的并发写在 Go 里是直接 fatal,
|
||||
`recover` 接不住**。
|
||||
|
||||
```go
|
||||
ctx.Lock()
|
||||
ctx.Extra["mykey"] = value
|
||||
ctx.FinalText += "补充说明"
|
||||
ctx.Unlock()
|
||||
```
|
||||
|
||||
## 受限 SDK vs 完整 SDK
|
||||
|
||||
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||
@ -268,42 +523,436 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||
|
||||
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
|
||||
|
||||
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "MyPlugin",
|
||||
"version": "0.1.0",
|
||||
"entry": "plugin.bin",
|
||||
"sdk": "1.2.0"
|
||||
}
|
||||
```
|
||||
|
||||
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
|
||||
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
|
||||
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
|
||||
|
||||
```bash
|
||||
$ hmapdev build
|
||||
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
|
||||
```
|
||||
|
||||
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
|
||||
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
|
||||
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
|
||||
|
||||
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
|
||||
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
|
||||
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
|
||||
|
||||
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
|
||||
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
|
||||
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
|
||||
|
||||
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
|
||||
|
||||
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
|
||||
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
|
||||
|
||||
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
|
||||
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
|
||||
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
|
||||
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
|
||||
|
||||
```bash
|
||||
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
|
||||
```
|
||||
|
||||
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
|
||||
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
|
||||
|
||||
## 示例插件
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| a2a | Agent-to-Agent 协议通信 |
|
||||
| ai_image | AI 图片生成 |
|
||||
| bili | Bilibili 视频下载 |
|
||||
| browser | 网络搜索、网页抓取、浏览器渲染 |
|
||||
| calendar | 日历管理 |
|
||||
| editdoc | 文档编辑 |
|
||||
| files | 文件管理 |
|
||||
| memo | 备忘录 |
|
||||
| music | 音乐播放 |
|
||||
| ocr | 光学字符识别 |
|
||||
| qq | QQ 消息集成(NapCat webhook,15 个工具) |
|
||||
| rss | RSS 订阅 |
|
||||
| sanitizer | 内容清洗/安全过滤 |
|
||||
| weather | 天气查询(wttr.in) |
|
||||
| 插件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| [weather](example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||
| [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||
| [qq](example/qq) | Go | QQ 消息集成(NapCat),17 个工具,输入/输出通道完整对接 |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
|
||||
| [ai_image](example/ai_image) | Go | AI 图片生成 |
|
||||
| [bili](example/bili) | Go | Bilibili 视频下载 |
|
||||
| [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
|
||||
| [calendar](example/calendar) | Go | 日历管理 |
|
||||
| [editdoc](example/editdoc) | Go | 文档编辑 |
|
||||
| [files](example/files) | Go | 文件管理 |
|
||||
| [memo](example/memo) | Go | 备忘录(PreAction 注入 + 定时提醒) |
|
||||
| [music](example/music) | Go | 音乐播放 |
|
||||
| [ocr](example/ocr) | Go | 光学字符识别 |
|
||||
| [rss](example/rss) | Go | RSS 订阅 |
|
||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||
|
||||
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
||||
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**(`ProtocolVersion`
|
||||
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
|
||||
「插件坏了」而不是「版本不配套」。
|
||||
|
||||
## Remote Device SDK
|
||||
|
||||
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ha_remotedevice (C SDK) │
|
||||
│ 协议引擎 │ WS 帧 │ JSON │ 状态机 │ 传输抽象 │
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
│ 同一份 C 代码,设备端和 App 端共用
|
||||
┌──────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────────┐
|
||||
│ ESP32 裸机 │ │ Linux 设备上的 App │
|
||||
│ 纯 C 直调 │ │ (Python ctypes / Go CGo / │
|
||||
│ 简单命令处理 │ │ Node addon / C# P/Invoke) │
|
||||
└──────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### 声明式 API 设计
|
||||
|
||||
设备在代码中声明**自己是什么**、**能做什么**、**支持哪些命令**,每个命令对应独立处理函数,SDK 自动分发并回执结果:
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* 声明能力 */
|
||||
const char *caps[] = {"camera", "status", NULL};
|
||||
|
||||
/* 声明式命令处理表:每个命令绑定独立处理函数 */
|
||||
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0;
|
||||
// 拍照/录像...
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,..."; // SDK 自动回执
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camerasue},
|
||||
{.command = "screensee", .handler = handle_screensee},
|
||||
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = my_transport, // 用户实现 4 个函数
|
||||
.server = "192.168.1.100:9890",
|
||||
.token = "my-token",
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "门口摄像头",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
},
|
||||
.handlers = handlers, // 声明式命令处理表
|
||||
.on_state = my_state_handler,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_client_start(client);
|
||||
while (1) {
|
||||
ha_client_process(client); // 主循环处理
|
||||
}
|
||||
```
|
||||
|
||||
### 传输层抽象
|
||||
|
||||
用户只需实现 4 个函数,适配不同平台:
|
||||
|
||||
```c
|
||||
ha_transport_t my_transport = {
|
||||
.connect = my_tcp_connect, // 建立 TCP 连接
|
||||
.send = my_tcp_send, // 发送数据
|
||||
.recv = my_tcp_recv, // 接收数据(阻塞)
|
||||
.close = my_tcp_close, // 关闭连接
|
||||
.ctx = &my_platform_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
### 支持的协议
|
||||
|
||||
| 功能 | API |
|
||||
|------|-----|
|
||||
| WS 连接 + 握手 | `ha_client_start` 自动完成 |
|
||||
| 设备注册 (hello/bind) | 启动时自动发送 |
|
||||
| 命令接收 (shell/homeagent) | `handlers` 表声明式注册,SDK 自动分发 |
|
||||
| 命令回执 | `ha_client_send_result` |
|
||||
| 二进制分块(录像等) | `ha_client_send_data_chunked` |
|
||||
| TTS 音频接收 | `on_binary` 回调 |
|
||||
| 事件上报 | `ha_client_send_event` |
|
||||
| 状态上报 | `ha_client_send_status` |
|
||||
| 心跳保持 | 自动 ping/pong |
|
||||
|
||||
### 使用方式
|
||||
|
||||
通过 `hmapdev` 工具链初始化项目:
|
||||
|
||||
```bash
|
||||
hmapdev init my-adapter --type remotedevice
|
||||
```
|
||||
|
||||
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(path/to/ha_remotedevice)
|
||||
target_link_libraries(my_app ha_remotedevice)
|
||||
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||
```
|
||||
|
||||
### 快速接入指南
|
||||
|
||||
以下是从零到设备成功接入 HomeAgent 的完整步骤。
|
||||
|
||||
#### 1. 准备工作
|
||||
|
||||
在 HomeAgent 平台上创建接入令牌:
|
||||
|
||||
```bash
|
||||
# 在 HomeAgent 服务端创建一个设备接入令牌
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"esp32-cam-1","name":"门口摄像头","kind":"camera"}'
|
||||
# 返回: {"token":"ha-dev-token-xxxxx"}
|
||||
```
|
||||
|
||||
记录下返回的 `token`,设备端配置时使用。
|
||||
|
||||
#### 2. 实现传输层(4 个函数)
|
||||
|
||||
根据你的平台实现 `ha_transport_t` 的 4 个函数指针。以下是几种常见场景:
|
||||
|
||||
**场景 A:带 TCP/IP 栈的嵌入式设备(如 ESP32 + lwIP)**
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct sockaddr_in addr;
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) return -1;
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (ret < 0) { closesocket(sock); return -1; }
|
||||
*(int *)ctx = sock;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return send(sock, (const char *)data, len, 0);
|
||||
}
|
||||
|
||||
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return recv(sock, (char *)buf, len, 0);
|
||||
}
|
||||
|
||||
static void esp_close(void *ctx) {
|
||||
int sock = *(int *)ctx;
|
||||
closesocket(sock);
|
||||
}
|
||||
|
||||
int esp_ctx = -1;
|
||||
ha_transport_t transport = {
|
||||
.connect = esp_connect,
|
||||
.send = esp_send,
|
||||
.recv = esp_recv,
|
||||
.close = esp_close,
|
||||
.ctx = &esp_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
**场景 B:通过串口(UART)连接透传模块**
|
||||
|
||||
```c
|
||||
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||
(void)host; (void)port;
|
||||
// 初始化 UART,波特率 115200
|
||||
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||
}
|
||||
|
||||
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||
}
|
||||
|
||||
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||
}
|
||||
|
||||
static void uart_close(void *ctx) {
|
||||
uart_deinit((uart_ctx_t *)ctx);
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:UART 透传时,另一端需运行一个 TCP 桥接程序,将串口数据转发到 HomeAgent 的 WebSocket 端口。
|
||||
|
||||
#### 3. 声明设备能力和命令处理
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* 声明设备能力 */
|
||||
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||
|
||||
/* 处理 camerasue 命令(拍照) */
|
||||
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0; // 参数:录像时长
|
||||
|
||||
// 拍照或录像,将结果填入 result
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 图像数据
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 处理 shell 命令 */
|
||||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
// 执行 shell 命令,args 为完整命令字符串
|
||||
result->status = 0;
|
||||
result->output = "command executed";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 声明式命令处理表 */
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camera},
|
||||
{.command = "screensee", .handler = handle_camera},
|
||||
{.command = "speakeruse", .handler = handle_speaker},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 配置并启动客户端
|
||||
|
||||
```c
|
||||
ha_config_t config = {
|
||||
.transport = transport, // 传输层实现
|
||||
.server = "192.168.1.100:9890", // HomeAgent 服务端地址
|
||||
.token = "ha-dev-token-xxxxx", // 第 1 步获取的令牌
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "门口摄像头",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||
},
|
||||
.handlers = handlers, // 命令处理表
|
||||
.on_binary = on_binary_data, // 接收 TTS 音频等二进制数据
|
||||
.on_state = on_state_change, // 连接状态变化回调
|
||||
.ping_interval = 30, // 心跳间隔秒数
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_status_t ret = ha_client_start(client);
|
||||
if (ret != HA_OK) {
|
||||
printf("设备接入失败: %d\n", ret);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 主循环 */
|
||||
while (1) {
|
||||
ha_client_process(client); // 处理协议帧、心跳、命令分发
|
||||
|
||||
/* 可选:设备主动上报事件 */
|
||||
ha_client_send_event(client, "motion_detected",
|
||||
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||
|
||||
/* 可选:上报设备状态 */
|
||||
ha_client_send_status(client, "online");
|
||||
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS); // 嵌入式 RTOS 风格延时
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 验证连接
|
||||
|
||||
在 HomeAgent 服务端检查设备是否在线:
|
||||
|
||||
```bash
|
||||
# 查看已注册设备列表
|
||||
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||
# 预期输出包含: {"device_id":"esp32-cam-1","status":"online",...}
|
||||
|
||||
# 向设备发送命令(测试 camerasue)
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"camerasue","args":"3"}'
|
||||
# 预期返回: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||
```
|
||||
|
||||
#### 6. 调试技巧
|
||||
|
||||
| 问题 | 检查点 |
|
||||
|------|--------|
|
||||
| 连接失败 | 确认 `server` 地址和端口可通;检查 `token` 是否正确 |
|
||||
| WS 握手失败 | 确认 HomeAgent 服务端已开启 WebSocket 支持 |
|
||||
| 命令无响应 | 确认 `handlers` 表中注册了对应命令名;检查 `on_binary` 是否配置 |
|
||||
| 断线重连 | `max_reconnect` 控制重连次数,-1 为无限重连 |
|
||||
| 内存不足(嵌入式) | 定义 `HA_NO_ALLOC` 宏禁用动态内存分配 |
|
||||
|
||||
### 位置
|
||||
|
||||
- **SDK 源码**: `remotedevice/`
|
||||
- **hmapdev 模板**: `hmapdev init --type remotedevice`
|
||||
|
||||
## 构建与安装
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
输出 `.hmap` 包到项目目录。
|
||||
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
|
||||
|
||||
### 安装
|
||||
|
||||
通过 pluginmgr HTTP API 安装:
|
||||
通过 pluginmgr HTTP API 安装(端口默认 9876,仅监听 127.0.0.1,无鉴权):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# 本地路径
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# 直接上传二进制
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
或手动将 `.hmap` 放入插件目录后重启平台。
|
||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||
|
||||
## 许可
|
||||
|
||||
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
|
||||
|
||||
**这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
||||
插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13
|
||||
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。
|
||||
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。
|
||||
|
||||
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
||||
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
||||
不属于本 SDK,其许可全文随发行包放在 `/usr/share/doc/homeagent/licenses/`。
|
||||
|
||||
640
README_EN.md
640
README_EN.md
@ -2,6 +2,103 @@
|
||||
|
||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||
|
||||
## Version and Compatibility
|
||||
|
||||
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
|
||||
|
||||
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||
|
||||
| Kernel version | Matching SDK |
|
||||
|---|---|
|
||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||
| 1.3.0 onward | **1.3.0** |
|
||||
|
||||
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||
has changed.
|
||||
|
||||
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
|
||||
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
|
||||
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
|
||||
|
||||
## New in 1.3.0: Injection Priority and Dynamic Output Channels
|
||||
|
||||
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
|
||||
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
|
||||
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
|
||||
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
|
||||
Queued input has no level: anything can jump ahead of it.
|
||||
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
|
||||
channels that die with their resource (one channel per remote device) can now be unregistered;
|
||||
previously they lingered and the model kept "successfully" sending into a dead channel.
|
||||
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
|
||||
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
|
||||
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
|
||||
external IDs — never use the raw ID.
|
||||
|
||||
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||
because the handshake validates `ProtocolVersion`, not the SDK version). Rebuild only when you want
|
||||
the new fields.
|
||||
|
||||
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
||||
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
|
||||
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
|
||||
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
|
||||
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
|
||||
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
|
||||
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
|
||||
degrades silently).
|
||||
|
||||
## Injection Behaviour and Context Pruning (1.2.0)
|
||||
|
||||
"Should this go into memory" and "should the context be pruned based on this" used to be
|
||||
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
|
||||
the same semantics and values.
|
||||
|
||||
```go
|
||||
type InjectOptions struct {
|
||||
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
|
||||
// original text still stays in context
|
||||
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
|
||||
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
|
||||
// content, then compute/prune on that
|
||||
}
|
||||
|
||||
const (
|
||||
ContextPolicyNone = "none"
|
||||
ContextPolicyPrune = "prune"
|
||||
)
|
||||
|
||||
// Six variants, one-to-one with the older three-argument methods, plus opts
|
||||
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
|
||||
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
|
||||
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
|
||||
line changed *or* a rebuild.
|
||||
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
|
||||
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
|
||||
(`ValidContextPolicy`); anything else is rejected.
|
||||
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
|
||||
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
|
||||
cleaned text".
|
||||
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
|
||||
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
|
||||
no tags, newly added fields would be silently dropped).
|
||||
|
||||
## SDK API Surface
|
||||
|
||||
### Plugin Interface
|
||||
@ -36,6 +133,7 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
||||
| Settings | `Settings()` | Access settings API |
|
||||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
|
||||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||
|
||||
### Stage Hooks
|
||||
@ -106,6 +204,32 @@ Type enum values:
|
||||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||
|
||||
### Multimodal Injection (added in 1.1.0)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
|
||||
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
|
||||
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
|
||||
|
||||
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
|
||||
|
||||
```go
|
||||
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
|
||||
Type: "image_url",
|
||||
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||
}})
|
||||
```
|
||||
|
||||
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
|
||||
its media reaches the model with the *next* tool message. These three let a plugin
|
||||
**initiate a turn that carries media** — the media goes out with this turn's message and is
|
||||
automatically stored in the media store with a memory reference attached.
|
||||
|
||||
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
|
||||
passed to the model only and never stored (storing them would require the kernel to make
|
||||
network requests, bringing timeouts, auth and SSRF into scope).
|
||||
|
||||
`source` identifies the origin, `channel` specifies the target output channel.
|
||||
|
||||
### Triple Extended Fields
|
||||
@ -115,6 +239,65 @@ The Triple data structure includes additional fields:
|
||||
- `Confidence` — confidence score (0.0–1.0)
|
||||
- `SubjectType` — subject type
|
||||
- `ObjectType` — object type
|
||||
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
|
||||
- `MediaDigests` — associated media digests (added in 1.1.0)
|
||||
|
||||
### Media in Memory (added in 1.1.0)
|
||||
|
||||
Inside plain-text memory, media is represented as a **marker** of the form
|
||||
`[<mime> <short digest>] <description>`:
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||
```
|
||||
|
||||
The description is the durable semantic memory (retrieval uses it); the digest is the key
|
||||
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
|
||||
never has to assemble one, it just **supplies the digest**.
|
||||
|
||||
#### Graph memory
|
||||
|
||||
```go
|
||||
s.Memory().Commit([]sdk.Triple{{
|
||||
Subject: "palette", Relation: "contains", Object: "three-band",
|
||||
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
|
||||
}})
|
||||
```
|
||||
|
||||
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
|
||||
a sentence to hang off, otherwise the reference has nowhere to attach.
|
||||
|
||||
#### Knowledge base
|
||||
|
||||
```go
|
||||
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||
Title: "illustrated note",
|
||||
Content: "body",
|
||||
}, []sdk.MediaAttachment{
|
||||
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
|
||||
{Digest: "a1b2c3d4e5f6"}, // reference existing content
|
||||
})
|
||||
```
|
||||
|
||||
`Insert` keeps its original signature; markers already present in the body are bound as
|
||||
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
|
||||
description, **no bytes** — one query can match dozens of media items). Removing a document
|
||||
releases its references.
|
||||
|
||||
#### Text memory
|
||||
|
||||
```go
|
||||
s.TextMemory().Append(sdk.TextEvent{
|
||||
Role: "user", Content: "look at this",
|
||||
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||
})
|
||||
```
|
||||
|
||||
`RecentEvents` decodes markers in the body back into `Attachments`.
|
||||
|
||||
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
|
||||
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
|
||||
behaved before this feature shipped.
|
||||
|
||||
### ToolDef Field Reference
|
||||
|
||||
@ -140,16 +323,37 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
|
||||
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||||
|
||||
## plugindev Toolchain
|
||||
## hmapdev Toolchain
|
||||
|
||||
`plugindev` provides full development workflow support:
|
||||
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
||||
named after that package format). Prebuilt binaries ship as **release assets**
|
||||
(linux/darwin/windows × amd64/arm64); download from
|
||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
|
||||
|
||||
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
||||
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
||||
> (the old directory is still honored, so installed versions are not lost).
|
||||
|
||||
```bash
|
||||
# From release assets (latest SDK release / linux amd64 shown)
|
||||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
||||
chmod +x hmapdev
|
||||
|
||||
# Or build from source
|
||||
cd tools/hmapdev && go build -o hmapdev .
|
||||
```
|
||||
|
||||
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
||||
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
|
||||
> reproducible from source anyway.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
||||
| `plugindev build` | Build plugin, output .hmap package |
|
||||
| `plugindev clean` | Clean build artifacts |
|
||||
| `plugindev debug` | Run plugin in local debug mode |
|
||||
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||||
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||||
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
|
||||
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
||||
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
||||
|
||||
Supports both **Go** and **Lua** plugin languages.
|
||||
|
||||
@ -163,7 +367,7 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
"version": "1.0.0",
|
||||
"description": "Weather plugin",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"entry": "plugin.bin",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
@ -185,7 +389,7 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
| `version` | string | Version |
|
||||
| `description` | string | Plugin description |
|
||||
| `author` | string | Author |
|
||||
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
|
||||
| `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
|
||||
| `tags` | string[] | Tags |
|
||||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||||
| `outdir` | string | Output directory (default `dist`) |
|
||||
@ -198,16 +402,36 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
`.hmap` is a ZIP archive containing:
|
||||
|
||||
- `plugin.json` — plugin metadata
|
||||
- `plugin.so` — Go compiled artifact (Linux)
|
||||
- `plugin.dll` — Go compiled artifact (Windows)
|
||||
- `plugin.bin` — Go compiled artifact (single-platform build)
|
||||
- `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
|
||||
the one matching the current platform and renames it to `plugin.bin`
|
||||
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||||
|
||||
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
|
||||
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
|
||||
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
|
||||
|
||||
## Plugin Lifecycle
|
||||
|
||||
### Start & Stop
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||
- `Stop() error` — Plugin shutdown, release resources
|
||||
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
|
||||
|
||||
### Remove Cleanup (onRemove)
|
||||
|
||||
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
||||
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
||||
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `hmapdev` template includes an onRemove demo.
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-Restart
|
||||
|
||||
@ -219,6 +443,33 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||
|
||||
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||
> external connection has been established, and that connection setup usually happens in a
|
||||
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
|
||||
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
|
||||
> if you are on anything earlier.
|
||||
|
||||
## Concurrency Contract for Plugin Developers
|
||||
|
||||
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
|
||||
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
|
||||
the kernel writes its API fields during load/reload. So:
|
||||
|
||||
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
|
||||
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
|
||||
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
|
||||
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
|
||||
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
|
||||
fatal in Go, and `recover` cannot catch it**.
|
||||
|
||||
```go
|
||||
ctx.Lock()
|
||||
ctx.Extra["mykey"] = value
|
||||
ctx.FinalText += "supplementary note"
|
||||
ctx.Unlock()
|
||||
```
|
||||
|
||||
## Restricted SDK vs Full SDK
|
||||
|
||||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||
@ -232,36 +483,369 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
||||
|
||||
## Example Plugins
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| a2a | Agent-to-Agent protocol communication |
|
||||
| bili | Bilibili data fetching |
|
||||
| editdoc | Document editing |
|
||||
| files | File management |
|
||||
| memo | Memo/notes |
|
||||
| ocr | Optical character recognition |
|
||||
| qq | QQ messaging integration |
|
||||
| sanitizer | Content sanitization/safety filtering |
|
||||
| web | Web browsing and interaction |
|
||||
| webfetch | Web content fetching |
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
|
||||
| [ai_image](example/ai_image) | Go | AI image generation |
|
||||
| [bili](example/bili) | Go | Bilibili video downloading |
|
||||
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
|
||||
| [calendar](example/calendar) | Go | Calendar management |
|
||||
| [editdoc](example/editdoc) | Go | Document editing |
|
||||
| [files](example/files) | Go | File management |
|
||||
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
|
||||
| [music](example/music) | Go | Music playback |
|
||||
| [ocr](example/ocr) | Go | Optical character recognition |
|
||||
| [rss](example/rss) | Go | RSS subscriptions |
|
||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||||
|
||||
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
||||
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
||||
that plugin binaries are **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
|
||||
magic), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
||||
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
||||
match".
|
||||
|
||||
## Remote Device SDK
|
||||
|
||||
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ha_remotedevice (C SDK) │
|
||||
│ Protocol Engine │ WS Frames │ JSON │ State │
|
||||
│ Machine │ Transport Abstraction │
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
│ Same C code, shared by device & app
|
||||
┌──────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────────┐
|
||||
│ ESP32 Bare │ │ Linux App │
|
||||
│ Pure C │ │ (Python ctypes / Go CGo /│
|
||||
│ Simple Cmd │ │ Node addon / C# P/Invoke)│
|
||||
└──────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Declarative API Design
|
||||
|
||||
The device declares **what it is** and **what it can do** in code. The SDK handles all protocol details automatically:
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* Declare capabilities */
|
||||
const char *caps[] = {"camera", "status", NULL};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = my_transport, // User implements 4 functions
|
||||
.server = "192.168.1.100:9890",
|
||||
.token = "my-token",
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "Front Door Camera",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
},
|
||||
.on_cmd = my_cmd_handler, // Called when receiving commands
|
||||
.on_binary = my_data_handler, // Called on binary data (TTS audio, etc.)
|
||||
.on_state = my_state_handler, // Connection state changes
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_client_start(client);
|
||||
while (1) {
|
||||
ha_client_process(client); // Main loop processing
|
||||
}
|
||||
```
|
||||
|
||||
### Transport Layer Abstraction
|
||||
|
||||
Users only need to implement 4 functions to adapt to different platforms:
|
||||
|
||||
```c
|
||||
ha_transport_t my_transport = {
|
||||
.connect = my_tcp_connect, // Establish TCP connection
|
||||
.send = my_tcp_send, // Send data
|
||||
.recv = my_tcp_recv, // Receive data (blocking)
|
||||
.close = my_tcp_close, // Close connection
|
||||
.ctx = &my_platform_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
### Protocol Support
|
||||
|
||||
| Feature | API |
|
||||
|---------|-----|
|
||||
| WS connection + handshake | Automatic via `ha_client_start` |
|
||||
| Device registration (hello/bind) | Automatic on startup |
|
||||
| Command receive (shell/homeagent) | `on_cmd` callback |
|
||||
| Command result | `ha_client_send_result` |
|
||||
| Binary chunked transfer (video) | `ha_client_send_data_chunked` |
|
||||
| TTS audio receive | `on_binary` callback |
|
||||
| Event reporting | `ha_client_send_event` |
|
||||
| Status reporting | `ha_client_send_status` |
|
||||
| Heartbeat keepalive | Automatic ping/pong |
|
||||
|
||||
### Usage
|
||||
|
||||
Initialize a project via the `hmapdev` toolchain:
|
||||
|
||||
```bash
|
||||
hmapdev init my-adapter --type remotedevice
|
||||
```
|
||||
|
||||
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(path/to/ha_remotedevice)
|
||||
target_link_libraries(my_app ha_remotedevice)
|
||||
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||
```
|
||||
|
||||
### Quick Start Guide
|
||||
|
||||
A complete step-by-step guide from zero to a device successfully connected to HomeAgent.
|
||||
|
||||
#### Step 1: Preparation
|
||||
|
||||
Create an access token on the HomeAgent platform:
|
||||
|
||||
```bash
|
||||
# Create a device access token on the HomeAgent server
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"esp32-cam-1","name":"Front Door Camera","kind":"camera"}'
|
||||
# Returns: {"token":"ha-dev-token-xxxxx"}
|
||||
```
|
||||
|
||||
Save the returned `token` — you'll need it in the device configuration.
|
||||
|
||||
#### Step 2: Implement the Transport Layer (4 functions)
|
||||
|
||||
Implement the 4 function pointers of `ha_transport_t` for your platform. Here are common scenarios:
|
||||
|
||||
**Scenario A: Embedded device with TCP/IP stack (e.g., ESP32 + lwIP)**
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct sockaddr_in addr;
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) return -1;
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (ret < 0) { closesocket(sock); return -1; }
|
||||
*(int *)ctx = sock;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return send(sock, (const char *)data, len, 0);
|
||||
}
|
||||
|
||||
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return recv(sock, (char *)buf, len, 0);
|
||||
}
|
||||
|
||||
static void esp_close(void *ctx) {
|
||||
int sock = *(int *)ctx;
|
||||
closesocket(sock);
|
||||
}
|
||||
|
||||
int esp_ctx = -1;
|
||||
ha_transport_t transport = {
|
||||
.connect = esp_connect,
|
||||
.send = esp_send,
|
||||
.recv = esp_recv,
|
||||
.close = esp_close,
|
||||
.ctx = &esp_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
**Scenario B: Serial (UART) passthrough module**
|
||||
|
||||
```c
|
||||
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||
(void)host; (void)port;
|
||||
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||
}
|
||||
|
||||
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||
}
|
||||
|
||||
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||
}
|
||||
|
||||
static void uart_close(void *ctx) {
|
||||
uart_deinit((uart_ctx_t *)ctx);
|
||||
}
|
||||
```
|
||||
|
||||
> Note: For UART passthrough, a TCP bridge program must run on the other end to forward serial data to the HomeAgent WebSocket port.
|
||||
|
||||
#### Step 3: Declare Device Capabilities and Command Handlers
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* Declare device capabilities */
|
||||
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||
|
||||
/* Handle camerasue command (take photo) */
|
||||
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0;
|
||||
|
||||
// Capture image, fill the result
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 image data
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* Handle shell command */
|
||||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "command executed";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* Declarative command handler table */
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camera},
|
||||
{.command = "screensee", .handler = handle_camera},
|
||||
{.command = "speakeruse", .handler = handle_speaker},
|
||||
{.command = NULL}, /* terminator */
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 4: Configure and Start the Client
|
||||
|
||||
```c
|
||||
ha_config_t config = {
|
||||
.transport = transport, // Transport layer implementation
|
||||
.server = "192.168.1.100:9890", // HomeAgent server address
|
||||
.token = "ha-dev-token-xxxxx", // Token from Step 1
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "Front Door Camera",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||
},
|
||||
.handlers = handlers, // Command handler table
|
||||
.on_binary = on_binary_data, // Receive TTS audio etc.
|
||||
.on_state = on_state_change, // Connection state callback
|
||||
.ping_interval = 30,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_status_t ret = ha_client_start(client);
|
||||
if (ret != HA_OK) {
|
||||
printf("Device connection failed: %d\n", ret);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Main loop */
|
||||
while (1) {
|
||||
ha_client_process(client); // Process protocol frames, heartbeats, commands
|
||||
|
||||
/* Optional: device-initiated event reporting */
|
||||
ha_client_send_event(client, "motion_detected",
|
||||
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||
|
||||
/* Optional: report device status */
|
||||
ha_client_send_status(client, "online");
|
||||
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS); // RTOS-style delay
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 5: Verify the Connection
|
||||
|
||||
Check if the device is online on the HomeAgent server:
|
||||
|
||||
```bash
|
||||
# List registered devices
|
||||
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||
# Expected output includes: {"device_id":"esp32-cam-1","status":"online",...}
|
||||
|
||||
# Send a command to the device (test camerasue)
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"camerasue","args":"3"}'
|
||||
# Expected: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||
```
|
||||
|
||||
#### Step 6: Debugging Tips
|
||||
|
||||
| Issue | Check |
|
||||
|-------|-------|
|
||||
| Connection failed | Verify `server` address and port are reachable; check `token` |
|
||||
| WS handshake failed | Verify HomeAgent server WebSocket support is enabled |
|
||||
| Command not responding | Confirm the command name is registered in `handlers` table; check `on_binary` |
|
||||
| Reconnection issues | `max_reconnect` controls retry count; -1 = infinite |
|
||||
| Low memory (embedded) | Define `HA_NO_ALLOC` to disable dynamic memory allocation |
|
||||
|
||||
### Location
|
||||
|
||||
- **SDK Source**: `remotedevice/`
|
||||
- **hmapdev template**: `hmapdev init --type remotedevice`
|
||||
|
||||
## Building & Installing
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
Outputs a `.hmap` package to the project directory.
|
||||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
|
||||
|
||||
### Install
|
||||
|
||||
Via pluginmgr HTTP API:
|
||||
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# Local path
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# Upload binary directly
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
Or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
|
||||
## License
|
||||
|
||||
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
|
||||
|
||||
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
|
||||
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
|
||||
this SDK and **must be released under the same license**. Because AGPL §13 covers network
|
||||
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
|
||||
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
|
||||
license from this project — none is offered today.
|
||||
|
||||
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||||
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||||
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
|
||||
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "a2a",
|
||||
"name_zh": "A2A 代理通信",
|
||||
"name_en": "A2A Agent Communication",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["a2a", "agent", "interop"],
|
||||
"tags": [
|
||||
"a2a",
|
||||
"agent",
|
||||
"interop"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
@ -17,22 +18,58 @@ import (
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
serverAddr string
|
||||
|
||||
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
||||
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
||||
sessMu sync.Mutex
|
||||
sessions map[string]*a2aSession
|
||||
}
|
||||
|
||||
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
||||
type a2aSession struct {
|
||||
ID string
|
||||
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
|
||||
const maxSessionTurns = 10
|
||||
|
||||
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
|
||||
const sessionGCPeriod = 30 * time.Minute
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*a2aSession)
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||
tp := p.name + "_"
|
||||
|
||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||
// 且 output_list_channels 可见(agent 能主动向 a2a 会话推送消息)。
|
||||
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}); err != nil {
|
||||
log.Printf("[%s] register output channel: %v", p.name, err)
|
||||
}
|
||||
|
||||
// 会话 GC:后台周期回收长期不用的会话
|
||||
go p.sessionGCLoop()
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "listen", Default: "127.0.0.1:12000",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
// Outbound: query + discover
|
||||
@ -41,9 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||
},
|
||||
"required": []string{"agent_url", "query"},
|
||||
},
|
||||
@ -97,7 +135,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// Inbound HTTP server
|
||||
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
p.startServer(addrStr)
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start A2A server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +150,69 @@ func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sessionGCLoop 周期清理超时会话。
|
||||
func (p *Plugin) sessionGCLoop() {
|
||||
ticker := time.NewTicker(sessionGCPeriod)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
p.sessMu.Lock()
|
||||
for id, sess := range p.sessions {
|
||||
if time.Since(sess.LastUsed) > 2*time.Hour {
|
||||
delete(p.sessions, id)
|
||||
}
|
||||
}
|
||||
p.sessMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
|
||||
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
|
||||
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
|
||||
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
|
||||
p.sessMu.Lock()
|
||||
sess := p.sessions[sessionID]
|
||||
var hist []string
|
||||
var lastUsed time.Time
|
||||
if sess != nil {
|
||||
hist = append([]string{}, sess.History...)
|
||||
lastUsed = sess.LastUsed
|
||||
}
|
||||
p.sessMu.Unlock()
|
||||
if sess == nil {
|
||||
return nil
|
||||
}
|
||||
_ = lastUsed
|
||||
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
|
||||
start := 0
|
||||
if len(hist) > limit {
|
||||
start = len(hist) - limit
|
||||
}
|
||||
msgs := make([]map[string]interface{}, 0, len(hist)-start)
|
||||
for i := start; i < len(hist); i++ {
|
||||
role, text := "user", hist[i]
|
||||
if after, ok := strings.CutPrefix(text, "用户: "); ok {
|
||||
role, text = "user", after
|
||||
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
|
||||
role, text = "agent", after
|
||||
}
|
||||
msgs = append(msgs, map[string]interface{}{
|
||||
"role": role,
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (p *Plugin) stopServer() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
@ -120,7 +222,7 @@ func (p *Plugin) stopServer() {
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) {
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||
@ -128,18 +230,32 @@ func (p *Plugin) startServer(addr string) {
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
||||
return
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
p.server = &http.Server{Handler: mux}
|
||||
p.serverAddr = listener.Addr().String()
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverAddr = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] A2A server on %s", p.name, p.serverAddr)
|
||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] A2A server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||
@ -171,8 +287,10 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Message *struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
@ -195,29 +313,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
queryText = strings.TrimSpace(queryText)
|
||||
}
|
||||
|
||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
||||
if queryText != "" {
|
||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
||||
if queryText == "" {
|
||||
http.Error(w, "query/message.text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Respond with task accepted
|
||||
// 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
|
||||
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||
injectText := queryText
|
||||
p.sessMu.Lock()
|
||||
if sessionID != "" {
|
||||
sess := p.sessions[sessionID]
|
||||
if sess == nil {
|
||||
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||
p.sessions[sessionID] = sess
|
||||
}
|
||||
sess.LastUsed = time.Now()
|
||||
// 有历史则把上下文拼在前面(截尾防爆量)
|
||||
if len(sess.History) > 0 {
|
||||
ctxText := strings.Join(sess.History, "\n")
|
||||
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
|
||||
}
|
||||
} else {
|
||||
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
|
||||
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||
}
|
||||
p.sessMu.Unlock()
|
||||
|
||||
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
|
||||
// 也不再回 202 让请求方永远等不到结果)。HTTP 超时由调用方控制。
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name,
|
||||
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
|
||||
|
||||
// 回复写回会话历史(下一轮作为上下文)
|
||||
p.sessMu.Lock()
|
||||
if sess := p.sessions[sessionID]; sess != nil {
|
||||
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
||||
if len(sess.History) > maxSessionTurns*2 {
|
||||
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
|
||||
}
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
p.sessMu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||
"status": "submitted",
|
||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||
"status": "completed",
|
||||
"session_id": sessionID,
|
||||
"message": map[string]interface{}{
|
||||
"role": "agent",
|
||||
"parts": []map[string]string{{"type": "text", "text": reply}},
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
case "tasks.get":
|
||||
case "tasks.get", "session.get":
|
||||
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
|
||||
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||
if sessionID == "" {
|
||||
sessionID = strings.TrimSpace(req.Params.Query)
|
||||
}
|
||||
limit := 10
|
||||
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
|
||||
limit = req.Params.Limit
|
||||
}
|
||||
msgs := p.sessionMessages(sessionID, limit)
|
||||
if msgs == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"status": "not_found",
|
||||
"messages": []interface{}{},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
||||
"result": map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"status": "completed",
|
||||
"messages": msgs,
|
||||
},
|
||||
})
|
||||
|
||||
default:
|
||||
@ -261,9 +446,10 @@ type A2ARequest struct {
|
||||
}
|
||||
|
||||
type A2AParams struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type A2AResponse struct {
|
||||
@ -274,9 +460,10 @@ type A2AResponse struct {
|
||||
}
|
||||
|
||||
type A2AResult struct {
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||
}
|
||||
|
||||
@ -341,6 +528,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
|
||||
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||
agentURL, _ := args["agent_url"].(string)
|
||||
query, _ := args["query"].(string)
|
||||
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||
timeoutSec := 60
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
@ -362,7 +550,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||
Method: "tasks.send",
|
||||
Params: A2AParams{
|
||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||
SessionID: sessionID,
|
||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||
},
|
||||
}
|
||||
|
||||
@ -401,34 +590,39 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||
"response": replyText,
|
||||
}, nil
|
||||
}
|
||||
if a2aResp.Result.SessionID != "" || sessionID != "" {
|
||||
result["session_id"] = a2aResp.Result.SessionID
|
||||
if result["session_id"] == "" {
|
||||
result["session_id"] = sessionID
|
||||
}
|
||||
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- Management Handlers ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
if listen == "" {
|
||||
return "参数 listen 不能为空。设为空字符串可禁用 HTTP 服务。", nil
|
||||
}
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
p.stopServer()
|
||||
if listen != "" {
|
||||
p.startServer(listen)
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "A2A HTTP 服务已禁用(listen 设为空)", nil
|
||||
}
|
||||
|
||||
status := "已启动"
|
||||
if listen == "" {
|
||||
status = "已禁用"
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (%s)", listen, status), nil
|
||||
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
|
||||
@ -436,23 +630,28 @@ func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
if addrStr == "" {
|
||||
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
|
||||
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
||||
}
|
||||
|
||||
p.startServer(addrStr)
|
||||
if p.server == nil {
|
||||
return fmt.Sprintf("A2A 服务启动失败,请检查监听地址: %s", addrStr), nil
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("A2A 服务已重启,监听: %s", p.serverAddr), nil
|
||||
|
||||
p.srvMu.Lock()
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
|
||||
7
example/acp/go.mod
Normal file
7
example/acp/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module acp
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/acp/main.go
Normal file
11
example/acp/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
19
example/acp/plg.json
Normal file
19
example/acp/plg.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "acp",
|
||||
"name_zh": "ACP 代理通信",
|
||||
"name_en": "ACP Agent Client Protocol",
|
||||
"version": "1.2.1",
|
||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"acp",
|
||||
"agent",
|
||||
"interop"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
645
example/acp/plugin.go
Normal file
645
example/acp/plugin.go
Normal file
@ -0,0 +1,645 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
|
||||
// - 服务端:POST /api/session (JSON-RPC:session/new / session/update),
|
||||
// 请求注入本 Agent,另提供 GET /api/session?id=xxx SSE 事件流。
|
||||
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
serverID string
|
||||
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionState
|
||||
}
|
||||
|
||||
type sessionState struct {
|
||||
ID string
|
||||
Replying []map[string]interface{}
|
||||
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// maxSessionTurns 单会话保留的最大轮次对数。
|
||||
const maxSessionTurns = 10
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*sessionState)
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||
tp := p.name + "_"
|
||||
|
||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
||||
// (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp)
|
||||
s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120))
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
})
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "listen", Default: "127.0.0.1:12001",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
|
||||
Name: tp + "acp_query", Description: "向远程 ACP Agent(如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001)发起一个会话请求并等待回复,返回其最终回答文本,兼容 SSE 型与同步 JSON 型 ACP 服务端",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
||||
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
||||
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
||||
},
|
||||
"required": []string{"server_url", "prompt"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
|
||||
return r.Reply
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleAcpQuery)
|
||||
|
||||
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
|
||||
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001,设为空禁用)"},
|
||||
},
|
||||
},
|
||||
}, p.handleConfigure)
|
||||
|
||||
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
|
||||
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleStatus)
|
||||
|
||||
addr, _ := s.Settings().Get("listen")
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start ACP server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[%s] started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopServer()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) stopServer() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
p.serverID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/session", p.handleSession)
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverID = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] ACP server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
p.handleSessionPost(w, r)
|
||||
case "GET":
|
||||
p.handleSessionSSE(w, r)
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionPost 处理 JSON-RPC:session/new 与 session/update
|
||||
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Request *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"request,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
} `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "session/new":
|
||||
text := ""
|
||||
if req.Params.Request != nil {
|
||||
text = strings.TrimSpace(req.Params.Request.Text)
|
||||
}
|
||||
if text == "" {
|
||||
http.Error(w, "request.text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
|
||||
sid := strings.TrimSpace(req.Params.SessionID)
|
||||
p.mu.Lock()
|
||||
if sid != "" {
|
||||
if _, exists := p.sessions[sid]; !exists {
|
||||
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
|
||||
}
|
||||
} else {
|
||||
sid = fmt.Sprintf("session_%d", time.Now().UnixNano())
|
||||
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
|
||||
}
|
||||
st := p.sessions[sid]
|
||||
p.mu.Unlock()
|
||||
|
||||
// 延续上下文
|
||||
injectText := text
|
||||
p.mu.Lock()
|
||||
if len(st.History) > 0 {
|
||||
ctxText := strings.Join(st.History, "\n")
|
||||
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// 同步注入等待回复:不抢占打断,完整闭环返回文本。
|
||||
reply := ""
|
||||
if p.sdk != nil {
|
||||
reply = p.sdk.InjectInputSync(p.name, p.name,
|
||||
fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name))
|
||||
}
|
||||
|
||||
// 写回历史 + 填充 Replying 供 SSE 消费
|
||||
p.mu.Lock()
|
||||
st.History = append(st.History, "用户: "+text, "助手: "+reply)
|
||||
if len(st.History) > maxSessionTurns*2 {
|
||||
st.History = st.History[len(st.History)-maxSessionTurns*2:]
|
||||
}
|
||||
st.LastUsed = time.Now()
|
||||
if reply != "" {
|
||||
st.Replying = append(st.Replying, map[string]interface{}{
|
||||
"type": "reply", "text": reply,
|
||||
})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"session": map[string]interface{}{"id": sid},
|
||||
"reply": reply,
|
||||
},
|
||||
})
|
||||
|
||||
case "session/get":
|
||||
// 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序)
|
||||
sid := req.Params.SessionID
|
||||
p.mu.RLock()
|
||||
st := p.sessions[sid]
|
||||
var hist []string
|
||||
if st != nil {
|
||||
hist = append([]string{}, st.History...)
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
if st == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"session_id": sid,
|
||||
"status": "not_found",
|
||||
"messages": []interface{}{},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
limit := 10
|
||||
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
|
||||
limit = req.Params.Limit
|
||||
}
|
||||
start := 0
|
||||
if len(hist) > limit {
|
||||
start = len(hist) - limit
|
||||
}
|
||||
msgs := make([]map[string]interface{}, 0, len(hist)-start)
|
||||
for i := start; i < len(hist); i++ {
|
||||
role, text := "user", hist[i]
|
||||
if after, ok := strings.CutPrefix(text, "用户: "); ok {
|
||||
role, text = "user", after
|
||||
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
|
||||
role, text = "agent", after
|
||||
}
|
||||
msgs = append(msgs, map[string]interface{}{
|
||||
"role": role,
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"session_id": sid,
|
||||
"status": "completed",
|
||||
"messages": msgs,
|
||||
},
|
||||
})
|
||||
|
||||
case "session/update":
|
||||
sid := req.Params.SessionID
|
||||
p.mu.Lock()
|
||||
st := p.sessions[sid]
|
||||
p.mu.Unlock()
|
||||
if st == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if req.Params.Final {
|
||||
// 客户端结束会话:标记并保留历史(后续可再 session/new 续)
|
||||
p.mu.Lock()
|
||||
st.LastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"final": true},
|
||||
})
|
||||
|
||||
case "session/cancel":
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, req.Params.SessionID)
|
||||
p.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"canceled": true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionSSE 提供 SSE 事件流订阅
|
||||
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
|
||||
sid := r.URL.Query().Get("id")
|
||||
if sid == "" {
|
||||
http.Error(w, "id query param required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
st := p.sessions[sid]
|
||||
p.mu.RUnlock()
|
||||
if st == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
p.mu.RLock()
|
||||
replies := append([]map[string]interface{}{}, st.Replying...)
|
||||
p.mu.RUnlock()
|
||||
for _, rep := range replies {
|
||||
data, _ := json.Marshal(rep)
|
||||
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
|
||||
fl.Flush()
|
||||
}
|
||||
p.mu.Lock()
|
||||
st.Replying = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Outbound:ACP 客户端 ----
|
||||
|
||||
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
|
||||
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
|
||||
if strings.Contains(ct, "text/event-stream") {
|
||||
sc := bufio.NewScanner(bytes.NewReader(body))
|
||||
var last string
|
||||
for sc.Scan() {
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data != "" && data != "[DONE]" {
|
||||
last = data
|
||||
}
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
|
||||
}
|
||||
body = []byte(last)
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
|
||||
}
|
||||
return &raw, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
|
||||
serverURL, _ := args["server_url"].(string)
|
||||
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
|
||||
if serverURL == "" {
|
||||
return map[string]interface{}{"error": "server_url 不能为空"}, nil
|
||||
}
|
||||
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
|
||||
serverURL = "http://" + serverURL
|
||||
}
|
||||
prompt, _ := args["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
||||
}
|
||||
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||
timeoutSec := 120
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
}
|
||||
|
||||
endpoint := serverURL + "/api/session"
|
||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"request": map[string]interface{}{"text": prompt},
|
||||
}
|
||||
if sessionID != "" {
|
||||
params["session_id"] = sessionID
|
||||
}
|
||||
newBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
"method": "session/new",
|
||||
"params": params,
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
|
||||
}
|
||||
|
||||
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
var rpcResp struct {
|
||||
Result *struct {
|
||||
Session *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"session,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
} `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
if rpcResp.Error != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
|
||||
}
|
||||
if rpcResp.Result == nil {
|
||||
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
|
||||
// 兼容两种协议:
|
||||
// A) 标准/SSE 型(opencode、本插件服务端):result.session.id,回复经 SSE 事件流
|
||||
// B) 同步 JSON 型(pi bridge):result.sessionId + result.reply
|
||||
if rpcResp.Result.Reply != "" {
|
||||
return map[string]interface{}{
|
||||
"session_id": rpcResp.Result.SessionID,
|
||||
"status": "completed",
|
||||
"reply": rpcResp.Result.Reply,
|
||||
}, nil
|
||||
}
|
||||
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
|
||||
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
sid := rpcResp.Result.Session.ID
|
||||
|
||||
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
|
||||
|
||||
return map[string]interface{}{
|
||||
"session_id": sid,
|
||||
"status": "completed",
|
||||
"reply": replyText,
|
||||
"note": "延续会话:下次调用传此 session_id 可保持上下文",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
|
||||
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
|
||||
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
|
||||
req, _ := http.NewRequest("GET", sseURL, nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(SSE 读取失败: %v)", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bb, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
for sc.Scan() {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var evt struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Message *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message,omitempty"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &evt) == nil {
|
||||
text := evt.Text
|
||||
if evt.Message != nil && evt.Message.Text != "" {
|
||||
text = evt.Message.Text
|
||||
}
|
||||
if text != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "(未收到回复)"
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ---- Management ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "ACP HTTP 服务已禁用", nil
|
||||
}
|
||||
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverID
|
||||
p.srvMu.Unlock()
|
||||
|
||||
p.mu.RLock()
|
||||
n := len(p.sessions)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
|
||||
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
@ -5,7 +5,7 @@ ai_image plugin
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "ai_image",
|
||||
"name_zh": "AI绘图",
|
||||
"name_en": "AI Image",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["ai", "image", "draw", "generate"],
|
||||
"tags": [
|
||||
"ai",
|
||||
"image",
|
||||
"draw",
|
||||
"generate"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -21,6 +24,8 @@ type Plugin struct {
|
||||
provider string
|
||||
model string
|
||||
size string
|
||||
baseURL string
|
||||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -111,10 +116,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||||
Category: "ai_image", Secret: true,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "base_url", Default: "", Type: "string",
|
||||
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "provider", Default: "openai", Type: "string",
|
||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||
Category: "ai_image",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "model", Default: "dall-e-3", Type: "string",
|
||||
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||
|
||||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||
if p.sdk != nil {
|
||||
if dd := s.Settings().DataDir(); dd != "" {
|
||||
p.dataDir = dd
|
||||
}
|
||||
}
|
||||
if p.dataDir == "" {
|
||||
// 旧版内核无 DataDir API 时退到 /tmp
|
||||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||
}
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
|
||||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -209,6 +232,14 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
|
||||
}
|
||||
|
||||
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||||
// 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀;
|
||||
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
|
||||
endpoint := "https://api.openai.com/v1/images/generations"
|
||||
if p.baseURL != "" {
|
||||
base := strings.TrimSuffix(p.baseURL, "/v1")
|
||||
endpoint = base + "/v1/images/generations"
|
||||
}
|
||||
|
||||
body := openAIReq{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
||||
ResponseFormat: "url",
|
||||
}
|
||||
|
||||
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
|
||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
||||
urls[i] = d.URL
|
||||
}
|
||||
|
||||
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL):
|
||||
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
|
||||
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
|
||||
localPaths := make([]string, len(urls))
|
||||
var errs []string
|
||||
for i, u := range urls {
|
||||
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
localPaths[i] = path
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
|
||||
for _, pth := range localPaths {
|
||||
if pth != "" {
|
||||
content += "\n" + pth
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
content += "\n\n" + strings.Join(errs, "\n")
|
||||
}
|
||||
content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
||||
"images": urls,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"content": content,
|
||||
"images": localPaths,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"local_paths": localPaths,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
|
||||
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
|
||||
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
|
||||
dl := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
|
||||
resp, err := dl.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := ".png"
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||||
ext = ".jpg"
|
||||
} else if strings.Contains(ct, "webp") {
|
||||
ext = ".webp"
|
||||
}
|
||||
path := filepath.Join(p.dataDir, baseName+ext)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
type stabilityReq struct {
|
||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||
Width int `json:"width"`
|
||||
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
|
||||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
|
||||
"images": urls,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "bili",
|
||||
"name_zh": "B站视频下载",
|
||||
"name_en": "Bilibili Video Downloader",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["bili", "video", "download"],
|
||||
"tags": [
|
||||
"bili",
|
||||
"video",
|
||||
"download"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -30,6 +32,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Description: "B站视频下载后的保存目录",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "proxy", Default: "",
|
||||
Type: "string", DisplayName: "HTTP 代理",
|
||||
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
|
||||
Category: p.name,
|
||||
})
|
||||
if v, _ := s.Settings().Get("proxy"); v != nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.proxy = str
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||
Name: tp + "video",
|
||||
@ -94,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
// 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
|
||||
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
|
||||
outputDir = filepath.Clean(outputDir)
|
||||
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
|
||||
if outputDir == forbidden {
|
||||
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
|
||||
}
|
||||
}
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
|
||||
var out bytes.Buffer
|
||||
@ -101,7 +122,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd.Env = proxyEnv(p.proxy)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
@ -171,12 +192,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(taskDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir task dir: %w", err)
|
||||
}
|
||||
|
||||
dlArgs := []string{
|
||||
"--no-warnings",
|
||||
"--socket-timeout", "30",
|
||||
"--retries", "3",
|
||||
"--fragment-retries", "3",
|
||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
||||
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
|
||||
"--no-overwrites",
|
||||
}
|
||||
if format != "" {
|
||||
@ -184,7 +210,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
dlArgs = append(dlArgs, url)
|
||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd2.Env = proxyEnv(p.proxy)
|
||||
var dlOut bytes.Buffer
|
||||
cmd2.Stdout = &dlOut
|
||||
cmd2.Stderr = &dlOut
|
||||
@ -192,9 +218,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(outputDir)
|
||||
var newest string
|
||||
var newestTime int64
|
||||
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
|
||||
for _, f := range parts {
|
||||
os.Remove(f)
|
||||
}
|
||||
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
|
||||
for _, f := range residuals {
|
||||
os.Remove(f)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(taskDir)
|
||||
var mainFile string
|
||||
var mainSize int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
@ -203,30 +238,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
t := fi.ModTime().Unix()
|
||||
if t > newestTime {
|
||||
newestTime = t
|
||||
newest = e.Name()
|
||||
if fi.Size() > mainSize {
|
||||
mainSize = fi.Size()
|
||||
mainFile = e.Name()
|
||||
}
|
||||
}
|
||||
if newest == "" {
|
||||
if mainFile == "" {
|
||||
return map[string]interface{}{
|
||||
"content": "下载完成,但未找到视频文件",
|
||||
}, nil
|
||||
}
|
||||
dlPath := filepath.Join(outputDir, newest)
|
||||
fi, _ := os.Stat(dlPath)
|
||||
var fileSize int64
|
||||
if fi != nil {
|
||||
fileSize = fi.Size()
|
||||
}
|
||||
dlPath := filepath.Join(taskDir, mainFile)
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
|
||||
"file": dlPath,
|
||||
"filename": newest,
|
||||
"filename": mainFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func proxyEnv(proxy string) []string {
|
||||
env := os.Environ()
|
||||
if proxy != "" {
|
||||
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
|
||||
@ -17,10 +17,10 @@ require (
|
||||
golang.org/x/sys v0.16.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
{
|
||||
{
|
||||
"name": "browser",
|
||||
"name_zh": "浏览器",
|
||||
"name_en": "Browser",
|
||||
"version": "2.0.0",
|
||||
"version": "2.4.1",
|
||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
||||
"tags": [
|
||||
"web",
|
||||
"search",
|
||||
"fetch",
|
||||
"browser",
|
||||
"cdp"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -33,22 +35,49 @@ type Plugin struct {
|
||||
proxy string
|
||||
client *http.Client
|
||||
|
||||
sessions map[string]*BrowserSession
|
||||
nextID int
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
sessions map[string]*BrowserSession
|
||||
nextID int
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
||||
|
||||
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
||||
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||
sharedAllocCtx context.Context
|
||||
sharedAllocCancel context.CancelFunc
|
||||
sharedMu sync.Mutex
|
||||
}
|
||||
|
||||
type BrowserSession struct {
|
||||
id string
|
||||
allocCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
id string
|
||||
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
currentURL string
|
||||
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||
sessionKey string // 共享模式下的复用键(agent 来源标识,同 key 复用同一标签页)
|
||||
}
|
||||
|
||||
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_,防路径穿越。
|
||||
func sanitizeProfileName(name string) string {
|
||||
var b []byte
|
||||
for _, c := range []byte(name) {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||
b = append(b, c)
|
||||
}
|
||||
}
|
||||
if len(b) == 0 || string(b) == "." || string(b) == ".." {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -131,6 +160,18 @@ func errResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{"isError": true, "content": msg}
|
||||
}
|
||||
|
||||
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
|
||||
raw := strings.TrimSpace(readArg(args, "timeout", ""))
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
|
||||
}
|
||||
timeout, err := time.ParseDuration(raw)
|
||||
if err != nil || timeout <= 0 {
|
||||
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
@ -163,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.SetAutoRestart(true)
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "timeout", Default: "30", Type: "int",
|
||||
@ -186,6 +230,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.proxy = readCfg(s.Settings(), "proxy", "")
|
||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||
|
||||
// 持久化 profile 根目录:<data>/browser_profiles
|
||||
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||||
if s2, ok := dd.(string); ok && s2 != "" {
|
||||
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
||||
}
|
||||
}
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
cleaner := func(output string) string {
|
||||
@ -241,13 +292,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||
Name: tp + "start",
|
||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
||||
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
|
||||
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||
},
|
||||
"required": []string{"timeout"},
|
||||
},
|
||||
}, p.handleBrowserStart)
|
||||
|
||||
@ -273,7 +326,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
||||
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
|
||||
"format": map[string]interface{}{"type": "string", "description": "图片格式: png 或 jpeg(默认 png)"},
|
||||
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
@ -336,6 +389,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
}, p.handleScroll)
|
||||
|
||||
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||
Name: tp + "install",
|
||||
Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleBrowserInstall)
|
||||
|
||||
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||
Name: tp + "close",
|
||||
Description: "关闭交互式浏览器会话,释放资源。",
|
||||
@ -356,18 +418,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
if p.client != nil {
|
||||
p.client.CloseIdleConnections()
|
||||
}
|
||||
p.mu.Lock()
|
||||
for _, s := range p.sessions {
|
||||
s.Close()
|
||||
}
|
||||
p.sessions = nil
|
||||
p.mu.Unlock()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
p.stopOnce.Do(func() {
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
if p.client != nil {
|
||||
p.client.CloseIdleConnections()
|
||||
}
|
||||
p.mu.Lock()
|
||||
for _, s := range p.sessions {
|
||||
s.Close()
|
||||
}
|
||||
p.sessions = nil
|
||||
p.mu.Unlock()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -424,7 +488,9 @@ type searchResult struct {
|
||||
}
|
||||
|
||||
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
|
||||
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
|
||||
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
|
||||
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
|
||||
req, _ := http.NewRequest("GET", u, nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
@ -434,38 +500,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseBingResults(string(body), count), nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
|
||||
}
|
||||
results := parseBingResults(string(body), count)
|
||||
if len(results) == 0 {
|
||||
// 关键:把「解析不出来」与「真的没结果」区分开。
|
||||
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
|
||||
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseBingResults(html string, count int) []searchResult {
|
||||
var (
|
||||
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
|
||||
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
|
||||
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
|
||||
)
|
||||
|
||||
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
|
||||
//
|
||||
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
|
||||
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
|
||||
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
|
||||
func splitBingBlocks(pageHTML string) []string {
|
||||
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
|
||||
if len(locs) == 0 {
|
||||
return nil
|
||||
}
|
||||
blocks := make([]string, 0, len(locs))
|
||||
for i, loc := range locs {
|
||||
end := len(pageHTML)
|
||||
if i+1 < len(locs) {
|
||||
end = locs[i+1][0]
|
||||
}
|
||||
blocks = append(blocks, pageHTML[loc[1]:end])
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func parseBingResults(pageHTML string, count int) []searchResult {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
var results []searchResult
|
||||
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
for _, block := range splitBingBlocks(pageHTML) {
|
||||
if len(results) >= count {
|
||||
break
|
||||
}
|
||||
block := m[1]
|
||||
var r searchResult
|
||||
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
|
||||
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
|
||||
r.URL = hm[1]
|
||||
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
|
||||
var href, title string
|
||||
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
|
||||
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
|
||||
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||
}
|
||||
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
|
||||
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
|
||||
r.Title = stripTags(tm[1])
|
||||
href = bingRealURL(html.UnescapeString(href))
|
||||
|
||||
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
|
||||
var snippet string
|
||||
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
|
||||
snippet = html.UnescapeString(stripTags(m[1]))
|
||||
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
|
||||
snippet = html.UnescapeString(stripTags(m[1]))
|
||||
}
|
||||
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
|
||||
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
|
||||
r.Snippet = stripTags(sm[1])
|
||||
}
|
||||
if r.URL != "" && r.Title != "" {
|
||||
results = append(results, r)
|
||||
|
||||
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
|
||||
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
|
||||
continue
|
||||
}
|
||||
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
|
||||
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
|
||||
func bingRealURL(href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
|
||||
return href
|
||||
}
|
||||
u, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return href
|
||||
}
|
||||
raw := u.Query().Get("u")
|
||||
if !strings.HasPrefix(raw, "a1") {
|
||||
return href
|
||||
}
|
||||
b64 := raw[2:]
|
||||
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
|
||||
if dec, err := enc.DecodeString(b64); err == nil {
|
||||
s := string(dec)
|
||||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||
query := readArg(args, "query", "")
|
||||
if query == "" {
|
||||
@ -660,38 +800,82 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||
rawURL := readArg(args, "url", "")
|
||||
if rawURL == "" {
|
||||
return errResult("url is required"), nil
|
||||
}
|
||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||
if waitSec > 0 {
|
||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
||||
if err := p.ssrfCheck(rawURL); err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
var html string
|
||||
chromiumPath := "/usr/local/bin/chromium"
|
||||
if _, err := os.Stat(chromiumPath); err == nil {
|
||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||
|
||||
var title, html string
|
||||
rendered := false
|
||||
|
||||
ok, needInstall, _ := p.ensureBackend()
|
||||
if ok {
|
||||
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||
defer remoteCancel()
|
||||
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||
defer tabCancel()
|
||||
actions := []chromedp.Action{
|
||||
chromedp.Navigate(rawURL),
|
||||
chromedp.WaitReady("body"),
|
||||
}
|
||||
if waitSec > 0 {
|
||||
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
|
||||
}
|
||||
actions = append(actions,
|
||||
chromedp.Title(&title),
|
||||
chromedp.OuterHTML("html", &html),
|
||||
)
|
||||
// 整体限时 30s,防慢页拖死工具
|
||||
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
|
||||
defer rcancel()
|
||||
if err := chromedp.Run(rctx, actions...); err == nil {
|
||||
rendered = true
|
||||
} else {
|
||||
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
|
||||
}
|
||||
} else if needInstall {
|
||||
return map[string]interface{}{
|
||||
"error": "browser backend not installed",
|
||||
"need_install": true,
|
||||
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !rendered {
|
||||
chromiumPath := "/usr/local/bin/chromium"
|
||||
if _, err := os.Stat(chromiumPath); err != nil {
|
||||
if _, e2 := exec.LookPath("chromium"); e2 == nil {
|
||||
chromiumPath = "chromium"
|
||||
} else {
|
||||
return errResult("no chromium available"), nil
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errResult("chromium: " + err.Error()), nil
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Run() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return errResult("chromium: " + err.Error()), nil
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
<-done // 回收子进程避免僵尸
|
||||
return errResult("chromium dump-dom timeout (30s)"), nil
|
||||
}
|
||||
html = out.String()
|
||||
} else {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return errResult("http get: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
html = string(body)
|
||||
}
|
||||
title := ""
|
||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
||||
title = m[1]
|
||||
}
|
||||
|
||||
text := htmlToText(html)
|
||||
origLen := len(text)
|
||||
truncated := origLen > 5000
|
||||
@ -706,18 +890,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
||||
if truncated {
|
||||
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||
}
|
||||
return map[string]interface{}{"content": result, "title": title}, nil
|
||||
mode := "backend-tab"
|
||||
if !rendered {
|
||||
mode = "local-dump-dom"
|
||||
}
|
||||
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
|
||||
}
|
||||
|
||||
// ── Interactive Browser Session (CDP) ─────────────────────
|
||||
|
||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||
timeoutStr := readArg(args, "timeout", "10m")
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
func cdpReachable(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(endpoint + "/json/version")
|
||||
if err != nil {
|
||||
timeout = 10 * time.Minute
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
|
||||
func systemdUnitInstalled() bool {
|
||||
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
|
||||
return err == nil && len(out) > 0
|
||||
}
|
||||
|
||||
// startSystemdUnit 尝试 systemctl start(单元已安装但未运行时用)。
|
||||
func startSystemdUnit() error {
|
||||
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
|
||||
}
|
||||
|
||||
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。
|
||||
const cdpEndpoint = "http://127.0.0.1:9222"
|
||||
|
||||
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
|
||||
// 返回 (ok, needInstall, err)。
|
||||
func (p *Plugin) ensureBackend() (bool, bool, error) {
|
||||
if cdpReachable(cdpEndpoint) {
|
||||
return true, false, nil
|
||||
}
|
||||
if systemdUnitInstalled() {
|
||||
if err := startSystemdUnit(); err == nil {
|
||||
// 等待 CDP 就绪(chromium 启动 ~1-3s)
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if cdpReachable(cdpEndpoint) {
|
||||
return true, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false, fmt.Errorf("browser backend service installed but failed to start")
|
||||
}
|
||||
return false, true, nil // 未安装
|
||||
}
|
||||
|
||||
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
||||
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
||||
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||
if err := chromedp.Run(tabCtx); err != nil {
|
||||
tabCancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
return tabCtx, tabCancel, nil
|
||||
}
|
||||
|
||||
// localSpawnFailback 本地拉起一次性 Chromium(离线机器无法装 systemd 服务的兜底)。
|
||||
// 用临时 profile,登录态不跨会话保留——仅保证功能可用。
|
||||
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
@ -727,23 +964,88 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
if p.proxy != "" {
|
||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||
}
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
ctx, _ := chromedp.NewContext(allocCtx)
|
||||
|
||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
||||
if err := chromedp.Run(ctx); err != nil {
|
||||
cancel()
|
||||
return errResult("browser init failed: " + err.Error()), nil
|
||||
cancelAlloc()
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return allocCtx, cancelAlloc, nil, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||
timeout, err := parseBrowserSessionTimeout(args)
|
||||
if err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
session := &BrowserSession{
|
||||
allocCtx: allocCtx,
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
createdAt: time.Now(),
|
||||
timeout: timeout,
|
||||
source := readArg(args, "source", "")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
|
||||
// 同 source 复用已有标签页
|
||||
p.mu.Lock()
|
||||
for _, s := range p.sessions {
|
||||
if s.shared && s.sessionKey == source && !s.closed {
|
||||
s.mu.Lock()
|
||||
id := s.id
|
||||
cur := s.currentURL
|
||||
s.createdAt = time.Now()
|
||||
s.timeout = timeout
|
||||
closesAt := s.createdAt.Add(timeout)
|
||||
s.mu.Unlock()
|
||||
p.mu.Unlock()
|
||||
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"status": "reused",
|
||||
"url": cur,
|
||||
"timeout": timeout.String(),
|
||||
"closes_at": closesAt.Format(time.RFC3339),
|
||||
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
var session *BrowserSession
|
||||
|
||||
// 路径一:systemd 托管的共享后端(主路径)
|
||||
ok, needInstall, berr := p.ensureBackend()
|
||||
if ok {
|
||||
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||
probe, _ := chromedp.NewContext(remoteCtx)
|
||||
if err := chromedp.Run(probe); err != nil {
|
||||
remoteCancel()
|
||||
return errResult("connect to browser backend failed: " + err.Error()), nil
|
||||
}
|
||||
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||
if err := chromedp.Run(tabCtx); err != nil {
|
||||
remoteCancel()
|
||||
return errResult("open tab failed: " + err.Error()), nil
|
||||
}
|
||||
session = &BrowserSession{
|
||||
allocCtx: remoteCtx,
|
||||
cancel: tabCancel,
|
||||
ctx: tabCtx,
|
||||
createdAt: time.Now(),
|
||||
timeout: timeout,
|
||||
shared: true,
|
||||
sessionKey: source,
|
||||
}
|
||||
} else if needInstall {
|
||||
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
|
||||
"需要本机有 chromium 二进制(apt install chromium 或等价命令)," +
|
||||
"插件会注册 homeagent-browser.service 并启动。" +
|
||||
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||
return map[string]interface{}{
|
||||
"error": "backend not installed",
|
||||
"need_install": true,
|
||||
"guide": guide,
|
||||
}, nil
|
||||
} else {
|
||||
return errResult("browser backend error: " + berr.Error()), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
@ -755,7 +1057,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
|
||||
initURL := readArg(args, "url", "")
|
||||
if initURL != "" {
|
||||
if err := chromedp.Run(ctx,
|
||||
if err := chromedp.Run(session.ctx,
|
||||
chromedp.Navigate(initURL),
|
||||
chromedp.WaitReady("body"),
|
||||
); err != nil {
|
||||
@ -766,15 +1068,17 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
return errResult("navigate failed: " + err.Error()), nil
|
||||
}
|
||||
session.currentURL = initURL
|
||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
|
||||
}
|
||||
|
||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout)
|
||||
closesAt := session.createdAt.Add(timeout)
|
||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"url": initURL,
|
||||
"timeout": timeout.String(),
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"mode": "shared-backend",
|
||||
"url": initURL,
|
||||
"timeout": timeout.String(),
|
||||
"closes_at": closesAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -807,7 +1111,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
|
||||
return errResult("navigate failed: " + err.Error()), nil
|
||||
}
|
||||
s.currentURL = rawURL
|
||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))
|
||||
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))
|
||||
return map[string]interface{}{"status": "ok", "url": rawURL}, nil
|
||||
}
|
||||
|
||||
@ -825,6 +1129,9 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
||||
full = v
|
||||
}
|
||||
format := readArg(args, "format", "png")
|
||||
if format != "png" {
|
||||
return errResult("仅支持 png 格式"), nil
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if full {
|
||||
@ -837,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(buf)
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"format": format,
|
||||
"size": len(buf),
|
||||
"base64": b64,
|
||||
"data_uri": fmt.Sprintf("data:image/%s;base64,%s", format, b64),
|
||||
"status": "ok",
|
||||
"format": format,
|
||||
"size": len(buf),
|
||||
"base64": b64,
|
||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -872,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
||||
html = html[:maxChars] + "\n\n[HTML truncated]"
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"title": title,
|
||||
"url": currentURL,
|
||||
"html": html,
|
||||
"length": len(html),
|
||||
"status": "ok",
|
||||
"title": title,
|
||||
"url": currentURL,
|
||||
"html": html,
|
||||
"length": len(html),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -997,16 +1304,124 @@ func (p *Plugin) cleanupLoop() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
for id, s := range p.sessions {
|
||||
if time.Since(s.createdAt) >= s.timeout {
|
||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
||||
s.mu.Lock()
|
||||
closesAt := s.createdAt.Add(s.timeout)
|
||||
expired := !now.Before(closesAt)
|
||||
s.mu.Unlock()
|
||||
if expired {
|
||||
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
|
||||
delete(p.sessions, id)
|
||||
go s.Close()
|
||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
||||
s.Close()
|
||||
// NoMemory:会话生命周期通知,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── browser_install:安装 systemd 托管的共享浏览器后端 ──────────
|
||||
|
||||
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
|
||||
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
|
||||
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
|
||||
if cdpReachable(cdpEndpoint) {
|
||||
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
|
||||
}
|
||||
|
||||
// 探测 chromium 二进制
|
||||
chromePath := ""
|
||||
for _, c := range []string{
|
||||
"/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
|
||||
} {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
chromePath = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
|
||||
chromePath = out
|
||||
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
|
||||
chromePath = out
|
||||
}
|
||||
if chromePath == "" {
|
||||
return map[string]interface{}{
|
||||
"error": "chromium binary not found",
|
||||
"hint": "请先安装 chromium:apt install chromium 或等价命令,然后重试 browser_install",
|
||||
}, nil
|
||||
}
|
||||
|
||||
profileDir := ""
|
||||
if p.profilesDir != "" {
|
||||
profileDir = filepath.Join(p.profilesDir, "shared")
|
||||
os.MkdirAll(profileDir, 0755)
|
||||
} else {
|
||||
// profilesDir 未注入(无 data_dir),退到 /var/lib/homeagent-browser
|
||||
profileDir = "/var/lib/homeagent-browser"
|
||||
os.MkdirAll(profileDir, 0755)
|
||||
}
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, chromePath, profileDir)
|
||||
|
||||
unitPath := "/etc/systemd/system/homeagent-browser.service"
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": "write unit failed (need root): " + err.Error(),
|
||||
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds)",
|
||||
"manual_cmds": []string{
|
||||
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
|
||||
"sudo systemctl daemon-reload",
|
||||
"sudo systemctl enable --now homeagent-browser.service",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
for _, cmd := range [][]string{
|
||||
{"systemctl", "daemon-reload"},
|
||||
{"systemctl", "enable", "--now", "homeagent-browser.service"},
|
||||
} {
|
||||
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// 等待 CDP 就绪
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if cdpReachable(cdpEndpoint) {
|
||||
guide := "共享浏览器后端已就绪(CDP " + cdpEndpoint + ")。\n" +
|
||||
"全机共享说明:本机所有 agent(HomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
|
||||
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
|
||||
"- HomeAgent 内部:browser_start 即自动连接本后端\n" +
|
||||
"- 其他 agent:让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect)\n" +
|
||||
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
|
||||
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
|
||||
return map[string]interface{}{
|
||||
"status": "installed",
|
||||
"endpoint": cdpEndpoint,
|
||||
"chrome": chromePath,
|
||||
"profile": profileDir,
|
||||
"guide": guide,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
|
||||
}
|
||||
|
||||
213
example/browser/plugin_test.go
Normal file
213
example/browser/plugin_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
|
||||
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
|
||||
t.Fatalf("expected required timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
|
||||
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 2*time.Hour+30*time.Minute {
|
||||
t.Fatalf("timeout=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
|
||||
for _, value := range []string{"invalid", "0s", "-1m"} {
|
||||
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
|
||||
t.Errorf("timeout %q should be rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
|
||||
p := &Plugin{
|
||||
name: "browser",
|
||||
sessions: map[string]*BrowserSession{
|
||||
"browser_1": {
|
||||
id: "browser_1",
|
||||
shared: true,
|
||||
sessionKey: "qq",
|
||||
createdAt: time.Now().Add(-time.Hour),
|
||||
timeout: time.Minute,
|
||||
currentURL: "https://example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := result.(map[string]interface{})
|
||||
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
|
||||
t.Fatalf("unexpected result: %#v", out)
|
||||
}
|
||||
s := p.sessions["browser_1"]
|
||||
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
|
||||
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bing 解析器(2026-09 版式)─────────────────────────────
|
||||
//
|
||||
// 背景:旧实现把块内**第一个 <a>** 当标题 —— 拿到的是 Bing 的「来源行」
|
||||
// `deepin.orghttps://www.deepin.org`;摘要正则 `<div class="b_caption">.*?<p>`
|
||||
// 对现代 Bing 命中 0/N(摘要已迁到 p.b_lineclamp*),于是结果「有标题没摘要」,
|
||||
// 模型只好反复换词重搜。夹具 testdata/bing_cn.html 是真实 cn.bing.com 响应裁剪。
|
||||
|
||||
func TestParseBingResultsRealBingHTML(t *testing.T) {
|
||||
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||
if err != nil {
|
||||
t.Fatalf("读取夹具失败: %v", err)
|
||||
}
|
||||
results := parseBingResults(string(page), 3)
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("应解析出 3 条,实际 %d 条: %+v", len(results), results)
|
||||
}
|
||||
for i, r := range results {
|
||||
if !strings.HasPrefix(r.URL, "http") {
|
||||
t.Errorf("第 %d 条 URL 不是真实地址: %q", i+1, r.URL)
|
||||
}
|
||||
if strings.Contains(r.Title, "http") || strings.Contains(r.Title, "://") {
|
||||
t.Errorf("第 %d 条标题混入了 URL(旧 bug 的典型症状): %q", i+1, r.Title)
|
||||
}
|
||||
if r.Snippet == "" {
|
||||
t.Errorf("第 %d 条没有摘要(旧 bug 的典型症状): %+v", i+1, r)
|
||||
}
|
||||
}
|
||||
// 第一条必须与样本里的真实结果一致
|
||||
if results[0].URL != "https://www.deepin.org/" {
|
||||
t.Errorf("第一条 URL 应为 https://www.deepin.org/,实际 %q", results[0].URL)
|
||||
}
|
||||
if !strings.Contains(results[0].Title, "deepin") {
|
||||
t.Errorf("第一条标题不对: %q", results[0].Title)
|
||||
}
|
||||
if len(results[0].Snippet) < 10 || strings.Contains(results[0].Snippet, "://") {
|
||||
t.Errorf("第一条摘要不对(应是有内容的文本): %q", results[0].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
// 块内嵌套 <li>(deep links)时不能截断 —— 旧的 `<li class="b_algo"(?s)(.*?)</li>` 会在此翻车
|
||||
func TestParseBingResultsNestedLiKeepsResult(t *testing.T) {
|
||||
page := `<ol id="b_results"><li class="b_algo" data-id iid=SERP.1>` +
|
||||
`<h2><a href="https://a.example/x" h="ID=SERP,1">真标题</a></h2>` +
|
||||
`<div class="b_caption"><p class="b_lineclamp2">真摘要</p></div>` +
|
||||
`<div><ul><li><a href="https://sub.example/deeplink">子链接</a></li></ul></div>` +
|
||||
`</li><li class="b_algo"><h2><a href="https://b.example/y">第二条</a></h2>` +
|
||||
`<p class="b_lineclamp3">摘要二</p></li></ol>`
|
||||
rs := parseBingResults(page, 5)
|
||||
if len(rs) != 2 {
|
||||
t.Fatalf("应解析 2 条,实际 %d 条: %+v", len(rs), rs)
|
||||
}
|
||||
if rs[0].URL != "https://a.example/x" || rs[0].Title != "真标题" || rs[0].Snippet != "真摘要" {
|
||||
t.Errorf("第一条解析错误: %+v", rs[0])
|
||||
}
|
||||
if rs[1].Title != "第二条" || rs[1].Snippet != "摘要二" {
|
||||
t.Errorf("第二条(无 b_caption,摘要走 b_lineclamp3)解析错误: %+v", rs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBingRealURLDecodesRedirectWrapper(t *testing.T) {
|
||||
// Bing 跳转包装:/ck/a?...&u=a1<base64url>
|
||||
wrapped := "/ck/a?!&&p=abc&u=a1aHR0cHM6Ly93d3cuZGVlcGluLm9yZy96aC9EZWVwaW4v&ntb=1"
|
||||
if got := bingRealURL(wrapped); got != "https://www.deepin.org/zh/Deepin/" {
|
||||
t.Errorf("未解开跳转包装: %q", got)
|
||||
}
|
||||
if got := bingRealURL("https://direct.example/p"); got != "https://direct.example/p" {
|
||||
t.Errorf("直链不应被改动: %q", got)
|
||||
}
|
||||
// 解不开时保守返回原值,不能返回空
|
||||
bad := "/ck/a?u=a1!!!!"
|
||||
if got := bingRealURL(bad); got == "" {
|
||||
t.Errorf("解不开时应保留原值,实际返回空")
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripFunc 把任意请求转给本地测试服务器,从而离线测 bingSearch 的完整路径
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestBingSearchReportsParseFailureInsteadOfEmptyResult(t *testing.T) {
|
||||
var seenURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("<html><body>no result blocks here</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
seenURL = r.URL.String()
|
||||
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||
})
|
||||
})}}
|
||||
|
||||
if _, err := p.bingSearch("任意查询", 5); err == nil {
|
||||
t.Fatal("解析不出结果时必须报错,而不是伪装成「没有结果」")
|
||||
} else if !strings.Contains(err.Error(), "未解析出结果") {
|
||||
t.Errorf("错误信息应说明是解析失败: %v", err)
|
||||
}
|
||||
// 数据源必须是 cn.bing.com(www.bing.com 对程序化请求回 302,拿不到结果块)
|
||||
if !strings.Contains(seenURL, "cn.bing.com") {
|
||||
t.Errorf("应请求 cn.bing.com,实际 %q", seenURL)
|
||||
}
|
||||
if strings.Contains(seenURL, "www.bing.com") {
|
||||
t.Errorf("不应再请求 www.bing.com: %q", seenURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常路径:能解析出结果时返回结果且不报错
|
||||
func TestBingSearchParsesFixtureThroughClient(t *testing.T) {
|
||||
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||
if err != nil {
|
||||
t.Fatalf("读取夹具失败: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(page)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||
})
|
||||
})}}
|
||||
|
||||
results, err := p.bingSearch("deepin", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际 %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("应返回 2 条(count 生效),实际 %d", len(results))
|
||||
}
|
||||
if results[0].Snippet == "" {
|
||||
t.Errorf("摘要不应为空: %+v", results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试 URL 失败: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
1
example/browser/testdata/bing_cn.html
vendored
Normal file
1
example/browser/testdata/bing_cn.html
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -5,7 +5,7 @@ calendar plugin
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "calendar",
|
||||
"name_zh": "日历",
|
||||
"name_en": "Calendar",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "日历事件管理,支持提醒和重复事件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
||||
"tags": [
|
||||
"calendar",
|
||||
"event",
|
||||
"reminder",
|
||||
"schedule"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -15,31 +15,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
RepeatNone = "none"
|
||||
RepeatDaily = "daily"
|
||||
RepeatWeekday = "weekday"
|
||||
RepeatWeekly = "weekly"
|
||||
RepeatBiweekly = "biweekly"
|
||||
RepeatMonthly = "monthly"
|
||||
RepeatYearly = "yearly"
|
||||
RepeatNone = "none"
|
||||
RepeatDaily = "daily"
|
||||
RepeatWeekday = "weekday"
|
||||
RepeatWeekly = "weekly"
|
||||
RepeatBiweekly = "biweekly"
|
||||
RepeatMonthly = "monthly"
|
||||
RepeatYearly = "yearly"
|
||||
RepeatLunarYearly = "lunar_yearly"
|
||||
)
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
AllDay bool `json:"all_day,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Reminds []int `json:"reminds,omitempty"`
|
||||
RemindAt []int64 `json:"remind_at,omitempty"`
|
||||
Repeat string `json:"repeat,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Lunar bool `json:"lunar,omitempty"`
|
||||
LunarMonth int `json:"lunar_month,omitempty"`
|
||||
LunarDay int `json:"lunar_day,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
AllDay bool `json:"all_day,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Reminds []int `json:"reminds,omitempty"`
|
||||
RemindAt []int64 `json:"remind_at,omitempty"`
|
||||
Repeat string `json:"repeat,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Lunar bool `json:"lunar,omitempty"`
|
||||
LunarMonth int `json:"lunar_month,omitempty"`
|
||||
LunarDay int `json:"lunar_day,omitempty"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
@ -134,6 +134,18 @@ func readArg[T string | int64 | float64](args map[string]interface{}, key string
|
||||
return fallback
|
||||
}
|
||||
|
||||
func readArgBool(args map[string]interface{}, key string) bool {
|
||||
if v, ok := args[key]; ok && v != nil {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s == "1" || strings.EqualFold(s, "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Time Helpers ---
|
||||
|
||||
var shortWeekday = map[time.Weekday]string{
|
||||
@ -185,14 +197,14 @@ func daysInLunarYear(year int) int {
|
||||
}
|
||||
y := lunarInfo[year-1900]
|
||||
sum := 0
|
||||
for i := 0x8000; i > 0; i >>= 1 {
|
||||
for i := 0x8000; i > 0x8; i >>= 1 {
|
||||
if y&i > 0 {
|
||||
sum += 30
|
||||
} else {
|
||||
sum += 29
|
||||
}
|
||||
}
|
||||
return sum
|
||||
return sum + leapDays(year)
|
||||
}
|
||||
|
||||
func leapMonth(year int) int {
|
||||
@ -236,11 +248,9 @@ func lunarToSolar(year, month, day int) (time.Time, bool) {
|
||||
offset += daysInLunarYear(y)
|
||||
}
|
||||
lm := leapMonth(year)
|
||||
_ = lm
|
||||
for m := 1; m < month; m++ {
|
||||
offset += monthDays(year, m)
|
||||
if m == lm {
|
||||
offset += leapDays(year)
|
||||
}
|
||||
}
|
||||
offset += day - 1
|
||||
solar := baseSolar.AddDate(0, 0, offset)
|
||||
@ -254,7 +264,7 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t.After(after) || t.Equal(after) {
|
||||
if t.After(after) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
@ -266,14 +276,25 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
}
|
||||
p.dataDir = filepath.Join(dataHome, ".homeagent", "calendar")
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
p.loadEvents()
|
||||
|
||||
// 持久化交由 stop handler:内核会在调用 Stop() 之前执行,
|
||||
// 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。
|
||||
s.RegisterStopHandler(p.saveEvents)
|
||||
// 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"event_add", sdk.ToolDef{
|
||||
@ -341,7 +362,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"today", sdk.ToolDef{
|
||||
Name: tp + "today", Description: "Show today's events with countdown.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleToday)
|
||||
@ -349,7 +370,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"week", sdk.ToolDef{
|
||||
Name: tp + "week", Description: "Show this week's events grouped by day.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleWeek)
|
||||
@ -388,7 +409,6 @@ func (p *Plugin) Stop() error {
|
||||
p.remindTicker.Stop()
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
p.saveEvents()
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
@ -411,9 +431,9 @@ func (p *Plugin) checkReminders() {
|
||||
now := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
changed := false
|
||||
var injectMsgs []string
|
||||
|
||||
for i := range p.events {
|
||||
e := &p.events[i]
|
||||
@ -455,7 +475,7 @@ func (p *Plugin) checkReminders() {
|
||||
if e.Note != "" {
|
||||
msg += fmt.Sprintf("\n📝 %s", e.Note)
|
||||
}
|
||||
go p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
||||
injectMsgs = append(injectMsgs, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@ -479,8 +499,17 @@ func (p *Plugin) checkReminders() {
|
||||
pid = e.ParentID
|
||||
}
|
||||
next.ParentID = pid
|
||||
newEvents = append(newEvents, *next)
|
||||
changed = true
|
||||
dup := false
|
||||
for _, ev := range p.events {
|
||||
if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
newEvents = append(newEvents, *next)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(newEvents) > 0 {
|
||||
@ -491,6 +520,12 @@ func (p *Plugin) checkReminders() {
|
||||
if changed {
|
||||
p.saveEventsLocked()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
for _, msg := range injectMsgs {
|
||||
// NoMemory:日程到点提醒,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
|
||||
@ -560,9 +595,7 @@ func (p *Plugin) cleanupPastEvents() {
|
||||
keep = append(keep, e)
|
||||
continue
|
||||
}
|
||||
if e.Repeat != "" && e.Repeat != RepeatNone {
|
||||
keep = append(keep, e)
|
||||
}
|
||||
_ = e // 过时重复事件不再保留:next 已由 nextOccurrence 追加
|
||||
}
|
||||
p.events = keep
|
||||
}
|
||||
@ -573,6 +606,17 @@ func (p *Plugin) eventsFile() string {
|
||||
return filepath.Join(p.dataDir, "events.json")
|
||||
}
|
||||
|
||||
// cleanupData 删除插件时清理本地持久化数据文件。
|
||||
func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) loadEvents() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@ -616,7 +660,7 @@ func (p *Plugin) saveEventsLocked() {
|
||||
NextEventID: p.nextEventID,
|
||||
}
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.eventsFile(), b, 0644)
|
||||
atomicWriteJSON(p.eventsFile(), b)
|
||||
}
|
||||
|
||||
// --- Helper: parse remind_before ---
|
||||
@ -692,10 +736,7 @@ func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error
|
||||
note := readArg(args, "note", "")
|
||||
remindStr := readArg(args, "remind_before", "")
|
||||
reminds := parseReminds(remindStr)
|
||||
lunar := false
|
||||
if v := readArg(args, "lunar", ""); v == "true" {
|
||||
lunar = true
|
||||
}
|
||||
lunar := readArgBool(args, "lunar")
|
||||
lunarMonth := int(readArg(args, "lunar_month", int64(0)))
|
||||
lunarDay := int(readArg(args, "lunar_day", int64(0)))
|
||||
|
||||
@ -897,10 +938,12 @@ func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, er
|
||||
e.Repeat = v
|
||||
}
|
||||
}
|
||||
if v := readArg(args, "lunar", ""); v == "true" {
|
||||
e.Lunar = true
|
||||
} else if v == "false" {
|
||||
e.Lunar = false
|
||||
if v, ok := args["lunar"]; ok && v != nil {
|
||||
if b, ok := v.(bool); ok {
|
||||
e.Lunar = b
|
||||
} else if s, ok := v.(string); ok {
|
||||
e.Lunar = s == "1" || strings.EqualFold(s, "true")
|
||||
}
|
||||
}
|
||||
if v := readArg(args, "lunar_month", int64(0)); v > 0 {
|
||||
e.LunarMonth = int(v)
|
||||
@ -1138,3 +1181,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||
func atomicWriteJSON(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
100
example/deepsearch/README.md
Normal file
100
example/deepsearch/README.md
Normal file
@ -0,0 +1,100 @@
|
||||
# 联网检索插件(HomeAgent)
|
||||
|
||||
给 agent 补上**真正的信息检索**能力:检索交给本地 SearXNG(多引擎聚合、结构化 JSON),
|
||||
并补上「读完前 K 篇再回答」的深检索。
|
||||
|
||||
## 为什么需要它(背景)
|
||||
|
||||
agent 原本只有 `browser_*` 那套浏览器工具,联网检索实际只有 `browser_search` 一个入口,而它是
|
||||
**「抓 Bing HTML + 正则解析」**:
|
||||
|
||||
| 缺陷 | 实测结果 |
|
||||
|---|---|
|
||||
| 标题取的是结果块里**第一个 `<a>`** | 拿到的是 Bing 的「来源行」而非标题 → `deepin.orghttps://www.deepin.org` |
|
||||
| 摘要正则 `<div class="b_caption">.*?<p>` | 对现代 Bing **命中 0/10**(摘要已迁到 `p.b_lineclamp*`)→ 结果**完全没有摘要** |
|
||||
| 用 `www.bing.com` | 程序化请求直接 302;`cn.bing.com` 才返回 10 个结果块 |
|
||||
| 单引擎、无兜底、无去重、无站点读取 | 模型只能反复换词重搜(日志里 8 秒 6 连击) |
|
||||
|
||||
结果就是日志里那句用户反馈:**「你的搜索能力好像不太行啊」**。
|
||||
|
||||
## 依赖:本地 SearXNG(由本插件托管)
|
||||
|
||||
插件会**自己管后端**:
|
||||
|
||||
- **启动时**:探 `healthz`;已在跑就**直接接管**(不重启),没跑就 `docker compose up -d` 并等就绪(上限 6s)
|
||||
- **停止时**:跑 `docker compose stop -t 2` 关闭它
|
||||
|
||||
配置项 `manage_searxng`(默认 true)与 `searxng_dir`(默认 `/root/searxng-agent`)控制这套行为;
|
||||
`stop_searxng_on_exit`(默认 true)设 false 可让后端在插件停止后继续跑(**插件重载频繁时建议设 false**,
|
||||
否则每次重载都会把后端重启一遍)。
|
||||
|
||||
### 生命周期契约(依据内核源码,非猜测)
|
||||
|
||||
| 环节 | 内核行为 |
|
||||
|---|---|
|
||||
| 停止插件 | 发 `plugin.stop` → 插件先跑 **RunStopHandlers(LIFO、幂等)** → 再 `Stop()` → `exit(0)` |
|
||||
| 宽限期 | **5 秒**;未退出则直接 SIGKILL —— 所以关闭动作限时 4s(`searxShutdownBudget`) |
|
||||
| stdin 关闭 | 同样会跑 handlers + `Stop()` |
|
||||
| 崩溃/被 kill | 关闭动作不会执行,后端会留在运行态;下次启动探测到就直接接管(**更安全的失败方向**) |
|
||||
| 自动重启 | `SetAutoRestart(true)` 由注入的 runtime 在 `plugin.start` 后经 `lifecycle.autoRestart` **显式上报**内核 |
|
||||
|
||||
### SearXNG 侧配置
|
||||
|
||||
部署在 **.60**,`127.0.0.1:8888`:
|
||||
|
||||
```
|
||||
/root/searxng-agent/docker-compose.yml # host 网络(要访问宿主 clash)
|
||||
/root/searxng-agent/settings.yml # json 输出 + limiter 关闭 + 出站走 clash
|
||||
```
|
||||
|
||||
两个必须知道的坑:
|
||||
|
||||
1. **`search.formats` 必须含 `json`**,否则 `/search?format=json` 返回 **403**(看起来像网络问题,其实是配置)。
|
||||
2. 该镜像默认 `GRANIAN_PORT=8080`,而 granian 的 `GRANIAN_*` **优先级高于 settings.yml**:
|
||||
.60 上 8080 被 homeagent 占用 → 不改 `SEARXNG_PORT` 就是无休止的 `Address already in use` 崩溃循环。
|
||||
|
||||
实测可用的引擎(2026-09-12):`duckduckgo`、`brave`、`google cse`;`quark` 时好时坏;
|
||||
`baidu`/`google` 经代理出口触发 CAPTCHA,`sogou` 崩溃,`wikidata` 报 HTTP error(已关)。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `deepsearch_search` | 联网检索(首选):标题 + URL + 摘要 + 发布时间,支持 `engines`/`category`/`time_range`/`language`,自动按 URL 去重并按分数排序;会回报**引擎覆盖度与无响应引擎** |
|
||||
| `deepsearch_news` | 新闻检索:`news` 类别 + 默认最近一周;新闻为空时自动回退 general + 时间范围 |
|
||||
| `deepsearch_fetch` | 抓单个网页并抽正文(去脚本/样式/导航),返回标题 + 纯文本,可设截断长度 |
|
||||
| `deepsearch_deep` | **深检索**:检索 → 并行抓前 K 篇正文 → 一次返回「候选清单 + 证据正文」;单篇失败不影响整体 |
|
||||
| `deepsearch_status` | 自检:healthz、json 是否可用、延迟、**哪些引擎真的在返回结果**(检索出问题先跑这个) |
|
||||
|
||||
## 配置项
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `searxng_url` | `http://127.0.0.1:8888` | 本地 SearXNG 地址 |
|
||||
| `max_results` | `8` | 默认条数(控制上下文体积) |
|
||||
| `language` | `zh-CN` | 检索语言 |
|
||||
| `safesearch` | `0` | 0 关 / 1 中 / 2 严 |
|
||||
| `request_timeout` | `20` | 单次请求超时(秒) |
|
||||
| `fetch_max_chars` | `4000` | `deepsearch_fetch` 正文上限 |
|
||||
| `proxy` | 空 | 仅作用于本插件直连抓取(搜索出网由 SearXNG 侧负责) |
|
||||
| `user_agent` | Chrome UA | 抓取用 |
|
||||
|
||||
每次调用前重读配置,改完即时生效。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```bash
|
||||
go test -count=1 -race ./... # 11 项测试(httptest 打桩 SearXNG)
|
||||
|
||||
# 真实后端联调(默认跳过):跑的就是当初失败的那条查询
|
||||
DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
|
||||
|
||||
hmapdev build # 产出 dist/deep_search_bundle.hmap
|
||||
```
|
||||
|
||||
## 已知边界
|
||||
|
||||
- **知乎等站点对直连抓取返回 403**(反爬),`deepsearch_deep` 会如实标注该篇抓取失败并继续;
|
||||
这类页面请改用浏览器工具(`browser_navigate` + `browser_render`)。
|
||||
- 引擎可用性随出口 IP 与目标站点风控变化;`deepsearch_status` 与每次结果里的「覆盖度」行就是给这个用的。
|
||||
- 未做正文去重/相似度合并:同一事件的多篇转载会各占一条(摘要已能区分)。
|
||||
21
example/deepsearch/go.mod
Normal file
21
example/deepsearch/go.mod
Normal file
@ -0,0 +1,21 @@
|
||||
module deepsearch-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
81
example/deepsearch/live_test.go
Normal file
81
example/deepsearch/live_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 真实后端联调(默认跳过,需显式指定地址):
|
||||
//
|
||||
// DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
|
||||
//
|
||||
// 它跑的就是当初失败的场景(日志里那条「你的搜索能力好像不太行啊」对应的查询),
|
||||
// 用来回答一个具体问题:换了后端之后,模型拿到的是不是「带摘要的相关结果」。
|
||||
func TestLiveSearxng(t *testing.T) {
|
||||
base := os.Getenv("DEEPSEARCH_LIVE_SEARXNG")
|
||||
if base == "" {
|
||||
t.Skip("未设置 DEEPSEARCH_LIVE_SEARXNG,跳过真实后端联调")
|
||||
}
|
||||
p := &Plugin{
|
||||
name: "deepsearch",
|
||||
searxURL: strings.TrimRight(base, "/"),
|
||||
maxItems: 6,
|
||||
language: "zh-CN",
|
||||
fetchMax: 1200,
|
||||
userAgent: defaultUA,
|
||||
}
|
||||
p.ensure()
|
||||
|
||||
// 1) 自检
|
||||
st, err := p.handleStatus(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
t.Logf("status: %v", st)
|
||||
|
||||
// 2) 当初失败的那条查询
|
||||
res, err := p.handleSearch(map[string]interface{}{"query": "深度科技 deepin 开发者 被开除"})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
t.Logf("检索结果:\n%s", txt)
|
||||
if !strings.Contains(txt, "摘要:") {
|
||||
t.Errorf("结果里应当有摘要(这正是原实现缺失的东西)")
|
||||
}
|
||||
if !strings.Contains(txt, "覆盖:") {
|
||||
t.Errorf("应报告引擎覆盖度")
|
||||
}
|
||||
|
||||
// 3) 正文抓取(取第一条结果的 URL)
|
||||
var firstURL string
|
||||
for _, line := range strings.Split(txt, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(l, "http") {
|
||||
firstURL = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstURL == "" {
|
||||
t.Fatal("未从结果中解析出 URL")
|
||||
}
|
||||
page, err := p.handleFetch(map[string]interface{}{"url": firstURL, "max_chars": float64(600)})
|
||||
if err != nil {
|
||||
t.Logf("抓取 %s 失败(真实站点有反爬/需 JS 属正常):%v", firstURL, err)
|
||||
} else {
|
||||
body := page.(map[string]interface{})["content"].(string)
|
||||
t.Logf("抓取 %s 正文前 400 字:%s", firstURL, oneLine(body, 400))
|
||||
}
|
||||
|
||||
// 4) 深检索
|
||||
deep, err := p.handleDeep(map[string]interface{}{"query": "统信 UOS 内核工程师 西装 事件", "top_k": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatalf("deep: %v", err)
|
||||
}
|
||||
dTxt := deep.(map[string]interface{})["content"].(string)
|
||||
if !strings.Contains(dTxt, "候选清单") || !strings.Contains(dTxt, "正文证据") {
|
||||
t.Errorf("深检索输出结构不对")
|
||||
}
|
||||
t.Logf("深检索输出前 800 字:\n%s", oneLine(dTxt, 800))
|
||||
}
|
||||
12
example/deepsearch/plg.json
Normal file
12
example/deepsearch/plg.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "deepsearch",
|
||||
"name_zh": "联网检索",
|
||||
"name_en": "Deep Search",
|
||||
"version": "1.1.2",
|
||||
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"sdk": "1.2.0",
|
||||
"tags": ["search", "web", "searxng", "retrieval", "news"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
1038
example/deepsearch/plugin.go
Normal file
1038
example/deepsearch/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
343
example/deepsearch/plugin_test.go
Normal file
343
example/deepsearch/plugin_test.go
Normal file
@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
p := &Plugin{
|
||||
name: "deepsearch",
|
||||
searxURL: srv.URL,
|
||||
maxItems: 5,
|
||||
language: "zh-CN",
|
||||
fetchMax: 1000,
|
||||
userAgent: "test-agent",
|
||||
http: srv.Client(),
|
||||
}
|
||||
return p, srv
|
||||
}
|
||||
|
||||
// 一份贴近真实 SearXNG 的响应:含重复 URL、缺摘要、多引擎、无响应引擎
|
||||
const sampleResponse = `{
|
||||
"query": "deepin 被开除",
|
||||
"results": [
|
||||
{"url":"https://www.zhihu.com/question/1?utm_source=x","title":"网传统信内核开发工程师因没穿西服被开除","content":"截止1月9日最新情况…","engines":["duckduckgo","brave"],"score":9.5,"publishedDate":"2026-09-10T00:00:00"},
|
||||
{"url":"https://www.zhihu.com/question/1","title":"网传统信内核开发工程师因没穿西服被开除(重复项)","content":"重复条目","engines":["brave"],"score":1.0},
|
||||
{"url":"https://www.163.com/dy/article/KIQURODQ.html","title":"离谱!传某信创操作系统大厂因西装开除核心开发者","content":"一位负责Linux内核开发的核心工程师…","engines":["brave","quark"],"score":7.2},
|
||||
{"url":"https://bbs.deepin.org.cn/zh","title":"deepin官方论坛","content":"","engines":["duckduckgo"],"score":2.0}
|
||||
],
|
||||
"answers": [],
|
||||
"suggestions": ["deepin 王勇 离职"],
|
||||
"unresponsive_engines": [["baidu","CAPTCHA"],["sogou","unexpected crash"]],
|
||||
"timings": {"search": 1.2}
|
||||
}`
|
||||
|
||||
// 1) 检索:去重 + 按分数排序 + 摘要/覆盖度输出
|
||||
func TestSearchDedupAndFormat(t *testing.T) {
|
||||
var gotQuery url.Values
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/search" {
|
||||
gotQuery = r.URL.Query()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(sampleResponse))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
res, err := p.handleSearch(map[string]interface{}{"query": "deepin 被开除", "count": float64(5)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotQuery.Get("format") != "json" {
|
||||
t.Errorf("必须要求 json 输出,实际 %q", gotQuery.Get("format"))
|
||||
}
|
||||
// SearXNG 的 /search **不认** count/limit(实测两者都返回同样的条数),
|
||||
// 所以「要几条」必须由插件侧截断 —— 也不要再发这种无意义参数(曾以为它生效过)。
|
||||
if gotQuery.Get("limit") != "" || gotQuery.Get("count") != "" {
|
||||
t.Errorf("不应依赖 SearXNG 的条数参数(它不认): %q", gotQuery.Encode())
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
// utm_source 应被规范化掉,重复项只剩一条
|
||||
if n := strings.Count(txt, "zhihu.com/question/1"); n != 1 {
|
||||
t.Errorf("URL 未正确去重(出现 %d 次):\n%s", n, txt)
|
||||
}
|
||||
if !strings.Contains(txt, "网传统信内核开发工程师") {
|
||||
t.Errorf("缺少标题: %s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "摘要:") {
|
||||
t.Errorf("应输出摘要: %s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "baidu(CAPTCHA)") {
|
||||
t.Errorf("应回报无响应引擎(让模型知道覆盖度): %s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "duckduckgo") || !strings.Contains(txt, "quark") {
|
||||
t.Errorf("应回报引擎覆盖: %s", txt)
|
||||
}
|
||||
// 高分条目应排在前面
|
||||
if strings.Index(txt, "统信内核开发工程师") > strings.Index(txt, "离谱!") {
|
||||
t.Errorf("未按分数排序:\n%s", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 403(未开 json)必须给出可操作提示,而不是裸错误
|
||||
func TestSearchForbiddenHint(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("Forbidden"))
|
||||
})
|
||||
_, err := p.handleSearch(map[string]interface{}{"query": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("应返回错误")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "403") || !strings.Contains(msg, "formats") {
|
||||
t.Errorf("403 提示应指向 json/limiter 配置,实际: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 空结果:要给出原因与下一步建议
|
||||
func TestSearchEmptyHint(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"query":"x","results":[],"suggestions":["换个词"],"unresponsive_engines":[["google","CAPTCHA"]]}`))
|
||||
})
|
||||
res, err := p.handleSearch(map[string]interface{}{"query": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{"未返回结果", "google(CAPTCHA)", "换个词", "deepsearch_news"} {
|
||||
if !strings.Contains(txt, want) {
|
||||
t.Errorf("空结果提示缺少 %q: %s", want, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 新闻:应带 categories=news 与 time_range=week;新闻为空时回退 general
|
||||
func TestNewsParamsAndFallback(t *testing.T) {
|
||||
var calls []url.Values
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Query())
|
||||
if r.URL.Query().Get("categories") == "news" {
|
||||
_, _ = w.Write([]byte(`{"query":"n","results":[]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"query":"n","results":[{"url":"https://a.com/1","title":"回退结果","content":"内容","engines":["brave"],"score":1}]}`))
|
||||
})
|
||||
res, err := p.handleNews(map[string]interface{}{"query": "某事"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("新闻为空时应回退 general,实际调用 %d 次", len(calls))
|
||||
}
|
||||
if calls[0].Get("categories") != "news" || calls[0].Get("time_range") != "week" {
|
||||
t.Errorf("首次应为 news + week,实际 categories=%q time_range=%q", calls[0].Get("categories"), calls[0].Get("time_range"))
|
||||
}
|
||||
if tmp := res.(map[string]interface{})["content"].(string); !strings.Contains(tmp, "回退结果") {
|
||||
t.Errorf("回退结果未被采用: %s", tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 正文抽取:去脚本/样式/导航,保留 article
|
||||
func TestFetchExtractsArticle(t *testing.T) {
|
||||
page := `<!doctype html><html><head><title>测试标题 - 站点</title>
|
||||
<style>.x{color:red}</style><script>var secret="SHOULD_NOT_APPEAR";</script></head>
|
||||
<body><nav>导航链接</nav><article>
|
||||
<p>第一段正文,包含关键事实。</p><p>第二段正文。</p>
|
||||
</article><footer>页脚</footer></body></html>`
|
||||
var srvURL string
|
||||
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
srvURL = srv.URL
|
||||
// 注意:不要用 example.com 之类真实域名——本机 DNS/proxy 会把它们转走,测试会飘
|
||||
res, err := p.handleFetch(map[string]interface{}{"url": srvURL + "/a"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
if !strings.Contains(txt, "第一段正文") {
|
||||
t.Errorf("正文丢失: %s", txt)
|
||||
}
|
||||
if strings.Contains(txt, "SHOULD_NOT_APPEAR") {
|
||||
t.Errorf("脚本内容不应出现: %s", txt)
|
||||
}
|
||||
if strings.Contains(txt, "导航链接") || strings.Contains(txt, "页脚") {
|
||||
t.Errorf("导航/页脚应被剥离: %s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "测试标题") {
|
||||
t.Errorf("标题应被提取: %s", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 深检索:候选 + 正文证据;单篇失败不应导致整体失败
|
||||
func TestDeepSearch(t *testing.T) {
|
||||
var srvURL string // 处理函数先于 server 存在,故用闭包变量回填
|
||||
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
_, _ = w.Write([]byte(`{"query":"d","results":[
|
||||
{"url":"` + srvURL + `/ok1","title":"好文一","content":"摘要一","engines":["brave"],"score":3},
|
||||
{"url":"` + srvURL + `/bad","title":"打不开的","content":"摘要二","engines":["brave"],"score":2},
|
||||
{"url":"` + srvURL + `/ok2","title":"好文二","content":"摘要三","engines":["brave"],"score":1}]}`))
|
||||
case "/ok1", "/ok2":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte("<html><body><article><p>正文内容 " + r.URL.Path + "</p></article></body></html>"))
|
||||
case "/bad":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srvURL = srv.URL
|
||||
res, err := p.handleDeep(map[string]interface{}{"query": "d", "top_k": float64(3), "max_chars": float64(500)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{"候选清单", "正文证据", "正文内容 /ok1", "正文内容 /ok2", "抓取失败"} {
|
||||
if !strings.Contains(txt, want) {
|
||||
t.Errorf("深检索输出缺少 %q:\n%s", want, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 自检:健康检查 + 探测检索 + 引擎覆盖统计
|
||||
func TestStatusReportsEngines(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(sampleResponse))
|
||||
})
|
||||
res, err := p.handleStatus(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
m := res.(map[string]interface{})
|
||||
if m["healthz"] != 200 {
|
||||
t.Errorf("healthz 应为 200,实际 %v", m["healthz"])
|
||||
}
|
||||
if m["search_ok"] != true {
|
||||
t.Errorf("search_ok 应为 true:%v", m["search_ok"])
|
||||
}
|
||||
engs, ok := m["engines_returning_results"].(map[string]int)
|
||||
if !ok || engs["brave"] == 0 || engs["quark"] == 0 {
|
||||
t.Errorf("引擎统计不正确: %#v", m["engines_returning_results"])
|
||||
}
|
||||
}
|
||||
|
||||
// 8) 摘要压成一行并按字符截断(避免巨长摘要吃掉上下文)
|
||||
func TestOneLineTruncate(t *testing.T) {
|
||||
got := oneLine("第一行\n第二行\t第三行", 5)
|
||||
if strings.Contains(got, "\n") {
|
||||
t.Errorf("应为单行: %q", got)
|
||||
}
|
||||
if r := []rune(got); len(r) != 6 { // 5 字符 + 省略号
|
||||
t.Errorf("截断长度不符: %q (%d runes)", got, len(r))
|
||||
}
|
||||
}
|
||||
|
||||
// 9) 正文抽取长度上限生效
|
||||
func TestHtmlToTextTruncation(t *testing.T) {
|
||||
long := strings.Repeat("字", 5000)
|
||||
_, text := htmlToText("<html><body><article><p>"+long+"</p></article></body></html>", 100)
|
||||
if !strings.Contains(text, "已截断") {
|
||||
t.Errorf("超长正文应被截断: %d", len([]rune(text)))
|
||||
}
|
||||
}
|
||||
|
||||
// 10) 非 http(s) 协议应被拒绝
|
||||
func TestFetchRejectsBadScheme(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
if _, err := p.handleFetch(map[string]interface{}{"url": "file:///etc/passwd"}); err == nil {
|
||||
t.Fatal("file:// 应被拒绝")
|
||||
}
|
||||
if _, err := p.handleFetch(map[string]interface{}{"url": "javascript:alert(1)"}); err == nil {
|
||||
t.Fatal("javascript: 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 11) raw 模式返回结构化 JSON(排查用)
|
||||
func TestSearchRawMode(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(sampleResponse))
|
||||
})
|
||||
res, err := p.handleSearch(map[string]interface{}{"query": "q", "raw": true})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
m, ok := res.(*searxResponse)
|
||||
if !ok {
|
||||
t.Fatalf("raw 应返回结构化响应,实际 %T", res)
|
||||
}
|
||||
if len(m.Results) != 4 {
|
||||
t.Errorf("结果数应为 4(raw 不去重),实际 %d", len(m.Results))
|
||||
}
|
||||
if _, err := json.Marshal(m); err != nil {
|
||||
t.Errorf("结构化结果应可序列化: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 13) 条数截断:SearXNG 不认条数参数,插件必须自己截,并且**如实说明**给了几条
|
||||
func TestSearchTruncatesToCountAndSaysSo(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(sampleResponse)) // 4 条,去重后 3 条
|
||||
})
|
||||
res, err := p.handleSearch(map[string]interface{}{"query": "deepin", "count": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
txt := res.(map[string]interface{})["content"].(string)
|
||||
|
||||
// 必须明确区分「命中几条」与「返回几条」:写成「命中 N 条」而实际给了 M<N 条,
|
||||
// 模型会把 N 当成拿到手的条数(实测被 agent 当成事实报给用户)。
|
||||
if !strings.Contains(txt, "命中 3 条,返回前 2 条") {
|
||||
t.Errorf("应如实说明命中数与返回数:\n%s", txt)
|
||||
}
|
||||
// 按 score 排序后的前两条:zhihu(9.5)、163(7.2);第三条 bbs.deepin(2.0) 必须被截掉
|
||||
if !strings.Contains(txt, "统信内核开发工程师") || !strings.Contains(txt, "离谱!") {
|
||||
t.Errorf("前两条(按分数)应在:\n%s", txt)
|
||||
}
|
||||
if strings.Contains(txt, "deepin官方论坛") {
|
||||
t.Errorf("第 3 条(score 最低)超出了 count=2,不该出现:\n%s", txt)
|
||||
}
|
||||
// 条目行数也要正好 2 条(防「头部说 2 条、正文还是全量」)
|
||||
if n := strings.Count(txt, "\n http"); n != 2 {
|
||||
t.Errorf("正文应恰好 2 条,实际 %d 条:\n%s", n, txt)
|
||||
}
|
||||
}
|
||||
|
||||
// 14) 条数上限:不因为模型要 200 条就真给 200 条
|
||||
func TestLimitResultsCapsAndDefaults(t *testing.T) {
|
||||
p := &Plugin{name: "deepsearch", maxItems: 8}
|
||||
many := make([]searxResult, 30)
|
||||
for i := range many {
|
||||
many[i] = searxResult{URL: "https://e.test/", Title: "t"}
|
||||
}
|
||||
if got := len(p.limitResults(map[string]interface{}{}, many)); got != 8 {
|
||||
t.Errorf("未指定 count 时应取配置的 max_items=8,实际 %d", got)
|
||||
}
|
||||
if got := len(p.limitResults(map[string]interface{}{"count": float64(3)}, many)); got != 3 {
|
||||
t.Errorf("count=3 应返回 3 条,实际 %d", got)
|
||||
}
|
||||
if got := len(p.limitResults(map[string]interface{}{"count": float64(200)}, many)); got != maxSearchResults {
|
||||
t.Errorf("超过上限应收敛到 %d 条,实际 %d", maxSearchResults, got)
|
||||
}
|
||||
// 结果比 count 少时不能造数据
|
||||
few := many[:2]
|
||||
if got := len(p.limitResults(map[string]interface{}{"count": float64(5)}, few)); got != 2 {
|
||||
t.Errorf("结果不足时应原样返回,实际 %d", got)
|
||||
}
|
||||
}
|
||||
164
example/deepsearch/searxng.go
Normal file
164
example/deepsearch/searxng.go
Normal file
@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
// SearXNG 生命周期托管:插件启动时拉起搜索后端,插件停止时关闭它。
|
||||
//
|
||||
// 契约依据(内核侧 internal/plugin/proc/*,已逐行核对):
|
||||
// - 内核停止插件:发 `plugin.stop` → 插件先跑 RunStopHandlers(LIFO、幂等)→ 再 Stop() → exit(0)
|
||||
// - 若插件未在 stopGracePeriod(**5 秒**)内退出,内核直接 SIGKILL
|
||||
// - stdin 关闭(内核消失)同样会跑 handlers + Stop()
|
||||
//
|
||||
// 因此这里的关闭动作必须**有界**:searxShutdownBudget 取 4s,留 1s 余量。
|
||||
//
|
||||
// 归属规则(谁拉起谁关):**只有本插件真正执行了 `docker compose up -d` 的实例才算「我们起的」**。
|
||||
// 探活发现已在运行的实例只「接管」——不认领关闭责任。否则同一台机器上的第二个实例
|
||||
// (E2E 测试拉起的插件、另一个 daemon)退出时会把生产后端一起带走:实测就是这条把
|
||||
// 线上搜索服务反复关停的(测试实例用默认配置,测试结束就 `docker compose stop`)。
|
||||
// 若插件是被 kill -9 / OOM 带走的,关闭动作不会执行 —— SearXNG 会留在运行态;
|
||||
// 下次 Start 探测到它在跑就直接接管,这是更安全的失败方向。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cfgManageSearx = "manage_searxng"
|
||||
cfgSearxDir = "searxng_dir"
|
||||
cfgStopOnExit = "stop_searxng_on_exit"
|
||||
|
||||
defaultSearxDir = "/root/searxng-agent"
|
||||
|
||||
searxProbeTimeout = 1500 * time.Millisecond // 单次 healthz 探测
|
||||
searxUpBudget = 20 * time.Second // docker compose up -d 的上限(正常 1s 内返回)
|
||||
searxReadyBudget = 6 * time.Second // up 之后等 healthz 就绪的上限
|
||||
searxShutdownBudget = 4 * time.Second // 必须 < 内核 5s 宽限期
|
||||
)
|
||||
|
||||
// searxBudget 把四个时间预算收拢,便于单测注入短值(否则测试要真等就绪窗口)。
|
||||
type searxBudget struct {
|
||||
probe time.Duration
|
||||
up time.Duration
|
||||
ready time.Duration
|
||||
shutdown time.Duration
|
||||
}
|
||||
|
||||
func (p *Plugin) budget() searxBudget {
|
||||
b := p.bud
|
||||
if b.probe == 0 {
|
||||
b.probe = searxProbeTimeout
|
||||
}
|
||||
if b.up == 0 {
|
||||
b.up = searxUpBudget
|
||||
}
|
||||
if b.ready == 0 {
|
||||
b.ready = searxReadyBudget
|
||||
}
|
||||
if b.shutdown == 0 {
|
||||
b.shutdown = searxShutdownBudget
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// cmdRunner 抽出来是为了让生命周期逻辑可单测:注入假执行器,不起真容器。
|
||||
type cmdRunner func(ctx context.Context, dir, name string, args ...string) (string, error)
|
||||
|
||||
func defaultRunner(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// searxReachable 探测搜索后端是否可用(只看 healthz,不发检索请求)。
|
||||
func (p *Plugin) searxReachable(timeout time.Duration) bool {
|
||||
if p.searxURL == "" {
|
||||
return false
|
||||
}
|
||||
base := p.http
|
||||
if base == nil {
|
||||
base = &http.Client{}
|
||||
}
|
||||
cl := *base // 复制一份,避免改到共享 client 的超时
|
||||
cl.Timeout = timeout
|
||||
req, err := http.NewRequest(http.MethodGet, p.searxURL+"/healthz", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("User-Agent", p.userAgent)
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode < 400
|
||||
}
|
||||
|
||||
// ensureSearxng 在插件启动时确保搜索后端在跑;已在跑则直接接管,不重启。
|
||||
func (p *Plugin) ensureSearxng() {
|
||||
b := p.budget()
|
||||
if !p.manageSearx {
|
||||
log.Printf("[%s] 未启用 SearXNG 托管(manage_searxng=false),假定 %s 由外部维护", p.name, p.searxURL)
|
||||
return
|
||||
}
|
||||
if p.searxReachable(b.probe) {
|
||||
// 只接管,不认领:不是我们拉起来的,就不能由我们关掉
|
||||
log.Printf("[%s] SearXNG 已在运行(%s),直接接管(不认领关闭责任)", p.name, p.searxURL)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), b.up)
|
||||
out, err := p.run(ctx, p.searxDir, "docker", "compose", "up", "-d")
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[%s] 拉起 SearXNG 失败(dir=%s,请检查 manage_searxng/searxng_dir 配置): %v;输出: %s",
|
||||
p.name, p.searxDir, err, oneLine(out, 300))
|
||||
return
|
||||
}
|
||||
log.Printf("[%s] 已执行 docker compose up -d(%s):%s", p.name, p.searxDir, oneLine(out, 200))
|
||||
|
||||
deadline := time.Now().Add(b.ready)
|
||||
for time.Now().Before(deadline) {
|
||||
if p.searxReachable(800 * time.Millisecond) {
|
||||
log.Printf("[%s] SearXNG 就绪", p.name)
|
||||
p.markSearxOwned()
|
||||
return
|
||||
}
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
}
|
||||
log.Printf("[%s] SearXNG 已启动但 %s 内未就绪;首次检索会自动等待", p.name, b.ready)
|
||||
p.markSearxOwned()
|
||||
}
|
||||
|
||||
func (p *Plugin) markSearxOwned() {
|
||||
p.searxMu.Lock()
|
||||
p.searxOwned = true
|
||||
p.searxMu.Unlock()
|
||||
}
|
||||
|
||||
// shutdownSearxng 关闭搜索后端。幂等,且有界(内核宽限期 5s,这里最多 4s)。
|
||||
func (p *Plugin) shutdownSearxng() {
|
||||
b := p.budget()
|
||||
p.searxMu.Lock()
|
||||
owned := p.searxOwned
|
||||
p.searxOwned = false
|
||||
p.searxMu.Unlock()
|
||||
|
||||
if !owned {
|
||||
return // 不是我们拉起来的 / 已经关过
|
||||
}
|
||||
if !p.manageSearx || !p.stopOnExit {
|
||||
log.Printf("[%s] 保留 SearXNG 运行(stop_searxng_on_exit=false)", p.name)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), b.shutdown)
|
||||
defer cancel()
|
||||
out, err := p.run(ctx, p.searxDir, "docker", "compose", "stop", "-t", "2")
|
||||
if err != nil {
|
||||
// 故意只记日志:这里再重试就会拖过内核宽限期,被 SIGKILL 更糟
|
||||
log.Printf("[%s] 关闭 SearXNG 失败(忽略): %v;输出: %s", p.name, err, oneLine(out, 200))
|
||||
return
|
||||
}
|
||||
log.Printf("[%s] 已关闭 SearXNG", p.name)
|
||||
}
|
||||
223
example/deepsearch/searxng_test.go
Normal file
223
example/deepsearch/searxng_test.go
Normal file
@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeCall struct {
|
||||
dir string
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (c fakeCall) String() string { return c.name + " " + strings.Join(c.args, " ") }
|
||||
|
||||
// newFakeRunner 记录调用并返回预设结果
|
||||
func newFakeRunner(calls *[]fakeCall, out string, err error) cmdRunner {
|
||||
var mu sync.Mutex
|
||||
return func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
mu.Lock()
|
||||
*calls = append(*calls, fakeCall{dir: dir, name: name, args: args})
|
||||
mu.Unlock()
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// fastBudget 把就绪窗口压到毫秒级,避免单测真等
|
||||
func fastBudget() searxBudget {
|
||||
return searxBudget{
|
||||
probe: 50 * time.Millisecond,
|
||||
up: time.Second,
|
||||
ready: 200 * time.Millisecond,
|
||||
shutdown: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// 1) 后端没跑 → 应执行 docker compose up -d,并认领关闭责任
|
||||
func TestEnsureSearxngStartsWhenUnreachable(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "Container searxng-agent Started", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("应恰好拉起一次,实际 %d 次:%v", len(calls), calls)
|
||||
}
|
||||
got := calls[0]
|
||||
if got.name != "docker" || strings.Join(got.args, " ") != "compose up -d" {
|
||||
t.Errorf("命令不对:%s", got)
|
||||
}
|
||||
if got.dir != "/tmp/fake-searx" {
|
||||
t.Errorf("工作目录应为配置的 compose 目录,实际 %q", got.dir)
|
||||
}
|
||||
if !p.searxOwned {
|
||||
t.Error("既然是我们拉起的,就应认领关闭责任")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 后端已在跑 → 不重启,**且不认领关闭责任**
|
||||
//
|
||||
// 这条是关键:同一台机器上会有第二个实例(E2E 测试拉起的插件、另一个 daemon)。
|
||||
// 如果「接管」也算「我拥有」,任一实例退出就会把生产后端关掉 —— 线上实测就是
|
||||
// 测试实例在 teardown 时 `docker compose stop`,把搜索服务反复关停。
|
||||
func TestEnsureSearxngAdoptsRunningBackendWithoutOwning(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("已在跑就不该重启它,实际执行了:%v", calls)
|
||||
}
|
||||
if p.searxOwned {
|
||||
t.Error("不是我们拉起的,就不能认领关闭责任(否则退出时会带走别人的后端)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2b) 接管的实例退出时,一个 docker 命令都不能发
|
||||
func TestAdoptedBackendSurvivesShutdown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("接管来的后端在退出时必须留着,实际执行了:%v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 关掉托管 → 完全不碰 docker
|
||||
func TestEnsureSearxngDisabled(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: false, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
if len(calls) != 0 || p.searxOwned {
|
||||
t.Errorf("manage_searxng=false 时不该有任何动作:calls=%v owned=%v", calls, p.searxOwned)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 拉起失败不能让插件起不来(记日志即可)
|
||||
func TestEnsureSearxngFailureNonFatal(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "Cannot connect to the Docker daemon", errors.New("exit status 1")),
|
||||
}
|
||||
p.ensureSearxng() // 不应 panic
|
||||
if p.searxOwned {
|
||||
t.Error("没拉起来就不该认领关闭责任(否则停止时会去关一个不是我们起的服务)")
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 停止:关掉我们拉起的后端,且幂等
|
||||
func TestShutdownStopsOwnedBackend(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
runner := newFakeRunner(&calls, "ok", nil)
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: runner,
|
||||
}
|
||||
p.ensureSearxng()
|
||||
calls = nil
|
||||
|
||||
p.shutdownSearxng()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("应执行一次 compose stop,实际 %v", calls)
|
||||
}
|
||||
if got := strings.Join(calls[0].args, " "); !strings.HasPrefix(got, "compose stop") {
|
||||
t.Errorf("停止命令不对:%s", got)
|
||||
}
|
||||
if p.searxOwned {
|
||||
t.Error("停止后应清掉认领标记")
|
||||
}
|
||||
|
||||
p.shutdownSearxng() // 幂等:不应再调一次
|
||||
if len(calls) != 1 {
|
||||
t.Errorf("重复停止应无副作用,实际 %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 不是我们拉起的 → 停止时不许动它
|
||||
func TestShutdownSkippedWhenNotOwned(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxDir: "/tmp/fake-searx", manageSearx: true, stopOnExit: true,
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.shutdownSearxng()
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("不该去停一个我们没起的服务:%v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 配了「停止时保留」→ 认领过也不关
|
||||
func TestShutdownKeepsBackendWhenConfigured(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: false, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
calls = nil
|
||||
p.shutdownSearxng()
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("stop_searxng_on_exit=false 时不应关闭:%v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 8) Stop() 自身也要收尾(内核 stdin 关闭路径不会走 stop handler 的注册顺序之外)
|
||||
func TestStopTriggersShutdown(t *testing.T) {
|
||||
var calls []fakeCall
|
||||
p := &Plugin{
|
||||
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||
}
|
||||
p.ensureSearxng()
|
||||
calls = nil
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatalf("Stop 返回错误: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Errorf("Stop 应触发一次关闭,实际 %v", calls)
|
||||
}
|
||||
}
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "editdoc",
|
||||
"name_zh": "文档编辑",
|
||||
"name_en": "Document Editor",
|
||||
|
||||
@ -4,15 +4,19 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
scriptPath string
|
||||
venvPython string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -20,6 +24,30 @@ func (p *Plugin) Name() string { return p.name }
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "script_path", Default: "", Type: "string",
|
||||
DisplayName: "编辑脚本路径",
|
||||
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "venv_python", Default: "", Type: "string",
|
||||
DisplayName: "venv Python 解释器",
|
||||
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
if v, err := s.Settings().Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = str
|
||||
}
|
||||
}
|
||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = str
|
||||
}
|
||||
}
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
@ -80,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
|
||||
scriptPath := p.scriptPath
|
||||
if scriptPath == "" {
|
||||
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
|
||||
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
|
||||
}
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
|
||||
}
|
||||
|
||||
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
|
||||
pythonBin := "python3"
|
||||
if _, err := os.Stat(venvPython); err == nil {
|
||||
pythonBin = venvPython
|
||||
if p.venvPython == "" {
|
||||
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||
}
|
||||
if _, err := os.Stat(p.venvPython); err != nil {
|
||||
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "files",
|
||||
"name_zh": "文件系统",
|
||||
"name_en": "File System",
|
||||
|
||||
@ -27,24 +27,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "dir",
|
||||
Default: "/",
|
||||
Default: "",
|
||||
Type: "string",
|
||||
DisplayName: "文件系统根目录",
|
||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /",
|
||||
Category: "files",
|
||||
})
|
||||
|
||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||
dir := getSetting[string](s.Settings(), "dir", "")
|
||||
if strings.HasPrefix(dir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, dir[2:])
|
||||
}
|
||||
if dir == "" {
|
||||
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
base := "."
|
||||
if err == nil {
|
||||
if ds, ok := dataDir.(string); ok && ds != "" {
|
||||
base = ds
|
||||
}
|
||||
}
|
||||
dir = filepath.Join(base, "files_sandbox")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve files.dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir files.dir: %w", err)
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = real
|
||||
}
|
||||
p.filesDir = abs
|
||||
os.MkdirAll(p.filesDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
@ -143,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
if !withinSandbox(base, abs) {
|
||||
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||
}
|
||||
return abs, nil
|
||||
real, err := evalReal(base, abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !withinSandbox(base, real) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
func withinSandbox(base, abs string) bool {
|
||||
if base == "/" {
|
||||
return true
|
||||
}
|
||||
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func evalReal(base, abs string) (string, error) {
|
||||
existing := abs
|
||||
var tail []string
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(existing)
|
||||
if err == nil {
|
||||
full := real
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
full = filepath.Join(full, tail[i])
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
if link, lerr := os.Readlink(existing); lerr == nil {
|
||||
target := link
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(existing), target)
|
||||
}
|
||||
if t, aerr := filepath.Abs(target); aerr == nil {
|
||||
target = filepath.Clean(t)
|
||||
}
|
||||
if !withinSandbox(base, target) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
tail = append(tail, filepath.Base(existing))
|
||||
existing = parent
|
||||
}
|
||||
}
|
||||
|
||||
// handleRead implements the read tool.
|
||||
|
||||
25
example/luademo/README.md
Normal file
25
example/luademo/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# luademo
|
||||
|
||||
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
|
||||
|
||||
- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
|
||||
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||
- **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner)
|
||||
- **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`
|
||||
- **其他**:`register_api`、`set_auto_restart`
|
||||
|
||||
## 本地独立测试
|
||||
|
||||
```bash
|
||||
lua main.lua # 使用 sdk.lua mock,不依赖内核
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。
|
||||
105
example/luademo/main.lua
Normal file
105
example/luademo/main.lua
Normal file
@ -0,0 +1,105 @@
|
||||
-- luademo plugin — 展示 v0.8.0 Lua SDK 全部能力
|
||||
-- 运行环境:内核注入真实实现;lua main.lua 可用 sdk.lua mock 独立测试
|
||||
local plugin = { name = "luademo" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
sdk.log("info", "luademo starting...")
|
||||
|
||||
-- 注册配置项(WebUI 可展示)
|
||||
sdk.settings.register_def({
|
||||
key = "plugin.luademo.greeting",
|
||||
default = "Hello",
|
||||
type = "string",
|
||||
display_name = "Greeting",
|
||||
description = "Greeting prefix for the hello tool",
|
||||
category = "luademo",
|
||||
})
|
||||
|
||||
-- 注册工具:no_memory(输出跳过记忆计算)+ cleaner(计算层过滤函数)
|
||||
sdk.register_tool("luademo_hello", {
|
||||
description = "A hello world tool with no_memory and cleaner",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
cleaner = function(text) return "CLEANED:" .. text end,
|
||||
}, function(args)
|
||||
local prefix, err = sdk.settings.get_core("plugin.luademo.greeting")
|
||||
if err ~= nil then prefix = "Hello" end
|
||||
return { content = (prefix or "Hello") .. " from luademo plugin!" }
|
||||
end)
|
||||
|
||||
-- 注册工具:数据类 API 巡检(memory/doc/knowledge/text_memory/llm/settings/social)
|
||||
sdk.register_tool("luademo_probe", {
|
||||
description = "Exercise every aligned data API and return combined results",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
}, function(args)
|
||||
local res = {}
|
||||
|
||||
local ok, err = sdk.memory.commit({ { subject = "demo", relation = "uses", object = "lua" } })
|
||||
res.memory_commit = { ok = ok, err = err }
|
||||
local recalled, rerr = sdk.memory.recall("demo", 1)
|
||||
res.memory_recall = { result = recalled, err = rerr }
|
||||
|
||||
ok, err = sdk.doc.insert({ id = "demo-1", title = "lua demo doc", content = "hello lua world" })
|
||||
res.doc_insert = { ok = ok, err = err }
|
||||
local docs, derr = sdk.doc.query("lua", 2)
|
||||
res.doc_query = { result = docs, err = derr }
|
||||
|
||||
ok, err = sdk.knowledge.add("luademo", "lua knowledge entry")
|
||||
res.knowledge_add = { ok = ok, err = err }
|
||||
local entries, kerr = sdk.knowledge.search("luademo", 2)
|
||||
res.knowledge_search = { result = entries, err = kerr }
|
||||
|
||||
ok, err = sdk.text_memory.append({ role = "tool", content = "luademo probe ran", channel = "luademo" })
|
||||
res.text_memory = { ok = ok, err = err }
|
||||
|
||||
local sources, serr = sdk.llm.list_sources()
|
||||
res.llm_sources = { result = sources, err = serr }
|
||||
|
||||
local v, verr = sdk.settings.get_core("agent.name")
|
||||
res.settings_get_core = { result = v, err = verr }
|
||||
local defs, defserr = sdk.settings.defs("plugin.luademo")
|
||||
res.settings_defs = { result = defs, err = defserr }
|
||||
|
||||
local persons, perr = sdk.social.list_persons()
|
||||
res.social_persons = { result = persons, err = perr }
|
||||
|
||||
return { content = res }
|
||||
end)
|
||||
|
||||
-- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
|
||||
sdk.register_stage("before_toolcall", function(ctx)
|
||||
local calls = ctx.tool_calls or {}
|
||||
if calls[1] then
|
||||
sdk.log("info", "luademo stage before_toolcall: tool=" .. tostring(calls[1].name))
|
||||
end
|
||||
return nil
|
||||
end, "own_tools")
|
||||
|
||||
-- 阶段钩子:全局作用域(修改 ctx 字段会写回内核,见 applyLuaStageResult)
|
||||
sdk.register_stage("pre_action", function(ctx)
|
||||
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
|
||||
-- 演示 stage 写回:给 llm_text 追加标记(内核会同步回 StageContext)
|
||||
if ctx.llm_text then
|
||||
ctx.llm_text = ctx.llm_text .. "[luademo]"
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
|
||||
-- 输出通道:路由输出到外部渠道(def 支持 no_memory/cleaner)
|
||||
sdk.register_output_channel("luademo_out", 0, "luademo push channel",
|
||||
{ no_memory = true, cleaner = function(t) return "OCLEANED:" .. t end },
|
||||
function(args) return { content = "out-channel ack" } end)
|
||||
|
||||
-- 输入通道
|
||||
sdk.register_input_channel("luademo_in", { no_memory = true })
|
||||
|
||||
-- 其他 API
|
||||
sdk.register_api("luademo.ping")
|
||||
sdk.set_auto_restart(true)
|
||||
|
||||
sdk.log("info", "luademo started")
|
||||
end
|
||||
|
||||
function plugin.stop() sdk.log("info", "luademo stopped") end
|
||||
return plugin
|
||||
11
example/luademo/plg.json
Normal file
11
example/luademo/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "luademo",
|
||||
"name_zh": "Lua 全功能示例",
|
||||
"name_en": "Lua Demo",
|
||||
"version": "0.1.0",
|
||||
"description": "Lua 插件全功能示例:工具(no_memory/cleaner) + 阶段钩子 + 通道 + 数据类 API",
|
||||
"author": "HomeAgent",
|
||||
"entry": "main.lua",
|
||||
"tags": ["luademo"],
|
||||
"targets": "lua"
|
||||
}
|
||||
67
example/luademo/sdk.lua
Normal file
67
example/luademo/sdk.lua
Normal file
@ -0,0 +1,67 @@
|
||||
-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||
sdk = {}
|
||||
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
||||
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
||||
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
||||
function sdk.get_setting(key) return nil end
|
||||
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
||||
sdk.memory = {}
|
||||
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||
function sdk.memory.commit(triples) return nil end
|
||||
function sdk.memory.introspect() return {} end
|
||||
function sdk.memory.merge(source, target) return 0 end
|
||||
function sdk.memory.purge(criteria, hard) return 0 end
|
||||
sdk.doc = {}
|
||||
function sdk.doc.query(text, top_k) return {} end
|
||||
function sdk.doc.insert(doc) return nil end
|
||||
function sdk.doc.remove(id) return nil end
|
||||
function sdk.doc.stats() return {} end
|
||||
sdk.knowledge = {}
|
||||
function sdk.knowledge.search(query, limit) return {} end
|
||||
function sdk.knowledge.add(tag, content) return nil end
|
||||
function sdk.knowledge.list() return {} end
|
||||
sdk.text_memory = {}
|
||||
function sdk.text_memory.append(evt) return nil end
|
||||
sdk.llm = {}
|
||||
function sdk.llm.list_sources() return {} end
|
||||
function sdk.llm.set_source(name) return nil end
|
||||
function sdk.llm.current_source() return nil end
|
||||
sdk.social = {}
|
||||
function sdk.social.get_person(name) return {} end
|
||||
function sdk.social.get_network(name, depth) return {} end
|
||||
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||
function sdk.social.get_relations(name) return {} end
|
||||
function sdk.social.list_persons() return {} end
|
||||
sdk.settings = {}
|
||||
function sdk.settings.get_core(key) return nil end
|
||||
function sdk.settings.set_core(key, value) return nil end
|
||||
function sdk.settings.list_core(prefix) return {} end
|
||||
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||
function sdk.settings.list(prefix) return {} end
|
||||
function sdk.settings.register_def(def) return nil end
|
||||
function sdk.settings.defs(prefix) return {} end
|
||||
function sdk.settings.dump() return {} end
|
||||
function sdk.settings.plugins() return {} end
|
||||
sdk.json = {}
|
||||
function sdk.json.encode(val)
|
||||
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||
elseif type(val) == "table" then local parts, i = {}, 1
|
||||
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||
return "{" .. table.concat(parts, ",") .. "}" end
|
||||
return "null"
|
||||
end
|
||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||
sdk.http = {}
|
||||
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
return sdk
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "memo",
|
||||
"name_zh": "备忘录",
|
||||
"name_en": "Memo/Notes",
|
||||
"version": "1.0.0",
|
||||
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
||||
"name_en": "Memo",
|
||||
"version": "1.1.0",
|
||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "todo", "notes"],
|
||||
"tags": [
|
||||
"memo",
|
||||
"todo",
|
||||
"notes"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -13,20 +13,31 @@ import (
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Memo struct {
|
||||
// Todo 待办条目:会被主动提醒
|
||||
type Todo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// Memo 备忘录条目:纯记事,不主动提醒
|
||||
type Memo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
todos []Todo
|
||||
nextTID int64
|
||||
memos []Memo
|
||||
nextID int64
|
||||
filePath string
|
||||
nextMID int64
|
||||
todoPath string
|
||||
memoPath string
|
||||
stopCh chan struct{}
|
||||
tp string
|
||||
}
|
||||
@ -37,70 +48,157 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.tp = p.name + "_"
|
||||
p.stopCh = make(chan struct{})
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||
p.load()
|
||||
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
|
||||
}
|
||||
p.todoPath = filepath.Join(dir, "todos.json")
|
||||
p.memoPath = filepath.Join(dir, "memos.json")
|
||||
p.loadTodos()
|
||||
p.loadMemos()
|
||||
|
||||
s.RegisterTool(p.tp+"create", sdk.ToolDef{
|
||||
Name: p.tp + "create",
|
||||
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
||||
// 卸载(删除)时清理数据文件;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
// ── 待办(会被主动提醒)──
|
||||
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||||
Name: p.tp + "todo_add",
|
||||
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleCreate)
|
||||
}, p.handleTodoAdd)
|
||||
|
||||
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
||||
Name: p.tp + "complete",
|
||||
Description: "将指定ID的备忘标记为已完成。",
|
||||
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_complete",
|
||||
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleComplete)
|
||||
}, p.handleTodoComplete)
|
||||
|
||||
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
||||
Name: p.tp + "list",
|
||||
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
||||
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||||
Name: p.tp + "todo_list",
|
||||
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
}, p.handleTodoList)
|
||||
|
||||
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_delete",
|
||||
Description: "删除指定ID的待办事项(包括已完成的)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleTodoDelete)
|
||||
|
||||
// ── 备忘(纯记事,不提醒)──
|
||||
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
|
||||
Name: p.tp + "memo_create",
|
||||
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleMemoCreate)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
|
||||
Name: p.tp + "memo_list",
|
||||
Description: "列出所有备忘录,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleMemoList)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "memo_delete",
|
||||
Description: "删除指定ID的备忘录。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleMemoDelete)
|
||||
|
||||
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
|
||||
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||
|
||||
go p.periodicCheck()
|
||||
|
||||
log.Printf("[%s] started, path=%s", p.name, p.filePath)
|
||||
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.save()
|
||||
p.saveTodos()
|
||||
p.saveMemos()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) load() {
|
||||
func (p *Plugin) loadTodos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.filePath)
|
||||
data, err := os.ReadFile(p.todoPath)
|
||||
if err != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
Todos []Todo `json:"todos"`
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
p.todos = store.Todos
|
||||
p.nextTID = store.NextID
|
||||
if p.todos == nil {
|
||||
p.todos = []Todo{}
|
||||
}
|
||||
if p.nextTID < 1 {
|
||||
p.nextTID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) loadMemos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.memoPath)
|
||||
if err != nil {
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
@ -108,66 +206,82 @@ func (p *Plugin) load() {
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
p.memos = store.Memos
|
||||
p.nextID = store.NextID
|
||||
p.nextMID = store.NextID
|
||||
if p.memos == nil {
|
||||
p.memos = []Memo{}
|
||||
}
|
||||
if p.nextID < 1 {
|
||||
p.nextID = 1
|
||||
if p.nextMID < 1 {
|
||||
p.nextMID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) save() {
|
||||
func (p *Plugin) saveTodos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextID,
|
||||
"todos": p.todos,
|
||||
"next_id": p.nextTID,
|
||||
}, "", " ")
|
||||
os.WriteFile(p.filePath, data, 0644)
|
||||
p.mu.RUnlock()
|
||||
atomicWriteJSON(p.todoPath, data)
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingCount() int {
|
||||
func (p *Plugin) saveMemos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextMID,
|
||||
}, "", " ")
|
||||
p.mu.RUnlock()
|
||||
atomicWriteJSON(p.memoPath, data)
|
||||
}
|
||||
|
||||
// ── 待办:未完成计数与提醒 ──
|
||||
|
||||
func (p *Plugin) pendingTodoCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
n := 0
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingMemos() []Memo {
|
||||
func (p *Plugin) pendingTodos() []Todo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []Memo
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
out = append(out, m)
|
||||
var out []Todo
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx.Lock()
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||||
})
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||||
func (p *Plugin) periodicCheck() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@ -176,19 +290,125 @@ func (p *Plugin) periodicCheck() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||
// NoMemory:这是定时提醒,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||
// ── 待办工具 ──
|
||||
|
||||
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
todo := Todo{
|
||||
ID: p.nextTID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextTID++
|
||||
p.todos = append(p.todos, todo)
|
||||
p.mu.Unlock()
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
|
||||
"id": todo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
|
||||
p.todos[i].Done = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
|
||||
todos := p.pendingTodos()
|
||||
if len(todos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的待办",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, t := range todos {
|
||||
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
"count": len(todos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) {
|
||||
p.todos = append(p.todos[:i], p.todos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 备忘工具 ──
|
||||
|
||||
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
@ -196,23 +416,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
p.mu.Lock()
|
||||
memo := Memo{
|
||||
ID: p.nextID,
|
||||
ID: p.nextMID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextID++
|
||||
p.nextMID++
|
||||
p.memos = append(p.memos, memo)
|
||||
p.mu.Unlock()
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
||||
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||||
"id": memo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
|
||||
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
@ -221,8 +440,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.memos {
|
||||
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
||||
p.memos[i].Done = true
|
||||
if p.memos[i].ID == int64(id) {
|
||||
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@ -230,30 +449,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
|
||||
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
||||
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
memos := p.pendingMemos()
|
||||
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.RLock()
|
||||
memos := append([]Memo{}, p.memos...)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if len(memos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的备忘",
|
||||
"content": "暂无备忘录",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, m := range memos {
|
||||
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
@ -270,5 +492,24 @@ func errorResult(msg string) map[string]interface{} {
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理数据文件(待办 + 备忘)
|
||||
func (p *Plugin) cleanupData() {
|
||||
if p.todoPath != "" {
|
||||
os.Remove(p.todoPath)
|
||||
}
|
||||
if p.memoPath != "" {
|
||||
os.Remove(p.memoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||
func atomicWriteJSON(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "music",
|
||||
"name_zh": "音乐搜索",
|
||||
"name_en": "Music Search",
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "ocr",
|
||||
"name_zh": "OCR 文字识别",
|
||||
"name_en": "OCR Text Recognition",
|
||||
|
||||
47
example/plugindev/README.md
Normal file
47
example/plugindev/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# plugindev — 插件开发工具链(Agent 可调用)
|
||||
|
||||
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
|
||||
不需要人来敲命令行:
|
||||
|
||||
```
|
||||
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua])
|
||||
↓ 改 plugin.go
|
||||
plugindev_build 构建打包(等价在该目录 hmapdev build)→ dist/*.hmap
|
||||
↓
|
||||
plugin_install 安装(用 path 指向刚构建出的 .hmap,overwrite=true 表示原地更新)
|
||||
↓
|
||||
plgreload 重载生效
|
||||
```
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|---|---|---|
|
||||
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
|
||||
| `plugindev_init` | `name`、`lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
|
||||
| `plugindev_build` | `dir`、`target` | 在工程目录构建打包;产物路径会在返回里给出 |
|
||||
| `plugindev_sdk` | `action`、`version`、`from` | SDK 版本管理(list/current/path/latest/install/use);`from` 可指向本地 SDK 源码 |
|
||||
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev` → `/root/go/bin/hmapdev` |
|
||||
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
|
||||
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
|
||||
|
||||
## 前置:装 hmapdev
|
||||
|
||||
```bash
|
||||
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
|
||||
hmapdev version
|
||||
```
|
||||
|
||||
## 安全边界(都在实现里,不只写在文档里)
|
||||
|
||||
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
|
||||
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
|
||||
避免把这个工具变成对任意目录跑构建;
|
||||
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB,直接回灌会撑爆模型上下文);
|
||||
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。
|
||||
7
example/plugindev/go.mod
Normal file
7
example/plugindev/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module plugindev
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/plugindev/main.go
Normal file
11
example/plugindev/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
19
example/plugindev/plg.json
Normal file
19
example/plugindev/plg.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "plugindev",
|
||||
"name_zh": "插件开发工具链",
|
||||
"name_en": "Plugin Dev Toolchain",
|
||||
"version": "1.0.0",
|
||||
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"plugindev",
|
||||
"toolchain",
|
||||
"developer"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
455
example/plugindev/plugin.go
Normal file
455
example/plugindev/plugin.go
Normal file
@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
// plugindev:把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
|
||||
//
|
||||
// 为什么需要它:hmapdev 是"给人和 CI 用"的命令行工具。做成插件后,Agent 能自己:
|
||||
// plugindev_init(脚手架)→ plugindev_build(构建出 .hmap)→ plugin_install(安装)→ plgreload
|
||||
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
|
||||
//
|
||||
// 安全边界(都在实现里落实,不只写在描述里):
|
||||
// - 只有 **hmapdev 一个可执行文件**会被 exec(不接受任意命令/参数拼接);
|
||||
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json),
|
||||
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
|
||||
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
|
||||
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
|
||||
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBuildTimeout = 10 * time.Minute
|
||||
maxOutputChars = 6000
|
||||
)
|
||||
|
||||
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
|
||||
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
|
||||
hmapdev string // 解析到的 hmapdev 可执行文件路径
|
||||
workspace string // 默认工作区(生成的工程落在这里)
|
||||
timeout time.Duration // 单次 hmapdev 调用的超时
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
|
||||
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
|
||||
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
|
||||
Description: "单次 hmapdev 调用的超时上限",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
p.hmapdev = p.resolveHmapdev()
|
||||
p.workspace = p.resolveWorkspace()
|
||||
p.timeout = defaultBuildTimeout
|
||||
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
|
||||
if n, ok := toInt(v); ok && n > 0 {
|
||||
p.timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool("plugindev_status", sdk.ToolDef{
|
||||
Name: "plugindev_status",
|
||||
Description: "查看插件开发工具链状态:hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, p.handleStatus)
|
||||
|
||||
s.RegisterTool("plugindev_init", sdk.ToolDef{
|
||||
Name: "plugindev_init",
|
||||
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go,再用 plugindev_build 构建。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例:my_plugin",
|
||||
},
|
||||
"lang": map[string]interface{}{
|
||||
"type": "string", "description": "go(默认)或 lua",
|
||||
},
|
||||
"dir": map[string]interface{}{
|
||||
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, p.handleInit)
|
||||
|
||||
s.RegisterTool("plugindev_build", sdk.ToolDef{
|
||||
Name: "plugindev_build",
|
||||
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"dir": map[string]interface{}{
|
||||
"type": "string", "description": "插件工程目录(必须含 plg.json)",
|
||||
},
|
||||
"target": map[string]interface{}{
|
||||
"type": "string", "description": "构建目标,留空 = native(当前平台)。例:linux/amd64",
|
||||
},
|
||||
},
|
||||
"required": []string{"dir"},
|
||||
},
|
||||
}, p.handleBuild)
|
||||
|
||||
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
|
||||
Name: "plugindev_sdk",
|
||||
Description: "管理插件 SDK 版本(hmapdev sdk 子命令):list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录)、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string", "description": "list | current | path | latest | install | use",
|
||||
},
|
||||
"version": map[string]interface{}{
|
||||
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
|
||||
},
|
||||
"from": map[string]interface{}{
|
||||
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
}, p.handleSDK)
|
||||
|
||||
s.RegisterTool("plugindev_projects", sdk.ToolDef{
|
||||
Name: "plugindev_projects",
|
||||
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, p.handleProjects)
|
||||
|
||||
log.Printf("[plugindev] 就绪:hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
// ---------------- 工具实现 ----------------
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
out := map[string]interface{}{
|
||||
"hmapdev": fallback(p.hmapdev, ""),
|
||||
"workspace": p.workspace,
|
||||
}
|
||||
if p.hmapdev == "" {
|
||||
out["available"] = false
|
||||
out["hint"] = "未找到 hmapdev。请安装:go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
|
||||
return out, nil
|
||||
}
|
||||
out["available"] = true
|
||||
if txt, err := p.run(nil, ""); err == nil {
|
||||
out["version"] = strings.TrimSpace(txt)
|
||||
} else {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
|
||||
out["sdk_current"] = strings.TrimSpace(txt)
|
||||
}
|
||||
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
|
||||
out["sdk_path"] = strings.TrimSpace(txt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if !namePattern.MatchString(name) {
|
||||
return map[string]interface{}{
|
||||
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64(它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
|
||||
}, nil
|
||||
}
|
||||
dir, _ := args["dir"].(string)
|
||||
if dir == "" {
|
||||
dir = p.workspace
|
||||
}
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
|
||||
}
|
||||
cmd := []string{"init", name}
|
||||
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
|
||||
cmd = append(cmd, "--lua")
|
||||
}
|
||||
txt, err := p.run(cmd, dir)
|
||||
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
|
||||
dir, _ := args["dir"].(string)
|
||||
if dir == "" {
|
||||
return map[string]interface{}{"error": "dir 不能为空"}, nil
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
|
||||
manifest := filepath.Join(abs, "plg.json")
|
||||
if _, err := os.Stat(manifest); err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json);用 plugindev_init 先建一个", abs),
|
||||
}, nil
|
||||
}
|
||||
cmd := []string{"build"}
|
||||
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
|
||||
cmd = append(cmd, strings.TrimSpace(target))
|
||||
}
|
||||
txt, runErr := p.run(cmd, abs)
|
||||
res := map[string]interface{}{"output": txt, "project_dir": abs}
|
||||
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
|
||||
res["artifacts"] = pkgs
|
||||
res["next"] = "用 plugin_install 安装本地产物(path 指向上面 artifacts 里的 .hmap),然后 plgreload"
|
||||
}
|
||||
if runErr != nil {
|
||||
res["error"] = runErr.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
|
||||
action, _ := args["action"].(string)
|
||||
action = strings.TrimSpace(action)
|
||||
switch action {
|
||||
case "list", "current", "path", "latest":
|
||||
txt, err := p.run([]string{"sdk", action}, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
case "install":
|
||||
version, _ := args["version"].(string)
|
||||
from, _ := args["from"].(string)
|
||||
cmd := []string{"sdk", "install"}
|
||||
if strings.TrimSpace(from) != "" {
|
||||
cmd = append(cmd, "--from", strings.TrimSpace(from))
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
cmd = append(cmd, strings.TrimSpace(version))
|
||||
} else if strings.TrimSpace(from) == "" {
|
||||
cmd = append(cmd, "latest")
|
||||
}
|
||||
txt, err := p.run(cmd, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
case "use":
|
||||
version, _ := args["version"].(string)
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return map[string]interface{}{"error": "use 需要 version"}, nil
|
||||
}
|
||||
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
default:
|
||||
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
|
||||
entries, err := os.ReadDir(p.workspace)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(p.workspace, e.Name())
|
||||
projects := []string{dir}
|
||||
// 有些工程会被生成到子目录里(hmapdev init 支持指定目录),这里只看一层
|
||||
for _, sub := range projects {
|
||||
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
item := map[string]interface{}{"name": e.Name(), "dir": sub}
|
||||
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
|
||||
item["version"] = v
|
||||
}
|
||||
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
|
||||
item["artifacts"] = pkgs
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
|
||||
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
|
||||
}
|
||||
|
||||
// ---------------- 基础设施 ----------------
|
||||
|
||||
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
|
||||
func (p *Plugin) run(args []string, dir string) (string, error) {
|
||||
if p.hmapdev == "" {
|
||||
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
// 继承环境(Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
|
||||
out, err := cmd.CombinedOutput()
|
||||
txt := truncateOutput(string(out))
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return txt, fmt.Errorf("hmapdev %s 超时(%s)", strings.Join(args, " "), p.timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return txt, nil
|
||||
}
|
||||
|
||||
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
|
||||
func (p *Plugin) resolveHmapdev() string {
|
||||
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
|
||||
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("hmapdev"); err == nil {
|
||||
return path
|
||||
}
|
||||
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveWorkspace:配置项 → <data_dir>/plugindev → ./plugindev。
|
||||
func (p *Plugin) resolveWorkspace() string {
|
||||
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
|
||||
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||
ws := strings.TrimSpace(s)
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
}
|
||||
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
|
||||
if dd, _ := v.(string); dd != "" {
|
||||
ws := filepath.Join(dd, "plugindev")
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
}
|
||||
ws := "plugindev"
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
|
||||
// truncateOutput 截断长输出:构建日志动辄几百 KB,直接返回会灌爆模型上下文。
|
||||
func truncateOutput(s string) string {
|
||||
if len(s) <= maxOutputChars {
|
||||
return s
|
||||
}
|
||||
head := s[:maxOutputChars/2]
|
||||
tail := s[len(s)-maxOutputChars/2:]
|
||||
return fmt.Sprintf("%s\n…(输出被截断,共 %d 字节)…\n%s", head, len(s), tail)
|
||||
}
|
||||
|
||||
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
|
||||
func listHmap(dir string) []string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func readPlgVersion(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s := string(b)
|
||||
i := strings.Index(s, `"version"`)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := s[i:]
|
||||
j := strings.Index(rest, ":")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[j+1:])
|
||||
rest = strings.TrimPrefix(rest, `"`)
|
||||
if k := strings.Index(rest, `"`); k > 0 {
|
||||
return rest[:k]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func fallback(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -2,14 +2,17 @@
|
||||
"name": "qq",
|
||||
"name_zh": "QQ消息",
|
||||
"name_en": "qq",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.1",
|
||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "messaging"],
|
||||
"tags": [
|
||||
"qq",
|
||||
"messaging"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": false,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
1101
example/qq/plugin.go
1101
example/qq/plugin.go
File diff suppressed because it is too large
Load Diff
237
example/qq/plugin_test.go
Normal file
237
example/qq/plugin_test.go
Normal file
@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func newPermissionTestPlugin(t *testing.T) *Plugin {
|
||||
t.Helper()
|
||||
instance, err := NewPluginFactory("qq", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return instance.(*Plugin)
|
||||
}
|
||||
|
||||
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
|
||||
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
|
||||
}
|
||||
|
||||
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
ctx := toolCallContext("calendar_list", nil)
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("owner call rejected: %s", *ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
|
||||
p.auth = qqAuthContext{active: true, userID: 10001}
|
||||
ctx := toolCallContext("calendar_list", nil)
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
|
||||
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
|
||||
|
||||
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
|
||||
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
|
||||
}
|
||||
|
||||
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("current-group history rejected: %s", *ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: "来自未知事件(message_id=404)",
|
||||
Extra: map[string]interface{}{"input_source": "qq"},
|
||||
}
|
||||
if err := p.onInputAuthContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
|
||||
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateQQOutputIsStopped(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.maxDuplicateSend = 1
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
|
||||
|
||||
ctx := toolCallContext("output_send__qq", args)
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("first send rejected: %s", *ctx.Response)
|
||||
}
|
||||
|
||||
ctx = toolCallContext("output_send__qq", args)
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
|
||||
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
|
||||
var path string
|
||||
var request map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.napcatURL = server.URL
|
||||
p.httpClient = server.Client()
|
||||
_, err := p.handleChannelOutput(map[string]interface{}{
|
||||
"payload": "hello",
|
||||
"type": "text",
|
||||
"meta": `{"group_id":20002,"user_id":10001}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != "/send_group_msg" {
|
||||
t.Fatalf("path=%q, want /send_group_msg", path)
|
||||
}
|
||||
segments, ok := request["message"].([]interface{})
|
||||
if !ok || len(segments) < 2 {
|
||||
t.Fatalf("message is not a segment array: %#v", request["message"])
|
||||
}
|
||||
mention, _ := segments[0].(map[string]interface{})
|
||||
data, _ := mention["data"].(map[string]interface{})
|
||||
if mention["type"] != "at" || data["qq"] != "10001" {
|
||||
t.Fatalf("leading mention=%#v", mention)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
|
||||
// 现在只拦参数完全相同的重复调用。
|
||||
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
|
||||
for i := 0; i < 5; i++ {
|
||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||
"payload": fmt.Sprintf("message-%d", i),
|
||||
"type": "text",
|
||||
"meta": `{"user_id":123}`,
|
||||
})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
|
||||
for i := 0; i < 50; i++ {
|
||||
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.maxQQOutputCalls = 0
|
||||
p.maxDuplicateSend = 0
|
||||
p.maxQQToolCalls = 0
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
for i := 0; i < 30; i++ {
|
||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||
"payload": "same-content",
|
||||
"type": "text",
|
||||
"meta": `{"user_id":123}`,
|
||||
})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
|
||||
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
||||
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
||||
// (报「可信 QQ 会话身份不完整」)。
|
||||
//
|
||||
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
||||
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true}
|
||||
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
||||
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
||||
|
||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||
"payload": "带齐 meta 的主动发送",
|
||||
"type": "text",
|
||||
"meta": `{"user_id":2198972886}`,
|
||||
})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
|
||||
}
|
||||
|
||||
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
|
||||
if err := p.beforeToolcall(ctx2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
|
||||
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
|
||||
}
|
||||
}
|
||||
153
example/recoverydiag/diag_test.go
Normal file
153
example/recoverydiag/diag_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var realCfg = "/home/newqqagent/config.db"
|
||||
var realLog = "/home/newqqagent/log"
|
||||
|
||||
func TestDiagTriage(t *testing.T) {
|
||||
p := &Plugin{name: "recoverydiag"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
|
||||
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
|
||||
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
|
||||
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
|
||||
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r, _ := p.handleTriage(c.args)
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s: not a map", c.name)
|
||||
}
|
||||
if got, _ := m["class"].(string); got != c.want {
|
||||
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagDB(t *testing.T) {
|
||||
if _, err := os.Stat(realCfg); err != nil {
|
||||
t.Skip("config.db not present, skipping")
|
||||
}
|
||||
p := &Plugin{name: "recoverydiag"}
|
||||
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
|
||||
if err != nil {
|
||||
t.Fatalf("handleDB: %v", err)
|
||||
}
|
||||
m := r.(map[string]interface{})
|
||||
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
|
||||
if m["integrity"] != "ok" {
|
||||
t.Errorf("integrity = %v, want ok", m["integrity"])
|
||||
}
|
||||
if m["source_count"] == 0 {
|
||||
t.Errorf("source_count == 0, expected LLM sources")
|
||||
}
|
||||
if got, _ := m["source_failed"].(int); got != 0 {
|
||||
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagLogScan(t *testing.T) {
|
||||
if _, err := os.Stat(realLog); err != nil {
|
||||
t.Skip("log dir not present, skipping")
|
||||
}
|
||||
p := &Plugin{name: "recoverydiag"}
|
||||
r, err := p.handleLogScan(map[string]interface{}{
|
||||
"log_dir": realLog,
|
||||
"since_minutes": 60 * 24 * 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handleLogScan: %v", err)
|
||||
}
|
||||
m := r.(map[string]interface{})
|
||||
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
|
||||
}
|
||||
|
||||
func TestDiagDelta(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cur := t.TempDir()
|
||||
sub := filepath.Join(base, "sub")
|
||||
os.MkdirAll(sub, 0755)
|
||||
|
||||
// modified: same path, different content
|
||||
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
|
||||
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
|
||||
// created
|
||||
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
|
||||
// deleted
|
||||
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
|
||||
// unchanged
|
||||
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
|
||||
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
|
||||
|
||||
p := &Plugin{name: "recoverydiag"}
|
||||
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
|
||||
if err != nil {
|
||||
t.Fatalf("handleDelta: %v", err)
|
||||
}
|
||||
m := r.(map[string]interface{})
|
||||
sum := m["summary"].(map[string]int)
|
||||
t.Logf("summary=%v total=%v", sum, m["total_diff"])
|
||||
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
|
||||
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagLoc(t *testing.T) {
|
||||
p := &Plugin{name: "recoverydiag"}
|
||||
r, _ := p.handleLoc(map[string]interface{}{
|
||||
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||
"db": map[string]interface{}{"verdict": "ok"},
|
||||
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||
})
|
||||
m := r.(map[string]interface{})
|
||||
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
|
||||
raw, _ := json.Marshal(m)
|
||||
var dec map[string]interface{}
|
||||
json.Unmarshal(raw, &dec)
|
||||
hs := dec["ranked_hypotheses"].([]interface{})
|
||||
if len(hs) == 0 {
|
||||
t.Fatal("no hypotheses")
|
||||
}
|
||||
top := hs[0].(map[string]interface{})
|
||||
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
|
||||
if top["cause"] != "code_panic_loop" {
|
||||
t.Errorf("expected code_panic_loop, got %v", top["cause"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagLocPersist(t *testing.T) {
|
||||
kb := filepath.Join(t.TempDir(), "recovery_kb")
|
||||
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
|
||||
args := map[string]interface{}{
|
||||
"persist": true,
|
||||
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||
"db": map[string]interface{}{"verdict": "ok"},
|
||||
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||
}
|
||||
if _, err := p.handleLoc(args); err != nil {
|
||||
t.Fatalf("handleLoc: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(kb)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
|
||||
if !strings.Contains(string(data), `"cause"`) {
|
||||
t.Errorf("persisted file missing cause field: %s", data)
|
||||
}
|
||||
}
|
||||
7
example/recoverydiag/go.mod
Normal file
7
example/recoverydiag/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module recoverydiag
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/recoverydiag/main.go
Normal file
11
example/recoverydiag/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
21
example/recoverydiag/plg.json
Normal file
21
example/recoverydiag/plg.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "recoverydiag",
|
||||
"name_zh": "恢复诊断",
|
||||
"name_en": "Recovery Diagnostics",
|
||||
"version": "0.2.0",
|
||||
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"diag",
|
||||
"recovery",
|
||||
"diagnostics",
|
||||
"triage",
|
||||
"failback"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
1023
example/recoverydiag/plugin.go
Normal file
1023
example/recoverydiag/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ rss plugin
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
@ -10,8 +10,8 @@ require (
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "rss",
|
||||
"name_zh": "RSS订阅",
|
||||
"name_en": "RSS",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
||||
"tags": [
|
||||
"rss",
|
||||
"feed",
|
||||
"subscription",
|
||||
"monitor"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ import (
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
const injectDedupWindow = 5 * time.Minute
|
||||
|
||||
type FeedSub struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
@ -32,7 +34,9 @@ type Plugin struct {
|
||||
mu sync.RWMutex
|
||||
feeds []FeedSub
|
||||
seenGUIDs map[string]bool
|
||||
injected map[string]time.Time
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
pollTicker *time.Ticker
|
||||
}
|
||||
@ -100,19 +104,28 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.client = &http.Client{Timeout: 30 * time.Second}
|
||||
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
|
||||
p.fp = gofeed.NewParser()
|
||||
p.stopCh = make(chan struct{})
|
||||
p.seenGUIDs = make(map[string]bool)
|
||||
p.injected = make(map[string]time.Time)
|
||||
p.feeds = []FeedSub{}
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
}
|
||||
p.dataDir = filepath.Join(dataHome, ".homeagent", "rss")
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
p.loadData()
|
||||
|
||||
// 卸载(删除)时清理订阅数据目录;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "poll_interval", Default: "30", Type: "string",
|
||||
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
|
||||
@ -148,7 +161,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"list", sdk.ToolDef{
|
||||
Name: tp + "list", Description: "List all subscribed feeds",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
@ -157,7 +170,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleCheckNow)
|
||||
@ -176,7 +189,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.stopOnce.Do(func() { close(p.stopCh) })
|
||||
p.pollTicker.Stop()
|
||||
p.wg.Wait()
|
||||
p.saveData()
|
||||
@ -248,9 +261,34 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(newArticles)))
|
||||
now := time.Now()
|
||||
toInject := make([]*gofeed.Item, 0, len(newArticles))
|
||||
p.mu.Lock()
|
||||
for _, item := range newArticles {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
key := sub.URL + "|" + guid
|
||||
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
|
||||
continue
|
||||
}
|
||||
p.injected[key] = now
|
||||
p.seenGUIDs[key] = true
|
||||
toInject = append(toInject, item)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if len(toInject) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
|
||||
for _, item := range toInject {
|
||||
pubDate := ""
|
||||
if item.PublishedParsed != nil {
|
||||
pubDate = item.PublishedParsed.Format("01-02 15:04")
|
||||
@ -265,20 +303,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
||||
|
||||
p.mu.Lock()
|
||||
for _, item := range newArticles {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
p.seenGUIDs[sub.URL+"|"+guid] = true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
// 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算,
|
||||
// 原文仍进上下文(模型当轮看得到)。
|
||||
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
p.saveData()
|
||||
}
|
||||
|
||||
@ -320,6 +348,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
|
||||
guidCount := 0
|
||||
p.mu.Lock()
|
||||
for _, item := range parsed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
@ -331,6 +360,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
|
||||
p.seenGUIDs[url+"|"+guid] = true
|
||||
guidCount++
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
p.feeds = append(p.feeds, sub)
|
||||
@ -395,7 +425,16 @@ func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
|
||||
go p.checkAllFeeds()
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
|
||||
default:
|
||||
}
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
p.checkAllFeeds()
|
||||
}()
|
||||
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
|
||||
}
|
||||
|
||||
@ -409,7 +448,7 @@ func (p *Plugin) loadData() {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}
|
||||
if json.Unmarshal(b, &data) != nil {
|
||||
@ -427,14 +466,36 @@ func (p *Plugin) saveData() {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
data := struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}{
|
||||
Feeds: p.feeds,
|
||||
SeenGUIDs: p.seenGUIDs,
|
||||
}
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.dataFile(), b, 0644)
|
||||
atomicWriteJSON(p.dataFile(), b)
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||
func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.dataDir == "" {
|
||||
return
|
||||
}
|
||||
for _, f := range []string{"feeds.json"} {
|
||||
path := filepath.Join(p.dataDir, f)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||
func atomicWriteJSON(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "sanitizer",
|
||||
"name_zh": "输出清洗",
|
||||
"name_en": "sanitizer",
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||||
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
|
||||
// 同时保留原有"工具调用残留(思维泄漏)"清理。
|
||||
//
|
||||
// 挂载阶段:
|
||||
// - StageOnInput : 清洗用户输入(RawMessage)
|
||||
// - StageAfterToolcall : 清洗工具执行结果(ToolResults),坏字节不进 LLM 上下文
|
||||
// - StagePostAction : 清洗 LLM 输出(LLMText),保留原有思维泄漏清理
|
||||
//
|
||||
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
|
||||
//
|
||||
// 编译:
|
||||
//
|
||||
@ -13,21 +21,24 @@ import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[::][^}]*\}\}`)
|
||||
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
@ -36,10 +47,41 @@ func (p *Plugin) Name() string { return "sanitizer" }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
// 1) 输入清洗
|
||||
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := ctx.RawMessage
|
||||
ctx.RawMessage = cleanText(ctx.RawMessage)
|
||||
if before != ctx.RawMessage {
|
||||
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
|
||||
}
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
for i, tr := range ctx.ToolResults {
|
||||
if s, ok := tr.Result.(string); ok {
|
||||
clean := cleanText(s)
|
||||
if clean != s {
|
||||
ctx.ToolResults[i].Result = clean
|
||||
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
|
||||
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := len(ctx.LLMText)
|
||||
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||||
ctx.LLMText = cleanText(ctx.LLMText)
|
||||
after := len(ctx.LLMText)
|
||||
ctx.Unlock()
|
||||
if before != after {
|
||||
@ -47,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
log.Printf("[sanitizer] stage PostAction registered")
|
||||
log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -57,6 +99,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
return &Plugin{}, nil
|
||||
}
|
||||
|
||||
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
func cleanToolCallLeakage(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
@ -83,8 +126,12 @@ func cleanToolCallLeakage(content string) string {
|
||||
cleaned = append(cleaned, line)
|
||||
continue
|
||||
}
|
||||
if toolNameRE.MatchString(trimmed) {
|
||||
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
|
||||
if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
|
||||
continue
|
||||
}
|
||||
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
|
||||
rest := trimmed[m[1]:]
|
||||
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -100,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
|
||||
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
|
||||
// 2. 剔除无效 UTF-8 字节(strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
|
||||
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
|
||||
func cleanText(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// 先剥离 ANSI 转义:ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
|
||||
if strings.ContainsRune(s, 0x1b) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
if c == 0x1b {
|
||||
// 跳过完整转义序列
|
||||
j := i + 1
|
||||
if j < len(s) {
|
||||
switch s[j] {
|
||||
case '[': // CSI: ESC [ <params> <letter>
|
||||
j++
|
||||
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
case ']': // OSC: ESC ] ... BEL / ST
|
||||
i = j + 1
|
||||
for i < len(s) && s[i] != 0x07 {
|
||||
i++
|
||||
}
|
||||
i++ // skip BEL
|
||||
continue
|
||||
default: // 单字符转义(ESC c ESC 7 等)
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
s = sb.String()
|
||||
}
|
||||
|
||||
// 剔除无效 UTF-8 与 U+FFFD 替换符
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
}
|
||||
if strings.ContainsRune(s, utf8.RuneError) {
|
||||
// 连 U+FFFD 也不留给模型复述
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r != utf8.RuneError {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s = b.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -2,6 +2,31 @@ package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"clean", "你好世界 hello", "你好世界 hello"},
|
||||
{"invalid_utf8", "a\xff\xfe b", "a b"},
|
||||
{"ufffd", "有乱码\ufffd字符", "有乱码字符"},
|
||||
{"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
|
||||
{"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
|
||||
{"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
|
||||
{"ansi_osc", "\x1b]0;title\x07文本", "文本"},
|
||||
{"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
|
||||
{"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanText(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanToolCallLeakage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
7
example/vanblog/go.mod
Normal file
7
example/vanblog/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module vanblog-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/vanblog/plg.json
Normal file
11
example/vanblog/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "vanblog",
|
||||
"name_zh": "VanBlog 博客管理",
|
||||
"name_en": "VanBlog",
|
||||
"version": "1.0.0",
|
||||
"description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["blog", "vanblog", "cms"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
1334
example/vanblog/plugin.go
Normal file
1334
example/vanblog/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
81
example/vikunja/README.md
Normal file
81
example/vikunja/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Vikunja 插件(HomeAgent)
|
||||
|
||||
把 [Vikunja](https://vikunja.io) 待办/任务管理接入 HomeAgent:用自然语言查任务、建任务、改期、完成、看板拖动、指派、评论、时间跟踪、导入数据等。
|
||||
|
||||
## 配置项(全部可在插件配置界面修改)
|
||||
|
||||
| 键 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `url` | string | `https://vikunja.jianfgit.xyz` | 站点根地址,**不带** `/api` |
|
||||
| `token` | password(secret) | 空 | **必填**。Vikunja → 设置 → API Tokens 生成(`tk_` 开头)。令牌的权限范围决定本插件能力上限:勾全范围即为完整能力 |
|
||||
| `api_version` | select | `v2` | `v2`(推荐,标准 REST,含时间跟踪等新能力)或 `v1`(用于 v2 暂未提供的端点) |
|
||||
| `default_project_id` | string | 空 | 新建任务未指定项目时落到这里;留空则必须显式指定 |
|
||||
| `max_items` | int | `25` | 列表类工具的默认条数,控制上下文体积 |
|
||||
| `compact_output` | bool | `true` | 任务/项目/标签列表只返回关键字段;关闭则返回 Vikunja 完整对象 |
|
||||
| `timeout_seconds` | int | `20` | 单次 HTTP 超时 |
|
||||
| `verify_tls` | bool | `true` | 自签证书站点可关闭(不建议) |
|
||||
|
||||
配置在**每次工具调用前重新读取**,因此换了 token 不必重启插件。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 能力 |
|
||||
|---|---|
|
||||
| `vikunja_status` | 连接/配置自检:地址、token 对应的用户、API 版本、服务器能力、CalDAV 地址 |
|
||||
| `vikunja_tasks` | 列任务:按项目、完成状态、截止(today/this_week/overdue/no_due)、关键词、原生 filter 表达式 |
|
||||
| `vikunja_task_get` / `task_create` / `task_update` / `task_done` / `task_delete` | 任务增删改查(`task_update` 只传要改的字段) |
|
||||
| `vikunja_task_bulk` | 批量改完成状态/项目/优先级/截止/标签 |
|
||||
| `vikunja_task_assignees` / `task_labels` / `task_comments` / `task_relations` / `task_attachments` | 指派、标签、评论、关联(子任务/依赖/相关)、附件(支持上传本地文件) |
|
||||
| `vikunja_projects` / `project_views` | 项目增删改查、归档;视图与看板桶(把任务移入桶=看板拖动) |
|
||||
| `vikunja_labels` / `filters` | 标签、保存的筛选器(Saved Filter) |
|
||||
| `vikunja_teams` / `sharing` | 团队与成员;项目分享(用户/团队授权、链接分享含密码) |
|
||||
| `vikunja_notifications` / `subscriptions` / `webhooks` | 通知、订阅、Webhook 管理 |
|
||||
| `vikunja_time_entries` | 时间跟踪(**仅 v2**):补录/修改/删除、开始与停止计时器 |
|
||||
| `vikunja_migrate` | 从 TickTick/WeKan/CSV/Planka/Vikunja 文件(v2)与 Todoist/Trello/微软待办(v1)导入 |
|
||||
| `vikunja_user` / `vikunja_admin` | 当前账号(设置、登录会话、API Token)与实例管理(用户增删/提权/停用/改密、项目归属转移,需实例管理员) |
|
||||
| `vikunja_reactions` | 任务/评论的表情回应 |
|
||||
| `vikunja_api` | **通用直通**:调任意端点,未封装的能力走这里(可强制指定 v1/v2),保证能力无死角 |
|
||||
|
||||
## v1 / v2 差异(已按实例自带规范逐条核对)
|
||||
|
||||
插件默认 v2,并自动处理下列差异:
|
||||
|
||||
| 操作 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 建任务 | `PUT /projects/{id}/tasks` | `POST /projects/{id}/tasks` |
|
||||
| 改任务 | `POST /tasks/{id}`(必须整对象 → 插件自动取回-合并-提交) | `PATCH /tasks/{id}`(merge-patch,只发变更字段;被拒则回落取回-合并-PUT) |
|
||||
| 搜索参数 | `?s=` | `?q=` |
|
||||
| 加标签 | `PUT`(Label 对象) | `POST`(`{"label_id":N}`) |
|
||||
| 批量改 | `POST /tasks/bulk` | `PUT /tasks/bulk` |
|
||||
| 时间跟踪 | 不支持 | `/time-entries`(`end_time` 为 null 即计时中;停止用 `/time-entries/timer/stop`) |
|
||||
| 导入 | Todoist / Trello / 微软待办 | TickTick / WeKan / CSV / Planka / Vikunja 文件 |
|
||||
|
||||
> 官方路线:v1 仍支持但新端点只进 v2,3.0 弃用、4.0 移除。除“导入”外建议一律用 v2。
|
||||
|
||||
## 开发与构建
|
||||
|
||||
```bash
|
||||
cd third_party/homeagent-sdk/example/vikunja
|
||||
|
||||
go test -count=1 -race ./... # 16 项测试(httptest 打桩,不需要真 token)
|
||||
hmapdev build # 产出 dist/vikunja_bundle.hmap
|
||||
```
|
||||
|
||||
`go.mod` 里的 `replace` 把 SDK 指向仓库内的 `third_party/homeagent-sdk`,因此无需联网拉私有模块。
|
||||
|
||||
### 部署到运行实例
|
||||
|
||||
`.hmap` 包内是 `plugin.json` + `plugin.bin.<os>.<arch>`,安装时按运行平台重命名入口文件:
|
||||
|
||||
```bash
|
||||
unzip -o dist/vikunja_bundle.hmap -d /home/newqqagent/plugins/vikunja
|
||||
cd /home/newqqagent/plugins/vikunja && mv plugin.bin.linux.amd64 plugin.bin
|
||||
# 然后重载插件(或重启 homeagent.service)
|
||||
```
|
||||
|
||||
## 已知边界
|
||||
|
||||
- **附件下载**未单独封装:`task_attachments` 支持列出/上传/删除,下载请用 `vikunja_api` 访问附件 URL。
|
||||
- **链接分享的字段**(`right`/`password`)按 Vikunja 版本语义透传;如遇 4xx,可直接用 `raw` 参数传完整 JSON。
|
||||
- **批量改标签**的 `fields` 结构以 `BulkTask` 为准,未在真实实例上验证过(缺少可用 token),如有偏差请用 `vikunja_api` 直通。
|
||||
- CalDAV 是客户端协议,插件只提供地址(`vikunja_status` 里的 `caldav_url`),不做 CalDAV 同步。
|
||||
15
example/vikunja/go.mod
Normal file
15
example/vikunja/go.mod
Normal file
@ -0,0 +1,15 @@
|
||||
module vikunja-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
12
example/vikunja/plg.json
Normal file
12
example/vikunja/plg.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vikunja",
|
||||
"name_zh": "Vikunja 待办",
|
||||
"name_en": "Vikunja",
|
||||
"version": "1.0.1",
|
||||
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"sdk": "1.2.0",
|
||||
"tags": ["vikunja", "todo", "task", "gtd", "productivity"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
2617
example/vikunja/plugin.go
Normal file
2617
example/vikunja/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
523
example/vikunja/plugin_test.go
Normal file
523
example/vikunja/plugin_test.go
Normal file
@ -0,0 +1,523 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestPlugin 构造一个不依赖 sdk 的插件实例,指向 httptest 服务。
|
||||
// ensure() 在 sdk==nil 时会保留已设置的字段,因此可以这样直接测处理器。
|
||||
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
p := &Plugin{
|
||||
name: "vikunja",
|
||||
baseURL: srv.URL,
|
||||
token: "tk_test",
|
||||
apiVer: "v2",
|
||||
maxItems: 5,
|
||||
compact: true,
|
||||
http: srv.Client(),
|
||||
}
|
||||
return p, srv
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 1) 列表:v2 用 q= 搜索,且 filter 会带上默认 done 条件
|
||||
func TestTasksListV2(t *testing.T) {
|
||||
var gotQuery string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
if r.Header.Get("Authorization") != "Bearer tk_test" {
|
||||
t.Errorf("缺少 Bearer 头: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"写周报","done":false,"project_id":3,"due_date":"2026-09-13T10:00:00Z","labels":[{"title":"工作"}],"assignees":[{"username":"jianf"}]}]`))
|
||||
})
|
||||
res, err := p.handleTasksList(map[string]interface{}{"search": "周报", "limit": float64(5)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "q=%E5%91%A8%E6%8A%A5") {
|
||||
t.Errorf("v2 应使用 q= 搜索,实际 query=%s", gotQuery)
|
||||
}
|
||||
if strings.Contains(gotQuery, "s=") {
|
||||
t.Errorf("v2 不应使用 s=,实际 query=%s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "per_page=5") {
|
||||
t.Errorf("per_page 未生效: %s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "filter=done+%3D+false") && !strings.Contains(gotQuery, "filter=done%20%3D%20false") {
|
||||
t.Errorf("默认应过滤未完成,实际 filter 片段: %s", gotQuery)
|
||||
}
|
||||
m, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("结果应为 map,实际 %T", res)
|
||||
}
|
||||
if m["count"].(int) != 1 {
|
||||
t.Errorf("count 应为 1,实际 %v", m["count"])
|
||||
}
|
||||
tasks := m["tasks"].([]interface{})
|
||||
tk := tasks[0].(map[string]interface{})
|
||||
if _, ok := tk["labels"].([]string); !ok {
|
||||
t.Errorf("标签应被投影成名称数组,实际 %T", tk["labels"])
|
||||
}
|
||||
if _, ok := tk["description"]; ok {
|
||||
t.Errorf("精简输出不应出现 description")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 列表:v1 用 s= 搜索
|
||||
func TestTasksListV1SearchParam(t *testing.T) {
|
||||
var gotQuery string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTasksList(map[string]interface{}{"search": "abc"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "s=abc") {
|
||||
t.Errorf("v1 应使用 s= 搜索,实际 %s", gotQuery)
|
||||
}
|
||||
if strings.Contains(gotQuery, "q=") {
|
||||
t.Errorf("v1 不应出现 q=,实际 %s", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 建任务:v1=PUT、v2=POST(同路径,方法不同)
|
||||
func TestTaskCreateMethodByVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ver string
|
||||
method string
|
||||
}{
|
||||
{"v1", http.MethodPut},
|
||||
{"v2", http.MethodPost},
|
||||
} {
|
||||
var gotMethod, gotPath string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod, gotPath = r.Method, r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
|
||||
})
|
||||
p.apiVer = tc.ver
|
||||
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": "3", "title": "买菜"}); err != nil {
|
||||
t.Fatalf("[%s] err: %v", tc.ver, err)
|
||||
}
|
||||
if gotMethod != tc.method {
|
||||
t.Errorf("[%s] 期望 %s,实际 %s", tc.ver, tc.method, gotMethod)
|
||||
}
|
||||
if gotPath != "/api/"+tc.ver+"/projects/3/tasks" {
|
||||
t.Errorf("[%s] 路径错误: %s", tc.ver, gotPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 改任务(v2):走 merge-patch,只发变更字段
|
||||
func TestTaskUpdateV2MergePatch(t *testing.T) {
|
||||
var method, ctype string
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
method = r.Method
|
||||
ctype = r.Header.Get("Content-Type")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":7,"done":true}`))
|
||||
})
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "done": true}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if method != http.MethodPatch {
|
||||
t.Errorf("v2 应用 PATCH,实际 %s", method)
|
||||
}
|
||||
if !strings.Contains(ctype, "merge-patch") {
|
||||
t.Errorf("应使用 merge-patch 内容类型,实际 %s", ctype)
|
||||
}
|
||||
if len(body) != 1 || body["done"] != true {
|
||||
t.Errorf("只应发送变更字段,实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 改任务(v2)回退:merge-patch 被拒 → 取回-合并-PUT
|
||||
func TestTaskUpdateV2FallbackToMergePut(t *testing.T) {
|
||||
var calls []string
|
||||
var putBody map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
switch {
|
||||
case r.Method == http.MethodPatch:
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = w.Write([]byte(`{"code":9,"message":"unsupported media type"}`))
|
||||
case r.Method == http.MethodGet:
|
||||
_, _ = w.Write([]byte(`{"id":7,"title":"旧标题","done":false,"priority":1}`))
|
||||
case r.Method == http.MethodPut:
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &putBody)
|
||||
_, _ = w.Write([]byte(`{"id":7,"title":"新标题","done":false,"priority":1}`))
|
||||
default:
|
||||
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "title": "新标题"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"PATCH /api/v2/tasks/7", "GET /api/v2/tasks/7", "PUT /api/v2/tasks/7"}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("调用序列不符: %v", calls)
|
||||
}
|
||||
for i := range want {
|
||||
if calls[i] != want[i] {
|
||||
t.Errorf("第 %d 步期望 %s,实际 %s", i+1, want[i], calls[i])
|
||||
}
|
||||
}
|
||||
if putBody["title"] != "新标题" {
|
||||
t.Errorf("合并后的 body 应含新标题,实际 %v", putBody)
|
||||
}
|
||||
if putBody["priority"] != float64(1) {
|
||||
t.Errorf("合并必须保留原有字段(priority),实际 %v", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 改任务(v1):没有 merge-patch,必须取回-合并-POST
|
||||
func TestTaskUpdateV1FetchMergePost(t *testing.T) {
|
||||
var calls []string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"id":9,"title":"旧","priority":2}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":9,"title":"新","priority":2}`))
|
||||
})
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "9", "title": "新"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/tasks/9", "POST /api/v1/tasks/9"}
|
||||
if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] {
|
||||
t.Fatalf("v1 应为 GET→POST,实际 %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 错误映射:401 提示检查 token
|
||||
func TestErrorHint401(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":11,"message":"invalid token"}`))
|
||||
})
|
||||
_, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("应返回错误")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "401") || !strings.Contains(msg, "code=11") {
|
||||
t.Errorf("错误信息应含状态码与 Vikunja code,实际 %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "token") {
|
||||
t.Errorf("401 应给出 token 提示,实际 %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 8) 未配置 token 时应给出可操作提示,而不是发出无凭据请求
|
||||
func TestMissingToken(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("未配置 token 时不应发请求")
|
||||
})
|
||||
p.token = ""
|
||||
_, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "vikunja.token") {
|
||||
t.Fatalf("应提示配置项名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 9) 导入:Todoist 必须走 v1(即使插件默认是 v2)
|
||||
func TestMigrateUsesV1ForTodoist(t *testing.T) {
|
||||
var path string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "todoist", "code": "abc"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if path != "/api/v1/migration/todoist/migrate" {
|
||||
t.Errorf("Todoist 导入必须走 v1,实际 %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// 10) 导入:WeKan 走 v2
|
||||
func TestMigrateUsesV2ForWekan(t *testing.T) {
|
||||
var path, method string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
path, method = r.URL.Path, r.Method
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "wekan"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if path != "/api/v2/migration/wekan/migrate" || method != http.MethodPost {
|
||||
t.Errorf("WeKan 应走 v2 POST,实际 %s %s", method, path)
|
||||
}
|
||||
}
|
||||
|
||||
// 11) 时间跟踪:秒数换算成 end_time;计时开始则不带 end_time
|
||||
func TestTimeEntrySecondsBecomesEndTime(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
})
|
||||
start := "2026-09-12T10:00:00+08:00"
|
||||
if _, err := p.handleTimeEntries(map[string]interface{}{
|
||||
"action": "create", "task_id": "5", "seconds": float64(600),
|
||||
"start_time": start,
|
||||
}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
// 判据不写死字符串:按时区无关的方式比较两个时间点
|
||||
sStart, err := time.Parse(time.RFC3339, start)
|
||||
if err != nil {
|
||||
t.Fatalf("case 自身时间写错: %v", err)
|
||||
}
|
||||
gotEnd, ok := body["end_time"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("应有 end_time,实际 %v", body["end_time"])
|
||||
}
|
||||
tEnd, err := time.Parse(time.RFC3339, gotEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("end_time 不是 RFC3339: %q", gotEnd)
|
||||
}
|
||||
if diff := tEnd.Sub(sStart); diff != 10*time.Minute {
|
||||
t.Errorf("end_time 应由 start_time+600s 推出,实际差值 %v", diff)
|
||||
}
|
||||
if _, ok := body["seconds"]; ok {
|
||||
t.Errorf("TimeEntry 没有 seconds 字段,不应发送:%v", body)
|
||||
}
|
||||
|
||||
body = nil
|
||||
if _, err := p.handleTimeEntries(map[string]interface{}{"action": "timer_start", "task_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
v, present := body["end_time"]
|
||||
if !present || v != nil {
|
||||
t.Errorf("计时开始应显式 end_time=null(live timer),实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 12) 时间跟踪在 v1 下应给出明确不可用提示
|
||||
func TestTimeEntryUnavailableOnV1(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
p.apiVer = "v1"
|
||||
_, err := p.handleTimeEntries(map[string]interface{}{"action": "list"})
|
||||
if err == nil || !strings.Contains(err.Error(), "v2") {
|
||||
t.Fatalf("v1 下应提示改用 v2,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 13) 标签:v1 收 Label 对象、v2 收 label_id
|
||||
func TestLabelBodyByVersion(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if body["label_id"] != float64(5) {
|
||||
t.Errorf("v2 应发送 label_id,实际 %v", body)
|
||||
}
|
||||
|
||||
body = nil
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if body["id"] != float64(5) {
|
||||
t.Errorf("v1 应发送 Label 对象(id),实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 14) 时间字符串容忍:today / +3d / ISO
|
||||
func TestNormalizeTime(t *testing.T) {
|
||||
for _, in := range []string{"today", "tomorrow", "+3d", "2026-09-12 18:00", "2026-09-12T18:00:00+08:00"} {
|
||||
got := normalizeTime(in)
|
||||
s, ok := got.(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s: 期望字符串,实际 %T", in, got)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, s); err != nil {
|
||||
t.Errorf("%s → %s 不是 RFC3339: %v", in, s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 15) 通用直通:可指定 api_version,method 大小写不敏感
|
||||
func TestRawAPI(t *testing.T) {
|
||||
var method, path string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
method, path = r.Method, r.URL.Path
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
if _, err := p.handleRawAPI(map[string]interface{}{"method": "get", "path": "projects", "api_version": "v1"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if method != http.MethodGet || path != "/api/v1/projects" {
|
||||
t.Errorf("直通参数未生效: %s %s", method, path)
|
||||
}
|
||||
}
|
||||
|
||||
// 16) 精简输出可关闭(关闭时返回原样)
|
||||
func TestCompactToggle(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"t","description":"很长的描述","done":false}]`))
|
||||
})
|
||||
p.compact = false
|
||||
res, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
arr, ok := res.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("关闭精简后应原样返回数组,实际 %T", res)
|
||||
}
|
||||
if _, ok := arr[0].(map[string]interface{})["description"]; !ok {
|
||||
t.Errorf("关闭精简后应保留 description")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 回归:JSON body 里的 ID 必须是数字(线上实测的 422 缺口)────────────
|
||||
//
|
||||
// vikunja v2.6.0 实测(2026-09-12):
|
||||
// {"project_id":"1"} → 422 expected integer at body.project_id
|
||||
// {"user_id":"1"} → 422 expected integer at body.user_id
|
||||
// {"username":"jianf"} → 422 unexpected property at body.username
|
||||
// 旧实现把 argID() 的字符串直接塞进 body,assignee 还额外带 username,
|
||||
// 于是「建任务」「指派」在 v2 下必定失败 —— 只有真调用才暴露,单测没盖到。
|
||||
|
||||
func TestTaskCreateSendsNumericProjectID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
|
||||
})
|
||||
// project_id 传 float64 —— 这正是 SDK 从 JSON 解出来的真实类型
|
||||
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": float64(3), "title": "买菜"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if _, ok := body["project_id"].(float64); !ok {
|
||||
t.Errorf("project_id 必须是 JSON 数字,实际 %T=%v", body["project_id"], body["project_id"])
|
||||
}
|
||||
if strings.Contains(string(raw), `"project_id":"`) {
|
||||
t.Errorf("出现字符串型 project_id(v2 会 422 expected integer): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddResolvesUsernameToNumericUserID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
var calls []string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
if r.URL.Query().Get("q") != "alice" {
|
||||
t.Errorf("v2 用户搜索应用 q=,实际 query=%q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"},{"id":9,"username":"alice2"}]`))
|
||||
case "/api/v2/tasks/1/assignees":
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
default:
|
||||
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("应先查用户再指派,实际调用: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 必须是数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
if _, ok := body["username"]; ok {
|
||||
t.Errorf("v2 不接受 username 字段(422 unexpected property): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddNumericUserSkipsLookup(t *testing.T) {
|
||||
var calls []string
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
if r.URL.Path == "/api/v2/users" {
|
||||
t.Errorf("传数字 ID 时不该再查用户表")
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "7"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Errorf("应只有一次请求,实际: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 应为数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeRemoveUsesResolvedNumericPath(t *testing.T) {
|
||||
var gotPath string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"}]`))
|
||||
default:
|
||||
gotPath = r.Method + " " + r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "remove", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotPath != "DELETE /api/v2/tasks/1/assignees/7" {
|
||||
t.Errorf("移除应用解析出的数字 ID,实际 %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddUnknownUserGivesReadableError(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"bob"}]`))
|
||||
})
|
||||
_, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"})
|
||||
if err == nil {
|
||||
t.Fatal("找不到用户时必须报错,而不是发出一个注定 422 的请求")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "找不到用户") || !strings.Contains(err.Error(), "bob") {
|
||||
t.Errorf("错误信息应说明找不到并给出相近候选: %v", err)
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ weather plugin
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
@ -4,5 +4,5 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
|
||||
@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -44,12 +42,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
}
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
}
|
||||
os.MkdirAll(filepath.Join(dataHome, ".homeagent", "weather"), 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"current", sdk.ToolDef{
|
||||
Name: tp + "current", Description: "Get current weather for a city",
|
||||
@ -60,6 +52,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
|
||||
},
|
||||
},
|
||||
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
|
||||
NoMemory: true,
|
||||
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
|
||||
Cleaner: func(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.HasPrefix(line, "🌤") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleCurrent)
|
||||
|
||||
s.RegisterTool(tp+"forecast", sdk.ToolDef{
|
||||
@ -72,6 +75,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
|
||||
},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleForecast)
|
||||
|
||||
s.RegisterTool(tp+"set_location", sdk.ToolDef{
|
||||
@ -83,8 +87,34 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
"required": []string{"location"},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleSetLocation)
|
||||
|
||||
// 阶段钩子:own_tools 作用域——仅在本插件的工具被调用时触发
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
|
||||
}
|
||||
return nil
|
||||
}, sdk.StageScopeOwnTools)
|
||||
|
||||
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
|
||||
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
|
||||
NoMemory: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 输入通道:接收天气订阅请求(NoMemory: 通道输入不参与记忆计算)
|
||||
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
return nil
|
||||
}
|
||||
@ -343,7 +373,11 @@ func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error
|
||||
sunset = day.Astronomy[0].Sunset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(" %s %s/%s — %s~%s%s %s", weekday, day.Date[5:], day.Date[8:], minT, maxT, unitStr, desc)
|
||||
datePart := ""
|
||||
if len(day.Date) >= 8 {
|
||||
datePart = day.Date[5:7] + "/" + day.Date[8:]
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc)
|
||||
if precip != "" {
|
||||
line += precip
|
||||
}
|
||||
|
||||
132
meta/meta.go
132
meta/meta.go
@ -1,12 +1,56 @@
|
||||
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||
// 版本号应与核心 meta.Version 保持一致。
|
||||
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
|
||||
package meta
|
||||
|
||||
var (
|
||||
// Version 是 HomeAgent SDK 版本号。
|
||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||
Version = "0.8.0"
|
||||
//
|
||||
// 版本号语义:**SDK 版本跟随核心的中版本,patch 位恒为 .0**。
|
||||
// 整条核心 1.1.x 线(1.1.0、1.1.1、1.1.7…)共用 SDK 1.1.0;
|
||||
// 只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升到 1.2.0。
|
||||
// 这样插件开发者只需关心「我在为哪个中版本写插件」,
|
||||
// 不必跟着核心的每个 bugfix 换 SDK 依赖(见 核心仓 docs/git-branching.md §七)。
|
||||
//
|
||||
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
|
||||
// 公开 SDK 接口零改动,但产物形态变了(plugin.so → plugin.bin)。
|
||||
// 1.1.0:多模态贯通插件边界。**全部是新增,无签名变更**:
|
||||
// - Triple.SentenceText / Triple.MediaDigests
|
||||
// - Doc.MediaDigests / Doc.Attachments、MediaAttachment
|
||||
// - TextEvent.Attachments
|
||||
// - DocMemoryAPI.InsertWithMedia
|
||||
// - IOInjector 的 InjectInputMedia / InjectInputMediaSync /
|
||||
// InjectInterruptMedia;PluginSDK 补上缺失的 SetToolBlocks 包装
|
||||
// 同版修掉两处并发竞态(sdk/stress_test.go 的 -race 实证,不是理论风险):
|
||||
// PluginSDK 的 API 字段与 autoRestart 标志此前无锁,而写方
|
||||
// (内核注入 API、插件 SetAutoRestart)与读方(插件后台 goroutine
|
||||
// 注入、内核 registry 读 AutoRestart)天然跨 goroutine。
|
||||
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响。想用新字段的插件重编即可。
|
||||
//
|
||||
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**:
|
||||
// - InjectOptions{NoMemory, ContextPolicy}
|
||||
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
|
||||
// - ChannelDef.ContextPolicy(顺带给 ChannelDef 补上 JSON tag:
|
||||
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
|
||||
// 手写字段白名单,新增字段会被静默丢掉)
|
||||
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
|
||||
// 不裁剪),因此存量插件不需要改一行也不需要重编。
|
||||
// 裁剪(ContextPolicy=prune)必须显式声明——它会归档丢弃低相关事件。
|
||||
//
|
||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||
//
|
||||
// 现为 1.2.0:核心的 1.2.x 线正在发布中(release/v1.2.x 承载 1.2.0),
|
||||
// 但 **SDK 不跟 beta 发版**(§七.2)——SDK 1.2.0 的定版与 tag 随核心的
|
||||
// **正式** tag 一起做(§七.3)。在那之前 1.2.0 仍是 SDK 尚未发布的中版本,
|
||||
// 所以 main 就停在 1.2.0。
|
||||
//
|
||||
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x,
|
||||
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag,
|
||||
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
|
||||
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
|
||||
Version = "1.3.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -17,11 +61,23 @@ var (
|
||||
// SDKName 是 SDK 名称。
|
||||
SDKName = "HomeAgent SDK"
|
||||
|
||||
// CoreModule 是核心仓的 Go module path,供 plugindev 生成 go.mod 时使用。
|
||||
// CoreModule 是核心仓的 Go module path,供 hmapdev 生成 go.mod 时使用。
|
||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||
|
||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||
CoreVersion = "0.8.0"
|
||||
//
|
||||
// 1.0.0 是硬下限而非建议值:0.9.x 内核只会 dlopen `.so`,
|
||||
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
|
||||
//
|
||||
// ⚠️ 1.1.0 新增的媒体接口需要核心 **1.1.1+**(更早的核心没有
|
||||
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
||||
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
||||
// 媒体接口是可选能力,不用就不受影响。
|
||||
//
|
||||
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
|
||||
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
|
||||
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
|
||||
CoreVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
@ -29,59 +85,15 @@ func FullVersion() string {
|
||||
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||
}
|
||||
|
||||
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
|
||||
// 修改时需确保核心仓与 SDK 仓的值一致。
|
||||
|
||||
const (
|
||||
ABIVersion = 1
|
||||
ABIVersionMin = 1
|
||||
)
|
||||
|
||||
// ---- Dispatch Method IDs(与核心仓 internal/meta/meta.go 同步) ----
|
||||
const (
|
||||
CoreRegisterTool = 1
|
||||
CoreRegisterStage = 2
|
||||
CoreRegisterOutputCh = 3
|
||||
CoreRegisterPluginAPI = 4
|
||||
CoreInjectText = 5
|
||||
CoreInjectInterruptText = 6
|
||||
CoreInjectTextNoMemory = 7
|
||||
CoreSetAutoRestart = 8
|
||||
CoreMemoryRecall = 9
|
||||
CoreMemoryCommit = 10
|
||||
CoreMemoryIntrospect = 11
|
||||
CoreMemoryMerge = 12
|
||||
CoreMemoryPurge = 13
|
||||
CoreDocQuery = 14
|
||||
CoreKnowledgeSearch = 15
|
||||
CoreSettingsGet = 16
|
||||
CoreSettingsSet = 17
|
||||
CoreSettingsRegisterDef = 18
|
||||
CoreLLMListSources = 19
|
||||
CoreLLMSetSource = 20
|
||||
CoreSocialGetPerson = 21
|
||||
CoreSocialGetNetwork = 22
|
||||
CoreSubscribe = 23
|
||||
CoreUnsubscribe = 24
|
||||
CoreFreeString = 25
|
||||
CoreSettingsGetCore = 26
|
||||
CoreSettingsSetCore = 27
|
||||
CoreSettingsListCore = 28
|
||||
CoreSettingsGetPlugin = 29
|
||||
CoreSettingsSetPlugin = 30
|
||||
CoreSettingsListPlugin = 31
|
||||
CoreDocInsert = 32
|
||||
CoreDocRemove = 33
|
||||
CoreDocStats = 34
|
||||
CoreKnowledgeAdd = 35
|
||||
CoreKnowledgeList = 36
|
||||
CoreLLMCurrentSource = 37
|
||||
CoreSocialGetTrait = 38
|
||||
CoreSocialGetRelations = 39
|
||||
CoreSocialListPersons = 40
|
||||
CoreTextMemoryAppend = 41
|
||||
CoreSettingsList = 42
|
||||
CoreSettingsDefs = 43
|
||||
CoreSettingsDump = 44
|
||||
CoreSettingsPlugins = 45
|
||||
)
|
||||
// ---- 协议版本 ----
|
||||
//
|
||||
// 子进程 RPC 的协议版本是一个独立的小整数,与 SDK/内核语义版本解耦:
|
||||
// 语义版本变动频繁(修 bug、加字段),而 wire 协议只在**帧格式或握手语义**
|
||||
// 变化时才升。当前值见核心仓 internal/plugin/proc/protocol.go 的 ProtocolVersion。
|
||||
//
|
||||
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
|
||||
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
|
||||
// - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
|
||||
// - 版本协商改为握手帧里的 protocol 字段
|
||||
//
|
||||
// 保留那些常量只会让人以为它们还在生效。
|
||||
|
||||
150
package/build-examples.sh
Normal file
150
package/build-examples.sh
Normal file
@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# 给 SDK 发版打包**示例插件**的 .hmap 产物。
|
||||
#
|
||||
# 为什么要在 SDK 仓库里发示例插件的 hmap:
|
||||
# 插件二进制与内核是**协议绑定**的(internal/plugin/proc/protocol.go 的
|
||||
# ProtocolVersion + 统一共享内存区魔数)。SDK 升版往往同时意味着协议变化,
|
||||
# 而示例插件(qq/memo/browser/…)是使用者最常直接安装的东西。
|
||||
# 如果 SDK 只发工具链不发示例产物,使用者要么自己重编、要么用到与本版 SDK
|
||||
# 不匹配的旧产物——后者的表现是握手失败(协议/魔数不匹配),而且看起来像
|
||||
# 「插件坏了」而不是「版本不配套」。
|
||||
#
|
||||
# 用法:
|
||||
# package/build-examples.sh [TARGET] [OUT_DIR]
|
||||
# TARGET native(默认) | linux/amd64 | linux/arm64 | darwin/amd64 | darwin/arm64 | windows/amd64 | all
|
||||
# OUT_DIR 产物目录(默认 build/examples)
|
||||
#
|
||||
# 产物:
|
||||
# <OUT_DIR>/<name>_<goos>_<goarch>.hmap 每个示例插件一份
|
||||
# <OUT_DIR>/SHA256SUMS 全部产物齐全**之后**才计算
|
||||
# <OUT_DIR>/MANIFEST.txt 版本、协议版本、产自哪个 commit
|
||||
#
|
||||
# 纪律(与本项目其它构建脚本一致):
|
||||
# 1. 判成功看**产物是否存在**,不看退出码——hmapdev 对部分错误只打印不退出。
|
||||
# 2. SHA256SUMS 必须在全部产物生成完毕后一次算完,边打边算会漏掉后生成的包。
|
||||
set -uo pipefail
|
||||
|
||||
SDK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET="${1:-native}"
|
||||
OUT_DIR="${2:-$SDK_ROOT/build/examples}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo go)}"
|
||||
|
||||
case "$TARGET" in
|
||||
native) GOOS=""; GOARCH="" ;;
|
||||
linux/amd64) GOOS=linux; GOARCH=amd64 ;;
|
||||
linux/arm64) GOOS=linux; GOARCH=arm64 ;;
|
||||
darwin/amd64) GOOS=darwin; GOARCH=amd64 ;;
|
||||
darwin/arm64) GOOS=darwin; GOARCH=arm64 ;;
|
||||
windows/amd64)
|
||||
# 明确拒绝,而不是让调用方拿到一句深层 Go 编译错误。
|
||||
# 协议 2 的统一共享内存区只移植到了 Unix:内核 internal/plugin/proc/
|
||||
# shmpass_windows.go 仍是旧的 SHM_STAGE/SHM_EVTRING 两段布局,
|
||||
# 插件模板 proc_shm_windows.go 也缺 attachUnifiedShm。
|
||||
echo "windows 目标暂不支持:协议 2 的统一共享内存区未移植到 Windows(内核与插件模板均缺实现)。" >&2
|
||||
exit 1
|
||||
;;
|
||||
all)
|
||||
echo "本脚本一次只构建一个平台;请由 package/build.sh 传入具体目标。" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET" >&2
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [OUT_DIR]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# 按平台逐个构建,**不用** bundle 模式:
|
||||
# - bundle 会连 windows 一起编,而协议 2 的统一共享区尚未移植到 Windows
|
||||
# (内核 shmpass_windows.go 仍是旧的两段布局),必然失败;
|
||||
# - 逐平台构建每个目标都产出一份 .hmap,正是发版要附的产物。
|
||||
# 平台名解析成本脚本后面用(校验和与 MANIFEST 都要写清楚是哪个平台)。
|
||||
if [ -z "${GOOS:-}" ]; then
|
||||
GOOS="$(go env GOOS)"; GOARCH="$(go env GOARCH)"
|
||||
fi
|
||||
|
||||
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
|
||||
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
|
||||
# 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。
|
||||
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
|
||||
if [ ! -x "$PLUGINDEV" ]; then
|
||||
echo "[examples] 先构建 hmapdev ..."
|
||||
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
|
||||
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
|
||||
fi
|
||||
if [ ! -x "$PLUGINDEV" ]; then
|
||||
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 协议 ==="
|
||||
echo " ProtocolVersion = $(grep -m1 '^const ProtocolVersion' "$SDK_ROOT/../internal/plugin/proc/protocol.go" 2>/dev/null | grep -oE '[0-9]+' || echo '?(本仓非内核仓,跳过)')"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
# 清掉上一次的校验和:残留的 SHA256SUMS 会掩盖本次缺产物。
|
||||
rm -f "$OUT_DIR"/SHA256SUMS "$OUT_DIR"/MANIFEST.txt
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
failed_names=""
|
||||
|
||||
for dir in "$SDK_ROOT"/example/*/; do
|
||||
[ -f "$dir/plugin.go" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
|
||||
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
||||
rm -rf "$dir/build" "$dir/dist"
|
||||
|
||||
out=$( cd "$dir" && "$PLUGINDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
|
||||
rc=$?
|
||||
|
||||
# 判据是**退出码 + 产物存在**,两者都要。
|
||||
# 只看退出码:hmapdev 曾经出错也退 0(已修,但脚本不该依赖它「现在」是对的)。
|
||||
# 只看产物:部分平台失败时会留下上一次的产物,看起来像成功。
|
||||
hmap="$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)"
|
||||
if [ $rc -eq 0 ] && [ -n "$hmap" ]; then
|
||||
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份),
|
||||
# 只在前面加平台前缀避免多平台互相覆盖。
|
||||
dest="$OUT_DIR/${GOOS}_${GOARCH}_$(basename "$hmap")"
|
||||
cp "$hmap" "$dest"
|
||||
printf "✓ %-14s → %s (%s)\n" "$name" "$(basename "$dest")" "$(du -h "$dest" | cut -f1)"
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
printf "✗ %-14s 构建失败 (rc=%d)\n" "$name" "$rc"
|
||||
echo "$out" | tail -6 | sed 's/^/ /'
|
||||
fail=$((fail + 1))
|
||||
failed_names="$failed_names $name"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "示例产物: 成功 $ok / 失败 $fail"
|
||||
[ -n "$failed_names" ] && echo "失败:$failed_names"
|
||||
|
||||
# 有失败就不算发版闭环:宁可整个中断,也不要发出「少几个插件」的包。
|
||||
if [ $fail -ne 0 ]; then
|
||||
echo "[examples] 有示例构建失败,不生成 SHA256SUMS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) 全部产物齐了才算校验和。
|
||||
( cd "$OUT_DIR" && sha256sum ./*.hmap > SHA256SUMS )
|
||||
|
||||
VERSION="${VERSION:-$(git -C "$SDK_ROOT" describe --tags --dirty 2>/dev/null || echo unknown)}"
|
||||
COMMIT="${COMMIT:-$(git -C "$SDK_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)}"
|
||||
{
|
||||
echo "sdk_version: $VERSION"
|
||||
echo "sdk_commit: $COMMIT"
|
||||
echo "target: $GOOS/$GOARCH"
|
||||
echo "plugins: $ok"
|
||||
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo
|
||||
echo "这些 .hmap 与本版 SDK 的插件协议绑定,必须与同版本内核配套安装。"
|
||||
echo "校验:sha256sum -c SHA256SUMS"
|
||||
} > "$OUT_DIR/MANIFEST.txt"
|
||||
|
||||
echo "[examples] 产物: $OUT_DIR"
|
||||
echo "[examples] 清单: $OUT_DIR/MANIFEST.txt"
|
||||
echo "[examples] 校验: $OUT_DIR/SHA256SUMS"
|
||||
@ -5,6 +5,13 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
|
||||
# 宿主平台必须在**本脚本 export GOOS/GOARCH 之前**取定。
|
||||
# 否则 `go env GOOS` 会返回被 export 的目标平台(此前 `build.sh all all`
|
||||
# 就是因此拿 darwin 二进制在 linux 上跑,报 cannot execute binary file)。
|
||||
NATIVE_GOOS="$(env -u GOOS -u GOARCH "$GO" env GOOS 2>/dev/null || uname -s | tr 'A-Z' 'a-z')"
|
||||
NATIVE_GOARCH="$(env -u GOOS -u GOARCH "$GO" env GOARCH 2>/dev/null || uname -m)"
|
||||
case "$NATIVE_GOARCH" in x86_64|amd64) NATIVE_GOARCH="amd64" ;; aarch64|arm64) NATIVE_GOARCH="arm64" ;; esac
|
||||
case "$NATIVE_GOOS" in darwin|linux|windows) ;; *) NATIVE_GOOS="linux" ;; esac
|
||||
GOCACHE="${GOCACHE:-}"
|
||||
GOPATH="${GOPATH:-}"
|
||||
|
||||
@ -28,7 +35,7 @@ case "$TARGET" in
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|plugindev]"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|hmapdev|examples]"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
@ -42,12 +49,12 @@ export CGO_ENABLED=0
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
build_plugindev() {
|
||||
local src="tools/plugindev"
|
||||
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
|
||||
build_hmapdev() {
|
||||
local src="tools/hmapdev"
|
||||
local out="$BUILD_DIR/hmapdev${SUFFIX:+_$SUFFIX}"
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] plugindev ${GOOS:-linux}/${GOARCH:-amd64} → $out"
|
||||
echo "[BUILD] hmapdev ${GOOS:-linux}/${GOARCH:-amd64} → $out"
|
||||
cd "$PROJECT_ROOT/$src"
|
||||
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
||||
-o "$out" .
|
||||
@ -55,9 +62,47 @@ build_plugindev() {
|
||||
cd "$PROJECT_ROOT"
|
||||
}
|
||||
|
||||
# 示例插件产物随 SDK 一起发。
|
||||
#
|
||||
# 为什么必须发:插件二进制与内核是**协议绑定**的(ProtocolVersion + 统一共享
|
||||
# 内存区魔数)。SDK 升版常伴随协议变化,只发工具链不发示例产物,使用者很可能
|
||||
# 拿旧产物去装,表现是握手失败(魔数不匹配)——看起来像「插件坏了」而不是
|
||||
# 「版本不配套」。
|
||||
#
|
||||
# 用**宿主可执行**的那把工具链(而非 PATH 里的),保证产物与本次发版同源。
|
||||
#
|
||||
# 为什么不能用目标平台的那把:示例的跨平台构建是由 hmapdev 的 `--target GOOS/GOARCH`
|
||||
# 完成的,被执行的进程本身必須能在当前机器上跑。拿目标平台的二进制去跑只会得到
|
||||
# “cannot execute binary file: Exec format error”(`build.sh all all` 在 darwin 处断过)。
|
||||
build_examples() {
|
||||
local dev
|
||||
dev="$BUILD_DIR/hmapdev_${NATIVE_GOOS}_${NATIVE_GOARCH}"
|
||||
[ "$NATIVE_GOOS" = "windows" ] && dev="${dev}.exe"
|
||||
# 宿主工具链缺失时先补建(`all` 的第一个目标可能不是宿主平台)。
|
||||
if [ ! -x "$dev" ]; then
|
||||
echo "[BUILD] 先补建宿主工具链 ${NATIVE_GOOS}/${NATIVE_GOARCH}(示例的跨平台由 --target 完成)"
|
||||
( unset GOOS GOARCH; bash "$0" "${NATIVE_GOOS}/${NATIVE_GOARCH}" hmapdev ) || return 1
|
||||
fi
|
||||
if [ ! -x "$dev" ]; then
|
||||
echo "[BUILD] 无法构建示例:缺少宿主可执行的工具链 $dev" >&2
|
||||
echo " 先跑: $0 ${NATIVE_GOOS}/${NATIVE_GOARCH} hmapdev" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[BUILD] example plugins ${GOOS:-linux}/${GOARCH:-amd64} → $BUILD_DIR/examples(用 ${NATIVE_GOOS}/${NATIVE_GOARCH} 的工具链交叉构建)"
|
||||
PLUGINDEV="$dev" VERSION="$VERSION" bash "$PROJECT_ROOT/package/build-examples.sh" "$TARGET" "$BUILD_DIR/examples"
|
||||
echo " OK"
|
||||
}
|
||||
|
||||
case "$COMPONENT" in
|
||||
all|plugindev) build_plugindev ;;
|
||||
all)
|
||||
# 工具链必须先建完:示例用它来构建(同源保证协议一致)。
|
||||
build_hmapdev
|
||||
build_examples
|
||||
;;
|
||||
hmapdev) build_hmapdev ;;
|
||||
examples) build_examples ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -48,7 +48,7 @@ Function pageConfirm
|
||||
${EndIf}
|
||||
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
||||
Pop $0
|
||||
${NSD_CreateLabel} 15u 20u 100% 12u "• plugindev.exe — 插件开发工具"
|
||||
${NSD_CreateLabel} 15u 20u 100% 12u "• hmapdev.exe — 插件开发工具"
|
||||
Pop $0
|
||||
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
||||
Pop $0
|
||||
@ -64,11 +64,11 @@ Section "Install" SEC_INSTALL
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
DetailPrint "复制工具链文件..."
|
||||
File "plugindev.exe"
|
||||
File "hmapdev.exe"
|
||||
|
||||
DetailPrint "创建快捷方式..."
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk" "$INSTDIR\plugindev.exe" "" "$INSTDIR\plugindev.exe" 0
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk" "$INSTDIR\hmapdev.exe" "" "$INSTDIR\hmapdev.exe" 0
|
||||
|
||||
DetailPrint "配置环境变量..."
|
||||
; Add to system PATH
|
||||
@ -93,23 +93,23 @@ Section "Install" SEC_INSTALL
|
||||
DetailPrint "Git 已安装: $1"
|
||||
${Else}
|
||||
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
||||
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
|
||||
DetailPrint "安装完成后请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||
${EndIf}
|
||||
|
||||
${If} $hasGit == "1"
|
||||
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
|
||||
nsExec::ExecToStack '"$INSTDIR\plugindev.exe" sdk install ${SDK_VERSION}'
|
||||
nsExec::ExecToStack '"$INSTDIR\hmapdev.exe" sdk install ${SDK_VERSION}'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 == 0
|
||||
StrCpy $sdkInstallOk "1"
|
||||
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
||||
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
|
||||
nsExec::Exec '"$INSTDIR\plugindev.exe" sdk use ${SDK_VERSION}'
|
||||
nsExec::Exec '"$INSTDIR\hmapdev.exe" sdk use ${SDK_VERSION}'
|
||||
Pop $0
|
||||
${Else}
|
||||
DetailPrint "SDK 下载失败 (错误码: $0)"
|
||||
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
|
||||
DetailPrint "请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
@ -126,10 +126,10 @@ SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$INSTDIR\Uninstall.exe"
|
||||
Delete "$INSTDIR\plugindev.exe"
|
||||
Delete "$INSTDIR\hmapdev.exe"
|
||||
RMDir /r "$INSTDIR\sdk"
|
||||
RMDir "$INSTDIR"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk"
|
||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
||||
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
||||
|
||||
116
remotedevice/CMakeLists.txt
Normal file
116
remotedevice/CMakeLists.txt
Normal file
@ -0,0 +1,116 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
|
||||
|
||||
# ============================================================
|
||||
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
|
||||
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
||||
#
|
||||
# 使用方式:
|
||||
# add_subdirectory(path/to/ha_remotedevice)
|
||||
# target_link_libraries(my_app ha_remotedevice)
|
||||
# target_include_directories(my_app PRIVATE
|
||||
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||
# ============================================================
|
||||
|
||||
# 选项: 构建为静态库或动态库
|
||||
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
|
||||
|
||||
# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调)
|
||||
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
|
||||
|
||||
# 选项: 日志级别
|
||||
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
|
||||
|
||||
# 源文件
|
||||
set(HA_REMOTEDEVICE_SRC
|
||||
src/ha_remotedevice.c
|
||||
src/ha_json.c
|
||||
src/ha_ws.c
|
||||
)
|
||||
|
||||
# 头文件
|
||||
set(HA_REMOTEDEVICE_INCLUDE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
# 编译选项
|
||||
if(HA_NO_ALLOC)
|
||||
add_definitions(-DHA_NO_ALLOC)
|
||||
endif()
|
||||
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
|
||||
|
||||
# 创建库
|
||||
if(BUILD_SHARED_LIBS)
|
||||
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
|
||||
if(WIN32)
|
||||
# Windows 需要导出符号
|
||||
set_target_properties(ha_remotedevice PROPERTIES
|
||||
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||
endif()
|
||||
else()
|
||||
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
|
||||
endif()
|
||||
|
||||
# 包含目录
|
||||
target_include_directories(ha_remotedevice
|
||||
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
)
|
||||
|
||||
# 不链接任何外部库
|
||||
target_link_libraries(ha_remotedevice PRIVATE)
|
||||
|
||||
# 导出包含目录供外部项目使用
|
||||
set(HA_REMOTEDEVICE_INCLUDE_DIR
|
||||
${HA_REMOTEDEVICE_INCLUDE}
|
||||
CACHE INTERNAL "ha_remotedevice include directories")
|
||||
|
||||
# 安装规则
|
||||
install(TARGETS ha_remotedevice
|
||||
EXPORT ha_remotedevice-targets
|
||||
LIBRARY DESTINATION lib
|
||||
ARCHIVE DESTINATION lib
|
||||
RUNTIME DESTINATION bin
|
||||
INCLUDES DESTINATION include
|
||||
)
|
||||
|
||||
install(DIRECTORY include/
|
||||
DESTINATION include
|
||||
)
|
||||
|
||||
install(EXPORT ha_remotedevice-targets
|
||||
DESTINATION lib/cmake/ha_remotedevice
|
||||
NAMESPACE ha_remotedevice::
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 测试(可选)
|
||||
# ============================================================
|
||||
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
|
||||
|
||||
if(BUILD_TESTS)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_executable(ha_remotedevice_test
|
||||
test/test_ha_remotedevice.c
|
||||
)
|
||||
target_link_libraries(ha_remotedevice_test
|
||||
PRIVATE ha_remotedevice Threads::Threads
|
||||
)
|
||||
target_include_directories(ha_remotedevice_test
|
||||
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
# 添加测试
|
||||
add_test(NAME ha_remotedevice_test
|
||||
COMMAND ha_remotedevice_test
|
||||
)
|
||||
endif()
|
||||
|
||||
# ============================================================
|
||||
# 编译信息
|
||||
# ============================================================
|
||||
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
|
||||
message(STATUS " Build type: $<CONFIG>")
|
||||
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
|
||||
message(STATUS " No alloc: ${HA_NO_ALLOC}")
|
||||
216
remotedevice/include/ha_remotedevice.h
Normal file
216
remotedevice/include/ha_remotedevice.h
Normal file
@ -0,0 +1,216 @@
|
||||
#ifndef HA_REMOTEDEVICE_H
|
||||
#define HA_REMOTEDEVICE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ==================================================================
|
||||
* ha_remotedevice — 远程设备接入 C SDK
|
||||
*
|
||||
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
||||
* 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。
|
||||
*
|
||||
* 声明式设计:
|
||||
* 设备在代码中声明自己是什么(kind)和能做什么(caps),
|
||||
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
|
||||
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
|
||||
*
|
||||
* 协议流程:
|
||||
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
|
||||
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
|
||||
* ================================================================== */
|
||||
|
||||
/* ======================== 状态码 ======================== */
|
||||
typedef enum {
|
||||
HA_OK = 0,
|
||||
HA_ERR_GENERIC = -1,
|
||||
HA_ERR_NOMEM = -2,
|
||||
HA_ERR_INVALID = -3,
|
||||
HA_ERR_TIMEOUT = -4,
|
||||
HA_ERR_DISCONNECTED = -5,
|
||||
HA_ERR_PROTOCOL = -6,
|
||||
HA_ERR_TRANSPORT = -7,
|
||||
HA_ERR_NOT_FOUND = -8,
|
||||
} ha_status_t;
|
||||
|
||||
/* ======================== 传输层抽象 ========================
|
||||
*
|
||||
* 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。
|
||||
*
|
||||
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
|
||||
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
|
||||
* recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败
|
||||
* close(ctx) → 关闭连接
|
||||
*/
|
||||
typedef struct {
|
||||
int (*connect)(void *ctx, const char *host, uint16_t port);
|
||||
int (*send)(void *ctx, const uint8_t *data, int len);
|
||||
int (*recv)(void *ctx, uint8_t *buf, int len);
|
||||
void (*close)(void *ctx);
|
||||
void *ctx;
|
||||
} ha_transport_t;
|
||||
|
||||
/* ======================== 设备声明 ========================
|
||||
*
|
||||
* 声明式配置:设备在代码中声明自己的类型和能力。
|
||||
* 这些信息通过 hello 消息发送给网关。
|
||||
*
|
||||
* device_id — 唯一标识,如 "esp32-cam-1"
|
||||
* name — 设备显示名,如 "门口摄像头"
|
||||
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
|
||||
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
|
||||
* info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
|
||||
*/
|
||||
typedef struct {
|
||||
const char *device_id;
|
||||
const char *name;
|
||||
const char *kind;
|
||||
const char **caps; /* NULL 结尾 */
|
||||
const char *info_json; /* 可选,NULL 或 JSON 字符串 */
|
||||
} ha_device_info_t;
|
||||
|
||||
/* ======================== 命令结果 ========================
|
||||
*
|
||||
* 命令处理函数通过填写此结构体返回数据。
|
||||
* SDK 收到结果后自动发送回执(文本或二进制分块)。
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 简单文本:设置 status=0, output="结果文本"
|
||||
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
|
||||
* 3. 错误:设置 status=1, error="错误信息"
|
||||
*
|
||||
* 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。
|
||||
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
|
||||
* 所以 handler 可以返回栈上或静态字符串。
|
||||
*/
|
||||
typedef struct {
|
||||
int status; /* 0=ok, 非0=error */
|
||||
const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */
|
||||
const char *error; /* 错误信息 */
|
||||
int has_binary; /* 1=通过二进制分块回传 */
|
||||
const char *binary_mime; /* 二进制 MIME 类型 */
|
||||
const uint8_t *binary_data; /* 二进制数据指针 */
|
||||
int binary_len; /* 二进制数据长度 */
|
||||
} ha_cmd_result_t;
|
||||
|
||||
/* ======================== 命令处理声明 ========================
|
||||
*
|
||||
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
|
||||
*
|
||||
* command 值说明:
|
||||
* - "shell" → 处理 shell 类型命令,args 为完整命令字符串
|
||||
* - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数
|
||||
* - "screensee" → 处理 homeagent-screensee 命令
|
||||
* - "speakeruse" → 处理 homeagent-speakeruse 命令
|
||||
* - "computeruse" → 处理 homeagent-computeruse 命令
|
||||
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
|
||||
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
|
||||
* - "screensue" → 处理 homeagent-screensue 命令
|
||||
* - "deviceinfo" → 处理设备信息查询
|
||||
* - 其他自定义命令名 → 按字符串匹配分发
|
||||
*
|
||||
* handler 处理完毕后只需填写 result 结构体,SDK 自动回执。
|
||||
*/
|
||||
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata);
|
||||
|
||||
typedef struct {
|
||||
const char *command; /* 命令名,如 "camerasue"、"shell" */
|
||||
ha_cmd_handler_t handler; /* 处理函数 */
|
||||
} ha_cmd_handler_def_t;
|
||||
|
||||
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
|
||||
* data 指针在回调返回后失效,如需保存请拷贝。 */
|
||||
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
|
||||
const char *mime, const uint8_t *data,
|
||||
int len, void *userdata);
|
||||
|
||||
/* 连接状态变化回调 */
|
||||
typedef void (*ha_state_callback_t)(int connected, void *userdata);
|
||||
|
||||
/* ======================== 客户端配置 ========================
|
||||
*
|
||||
* 所有配置在 ha_client_new() 时一次性声明。
|
||||
* 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。
|
||||
*/
|
||||
typedef struct {
|
||||
ha_transport_t transport; /* 传输层实现(必须) */
|
||||
ha_device_info_t device; /* 设备声明(必须) */
|
||||
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
|
||||
const char *token; /* 接入令牌(必须) */
|
||||
|
||||
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
|
||||
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
|
||||
ha_state_callback_t on_state; /* 状态变化回调(可选) */
|
||||
void *userdata; /* 用户自定义数据,传给所有回调 */
|
||||
|
||||
int ping_interval; /* 心跳间隔秒数,0 则默认 30 */
|
||||
int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */
|
||||
} ha_config_t;
|
||||
|
||||
/* ======================== 客户端 API ======================== */
|
||||
|
||||
typedef struct ha_client ha_client_t;
|
||||
|
||||
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
|
||||
ha_client_t *ha_client_new(const ha_config_t *config);
|
||||
|
||||
/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
|
||||
ha_status_t ha_client_start(ha_client_t *client);
|
||||
|
||||
/* 主循环处理:必须在用户的主循环中周期性调用。
|
||||
* - 读取 WS 帧并分发
|
||||
* - 按 handlers 表查找命令处理函数,自动回执结果
|
||||
* - 处理心跳 ping/pong
|
||||
* - 处理断线重连
|
||||
* 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */
|
||||
ha_status_t ha_client_process(ha_client_t *client);
|
||||
|
||||
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
|
||||
|
||||
/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */
|
||||
void ha_client_send_event(ha_client_t *client, const char *type,
|
||||
const char *detail);
|
||||
|
||||
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
|
||||
void ha_client_send_status(ha_client_t *client, const char *status);
|
||||
|
||||
/* ===== 生命周期 ===== */
|
||||
|
||||
/* 停止客户端,断开连接。 */
|
||||
void ha_client_stop(ha_client_t *client);
|
||||
|
||||
/* 销毁客户端,释放所有资源。 */
|
||||
void ha_client_destroy(ha_client_t *client);
|
||||
|
||||
/* ======================== 工具函数 ======================== */
|
||||
|
||||
/* 解析 homeagent-* 命令,返回能力名和参数。
|
||||
* command = "camerasue 5" → cap="camerasue", args="5"
|
||||
* command = "screensee" → cap="screensee", args=""
|
||||
* command = "computeruse {...}" → cap="computeruse", args="..." */
|
||||
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
||||
const char **args);
|
||||
|
||||
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
|
||||
* command = "computeruse {\"action\":\"click\",\"x\":100}"
|
||||
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
|
||||
void ha_cmd_parse_json(const char *command, const char **action,
|
||||
const char **json_str);
|
||||
|
||||
/* Base64 编码(用于将二进制数据编码为文本回传)。
|
||||
* 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */
|
||||
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
|
||||
|
||||
/* 获取版本号 */
|
||||
const char *ha_version(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_REMOTEDEVICE_H */
|
||||
369
remotedevice/src/ha_json.c
Normal file
369
remotedevice/src/ha_json.c
Normal file
@ -0,0 +1,369 @@
|
||||
#include "ha_json.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* ======================== 解析器 ======================== */
|
||||
|
||||
/* 前向声明 */
|
||||
static ha_json_node_t *parse_value(const char **pp);
|
||||
|
||||
/* 跳过空白 */
|
||||
static const char *skip_ws(const char *p) {
|
||||
while (*p && (unsigned char)*p <= ' ') p++;
|
||||
return p;
|
||||
}
|
||||
|
||||
/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */
|
||||
static char *parse_string(const char **pp) {
|
||||
const char *p = skip_ws(*pp);
|
||||
if (*p != '"') return NULL;
|
||||
p++;
|
||||
int len = 0;
|
||||
const char *q = p;
|
||||
while (*q && *q != '"') {
|
||||
if (*q == '\\') { q++; if (*q) q++; }
|
||||
else q++;
|
||||
len++;
|
||||
}
|
||||
if (*q != '"') return NULL;
|
||||
char *s = (char *)malloc(len + 1);
|
||||
if (!s) return NULL;
|
||||
q = p;
|
||||
int i = 0;
|
||||
while (*q && *q != '"') {
|
||||
if (*q == '\\') {
|
||||
q++;
|
||||
switch (*q) {
|
||||
case '"': s[i++] = '"'; break;
|
||||
case '\\': s[i++] = '\\'; break;
|
||||
case '/': s[i++] = '/'; break;
|
||||
case 'b': s[i++] = '\b'; break;
|
||||
case 'f': s[i++] = '\f'; break;
|
||||
case 'n': s[i++] = '\n'; break;
|
||||
case 'r': s[i++] = '\r'; break;
|
||||
case 't': s[i++] = '\t'; break;
|
||||
case 'u': q += 4; s[i++] = '?'; continue;
|
||||
default: s[i++] = *q; break;
|
||||
}
|
||||
q++;
|
||||
} else {
|
||||
s[i++] = *q++;
|
||||
}
|
||||
}
|
||||
s[i] = '\0';
|
||||
*pp = q + 1;
|
||||
return s;
|
||||
}
|
||||
|
||||
static ha_json_node_t *new_node(ha_json_type_t type) {
|
||||
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
|
||||
if (n) n->type = type;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 解析数字 */
|
||||
static ha_json_node_t *parse_number(const char **pp) {
|
||||
const char *p = *pp;
|
||||
int neg = 0;
|
||||
if (*p == '-') { neg = 1; p++; }
|
||||
if (!isdigit((unsigned char)*p)) return NULL;
|
||||
int val = 0;
|
||||
while (isdigit((unsigned char)*p)) {
|
||||
val = val * 10 + (*p - '0');
|
||||
p++;
|
||||
}
|
||||
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
|
||||
if (*p == 'e' || *p == 'E') {
|
||||
p++;
|
||||
if (*p == '+' || *p == '-') p++;
|
||||
while (isdigit((unsigned char)*p)) p++;
|
||||
}
|
||||
*pp = p;
|
||||
ha_json_node_t *n = new_node(HA_JSON_INT);
|
||||
if (n) n->int_val = neg ? -val : val;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 解析 true/false/null */
|
||||
static ha_json_node_t *parse_keyword(const char **pp) {
|
||||
const char *p = *pp;
|
||||
ha_json_node_t *n = NULL;
|
||||
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
||||
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
|
||||
*pp = p + 4;
|
||||
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
|
||||
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
|
||||
*pp = p + 5;
|
||||
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
||||
n = new_node(HA_JSON_NULL);
|
||||
*pp = p + 4;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 解析对象 */
|
||||
static ha_json_node_t *parse_object(const char **pp) {
|
||||
const char *p = skip_ws(*pp);
|
||||
if (*p != '{') return NULL;
|
||||
p++;
|
||||
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
|
||||
if (!obj) return NULL;
|
||||
ha_json_node_t **tail = &obj->child;
|
||||
p = skip_ws(p);
|
||||
if (*p == '}') { *pp = p + 1; return obj; }
|
||||
while (*p) {
|
||||
p = skip_ws(p);
|
||||
char *key = parse_string(&p);
|
||||
if (!key) break;
|
||||
p = skip_ws(p);
|
||||
if (*p != ':') { free(key); break; }
|
||||
p++;
|
||||
ha_json_node_t *val = parse_value(&p);
|
||||
if (!val) { free(key); break; }
|
||||
val->key = key;
|
||||
*tail = val;
|
||||
tail = &val->next;
|
||||
p = skip_ws(p);
|
||||
if (*p == ',') { p++; continue; }
|
||||
if (*p == '}') break;
|
||||
}
|
||||
p = skip_ws(p);
|
||||
if (*p == '}') { *pp = p + 1; return obj; }
|
||||
ha_json_free(obj);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 解析数组 */
|
||||
static ha_json_node_t *parse_array(const char **pp) {
|
||||
const char *p = skip_ws(*pp);
|
||||
if (*p != '[') return NULL;
|
||||
p++;
|
||||
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
|
||||
if (!arr) return NULL;
|
||||
ha_json_node_t **tail = &arr->child;
|
||||
p = skip_ws(p);
|
||||
if (*p == ']') { *pp = p + 1; return arr; }
|
||||
while (*p) {
|
||||
ha_json_node_t *val = parse_value(&p);
|
||||
if (!val) break;
|
||||
*tail = val;
|
||||
tail = &val->next;
|
||||
p = skip_ws(p);
|
||||
if (*p == ',') { p++; continue; }
|
||||
if (*p == ']') break;
|
||||
}
|
||||
p = skip_ws(p);
|
||||
if (*p == ']') { *pp = p + 1; return arr; }
|
||||
ha_json_free(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 解析值(主入口) */
|
||||
static ha_json_node_t *parse_value(const char **pp) {
|
||||
const char *p = skip_ws(*pp);
|
||||
if (*p == '{') return parse_object(pp);
|
||||
if (*p == '[') return parse_array(pp);
|
||||
if (*p == '"') {
|
||||
char *s = parse_string(pp);
|
||||
if (!s) return NULL;
|
||||
ha_json_node_t *n = new_node(HA_JSON_STRING);
|
||||
if (!n) { free(s); return NULL; }
|
||||
n->str_val = s;
|
||||
return n;
|
||||
}
|
||||
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
|
||||
return parse_keyword(pp);
|
||||
}
|
||||
|
||||
/* ======================== 公共 API ======================== */
|
||||
|
||||
ha_json_node_t *ha_json_parse(const char *str) {
|
||||
if (!str) return NULL;
|
||||
const char *p = str;
|
||||
return parse_value(&p);
|
||||
}
|
||||
|
||||
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
|
||||
ha_json_node_t *n = ha_json_get(obj, key);
|
||||
if (!n || n->type != HA_JSON_STRING) return NULL;
|
||||
return n->str_val;
|
||||
}
|
||||
|
||||
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
|
||||
ha_json_node_t *n = ha_json_get(obj, key);
|
||||
if (!n || n->type != HA_JSON_INT) return def;
|
||||
return n->int_val;
|
||||
}
|
||||
|
||||
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
|
||||
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
|
||||
ha_json_node_t *c = obj->child;
|
||||
while (c) {
|
||||
if (c->key && strcmp(c->key, key) == 0) return c;
|
||||
c = c->next;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int ha_json_array_len(const ha_json_node_t *arr) {
|
||||
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
|
||||
int n = 0;
|
||||
ha_json_node_t *c = arr->child;
|
||||
while (c) { n++; c = c->next; }
|
||||
return n;
|
||||
}
|
||||
|
||||
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
|
||||
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
|
||||
ha_json_node_t *c = arr->child;
|
||||
int i = 0;
|
||||
while (c) {
|
||||
if (i == index) return c;
|
||||
i++; c = c->next;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void ha_json_free(ha_json_node_t *root) {
|
||||
if (!root) return;
|
||||
ha_json_node_t *c = root->child;
|
||||
while (c) {
|
||||
ha_json_node_t *next = c->next;
|
||||
free(c->key);
|
||||
if (c->type == HA_JSON_STRING) free(c->str_val);
|
||||
ha_json_free(c);
|
||||
c = next;
|
||||
}
|
||||
free(root);
|
||||
}
|
||||
|
||||
/* ======================== 构建器 ======================== */
|
||||
|
||||
static void json_escape(ha_json_builder_t *jb, const char *s) {
|
||||
if (!s) { ha_json_builder_raw(jb, "null"); return; }
|
||||
ha_json_builder_raw(jb, "\"");
|
||||
for (const char *p = s; *p; p++) {
|
||||
unsigned char c = (unsigned char)*p;
|
||||
switch (c) {
|
||||
case '"': ha_json_builder_raw(jb, "\\\""); break;
|
||||
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
|
||||
case '\b': ha_json_builder_raw(jb, "\\b"); break;
|
||||
case '\f': ha_json_builder_raw(jb, "\\f"); break;
|
||||
case '\n': ha_json_builder_raw(jb, "\\n"); break;
|
||||
case '\r': ha_json_builder_raw(jb, "\\r"); break;
|
||||
case '\t': ha_json_builder_raw(jb, "\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||
ha_json_builder_raw(jb, buf);
|
||||
} else {
|
||||
char buf[2] = { (char)c, 0 };
|
||||
ha_json_builder_raw(jb, buf);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ha_json_builder_raw(jb, "\"");
|
||||
}
|
||||
|
||||
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
|
||||
jb->buf = buf;
|
||||
jb->len = 0;
|
||||
jb->cap = cap;
|
||||
jb->depth = 0;
|
||||
if (cap > 0) buf[0] = '\0';
|
||||
}
|
||||
|
||||
void ha_json_builder_reset(ha_json_builder_t *jb) {
|
||||
jb->len = 0;
|
||||
jb->depth = 0;
|
||||
if (jb->cap > 0) jb->buf[0] = '\0';
|
||||
}
|
||||
|
||||
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
|
||||
while (*s && jb->len < jb->cap - 1) {
|
||||
jb->buf[jb->len++] = *s++;
|
||||
}
|
||||
jb->buf[jb->len] = '\0';
|
||||
}
|
||||
|
||||
void ha_json_builder_comma(ha_json_builder_t *jb) {
|
||||
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
|
||||
ha_json_builder_raw(jb, ",");
|
||||
}
|
||||
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
|
||||
}
|
||||
|
||||
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
|
||||
ha_json_builder_comma(jb);
|
||||
ha_json_builder_raw(jb, "{");
|
||||
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
||||
jb->depth++;
|
||||
}
|
||||
|
||||
void ha_json_builder_end_object(ha_json_builder_t *jb) {
|
||||
jb->depth--;
|
||||
ha_json_builder_raw(jb, "}");
|
||||
}
|
||||
|
||||
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
|
||||
ha_json_builder_comma(jb);
|
||||
ha_json_builder_raw(jb, "[");
|
||||
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
||||
jb->depth++;
|
||||
}
|
||||
|
||||
void ha_json_builder_end_array(ha_json_builder_t *jb) {
|
||||
jb->depth--;
|
||||
ha_json_builder_raw(jb, "]");
|
||||
}
|
||||
|
||||
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
|
||||
ha_json_builder_comma(jb);
|
||||
json_escape(jb, key);
|
||||
ha_json_builder_raw(jb, ":");
|
||||
}
|
||||
|
||||
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
|
||||
json_escape(jb, val);
|
||||
}
|
||||
|
||||
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%d", val);
|
||||
ha_json_builder_raw(jb, buf);
|
||||
}
|
||||
|
||||
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
|
||||
ha_json_builder_raw(jb, val ? "true" : "false");
|
||||
}
|
||||
|
||||
void ha_json_builder_add_null(ha_json_builder_t *jb) {
|
||||
ha_json_builder_raw(jb, "null");
|
||||
}
|
||||
|
||||
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
|
||||
ha_json_builder_key(jb, key);
|
||||
json_escape(jb, val);
|
||||
}
|
||||
|
||||
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
|
||||
ha_json_builder_key(jb, key);
|
||||
ha_json_builder_add_int(jb, val);
|
||||
}
|
||||
|
||||
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
|
||||
ha_json_builder_key(jb, key);
|
||||
ha_json_builder_add_bool(jb, val);
|
||||
}
|
||||
|
||||
const char *ha_json_builder_str(ha_json_builder_t *jb) {
|
||||
return jb->buf;
|
||||
}
|
||||
|
||||
int ha_json_builder_len(ha_json_builder_t *jb) {
|
||||
return jb->len;
|
||||
}
|
||||
107
remotedevice/src/ha_json.h
Normal file
107
remotedevice/src/ha_json.h
Normal file
@ -0,0 +1,107 @@
|
||||
#ifndef HA_JSON_H
|
||||
#define HA_JSON_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ======================== JSON 解析器(DOM 风格) ======================== */
|
||||
typedef enum {
|
||||
HA_JSON_NULL,
|
||||
HA_JSON_BOOL,
|
||||
HA_JSON_INT,
|
||||
HA_JSON_STRING,
|
||||
HA_JSON_ARRAY,
|
||||
HA_JSON_OBJECT,
|
||||
} ha_json_type_t;
|
||||
|
||||
typedef struct ha_json_node {
|
||||
ha_json_type_t type;
|
||||
union {
|
||||
int bool_val;
|
||||
int int_val;
|
||||
char *str_val;
|
||||
};
|
||||
struct ha_json_node *next; /* linked list for array/object items */
|
||||
struct ha_json_node *child; /* first child for array/object */
|
||||
char *key; /* key for object members */
|
||||
} ha_json_node_t;
|
||||
|
||||
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
|
||||
ha_json_node_t *ha_json_parse(const char *str);
|
||||
|
||||
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
|
||||
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
|
||||
|
||||
/* 从对象中按 key 获取 int 值,不存在返回 def */
|
||||
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
|
||||
|
||||
/* 从对象中按 key 获取子节点,不存在返回 NULL */
|
||||
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
|
||||
|
||||
/* 获取数组长度 */
|
||||
int ha_json_array_len(const ha_json_node_t *arr);
|
||||
|
||||
/* 获取数组第 index 个元素,越界返回 NULL */
|
||||
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
|
||||
|
||||
/* 释放整个 JSON 树 */
|
||||
void ha_json_free(ha_json_node_t *root);
|
||||
|
||||
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
|
||||
typedef struct {
|
||||
char *buf;
|
||||
int len;
|
||||
int cap;
|
||||
int depth;
|
||||
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
|
||||
} ha_json_builder_t;
|
||||
|
||||
/* 初始化构建器 */
|
||||
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
|
||||
|
||||
/* 清空构建器 */
|
||||
void ha_json_builder_reset(ha_json_builder_t *jb);
|
||||
|
||||
/* 基础写入 */
|
||||
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
|
||||
|
||||
/* 逗号(自动判断是否需要加) */
|
||||
void ha_json_builder_comma(ha_json_builder_t *jb);
|
||||
|
||||
/* 对象 */
|
||||
void ha_json_builder_begin_object(ha_json_builder_t *jb);
|
||||
void ha_json_builder_end_object(ha_json_builder_t *jb);
|
||||
|
||||
/* 数组 */
|
||||
void ha_json_builder_begin_array(ha_json_builder_t *jb);
|
||||
void ha_json_builder_end_array(ha_json_builder_t *jb);
|
||||
|
||||
/* 键名 */
|
||||
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
|
||||
|
||||
/* 值 */
|
||||
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
|
||||
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
|
||||
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
|
||||
void ha_json_builder_add_null(ha_json_builder_t *jb);
|
||||
|
||||
/* 快捷方法:直接写 "key":"val" */
|
||||
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
|
||||
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
|
||||
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
|
||||
|
||||
/* 获取当前构建的字符串指针 */
|
||||
const char *ha_json_builder_str(ha_json_builder_t *jb);
|
||||
|
||||
/* 获取当前长度 */
|
||||
int ha_json_builder_len(ha_json_builder_t *jb);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_JSON_H */
|
||||
628
remotedevice/src/ha_remotedevice.c
Normal file
628
remotedevice/src/ha_remotedevice.c
Normal file
@ -0,0 +1,628 @@
|
||||
#include "ha_remotedevice.h"
|
||||
#include "ha_json.h"
|
||||
#include "ha_ws.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define HA_VERSION "0.1.0"
|
||||
|
||||
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
|
||||
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
||||
const char *status, const char *output,
|
||||
const char *error);
|
||||
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
||||
const char *kind, const char *mime,
|
||||
const uint8_t *data, int len);
|
||||
|
||||
/* ======================== 内部状态 ======================== */
|
||||
typedef enum {
|
||||
HA_STATE_INIT,
|
||||
HA_STATE_DISCONNECTED,
|
||||
HA_STATE_CONNECTING,
|
||||
HA_STATE_WS_UPGRADING,
|
||||
HA_STATE_HELLO_SENT,
|
||||
HA_STATE_BIND_SENT,
|
||||
HA_STATE_READY,
|
||||
HA_STATE_STOPPING,
|
||||
} ha_state_t;
|
||||
|
||||
/* 语音数据聚合缓冲区 */
|
||||
typedef struct {
|
||||
char req_id[128];
|
||||
char kind[64];
|
||||
char mime[64];
|
||||
int total;
|
||||
uint8_t *data;
|
||||
int len;
|
||||
int cap;
|
||||
} ha_speech_accum_t;
|
||||
|
||||
struct ha_client {
|
||||
ha_config_t config; /* 拷贝的配置 */
|
||||
ha_state_t state;
|
||||
int reconnect_cnt; /* 当前重连次数 */
|
||||
ha_ws_t ws; /* WS 连接 */
|
||||
|
||||
/* JSON 构建缓冲区 */
|
||||
char json_buf[4096];
|
||||
ha_json_builder_t jb;
|
||||
|
||||
/* 语音数据聚合 */
|
||||
ha_speech_accum_t speech;
|
||||
};
|
||||
|
||||
/* ======================== 辅助函数 ======================== */
|
||||
|
||||
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
|
||||
|
||||
/* Base64 编码表 */
|
||||
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
|
||||
int needed = ((len + 2) / 3) * 4 + 1;
|
||||
if (out_len < needed) {
|
||||
if (out_len > 0) out[0] = '\0';
|
||||
return needed;
|
||||
}
|
||||
int i = 0, j = 0;
|
||||
while (i < len) {
|
||||
int rem = len - i;
|
||||
uint8_t b0 = data[i++];
|
||||
uint8_t b1 = (rem > 1) ? data[i++] : 0;
|
||||
uint8_t b2 = (rem > 2) ? data[i++] : 0;
|
||||
out[j++] = b64[b0 >> 2];
|
||||
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
|
||||
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
|
||||
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
|
||||
}
|
||||
out[j] = '\0';
|
||||
return j;
|
||||
}
|
||||
|
||||
/* ======================== JSON 构建辅助 ======================== */
|
||||
static void json_init(ha_client_t *c) {
|
||||
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
|
||||
}
|
||||
|
||||
/* ======================== WS 发送 JSON ======================== */
|
||||
static int ws_send_json(ha_client_t *c) {
|
||||
return ha_ws_send_text(&c->ws, c->json_buf);
|
||||
}
|
||||
|
||||
/* ======================== 协议消息构造 ======================== */
|
||||
|
||||
/* 构建 hello 消息 */
|
||||
static int send_hello(ha_client_t *c) {
|
||||
json_init(c);
|
||||
ha_json_builder_begin_object(&c->jb);
|
||||
ha_json_builder_string(&c->jb, "op", "hello");
|
||||
ha_json_builder_key(&c->jb, "device");
|
||||
ha_json_builder_begin_object(&c->jb);
|
||||
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
||||
ha_json_builder_string(&c->jb, "name", c->config.device.name);
|
||||
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
|
||||
/* caps */
|
||||
ha_json_builder_key(&c->jb, "caps");
|
||||
ha_json_builder_begin_array(&c->jb);
|
||||
if (c->config.device.caps) {
|
||||
for (const char **p = c->config.device.caps; *p; p++) {
|
||||
ha_json_builder_add_string(&c->jb, *p);
|
||||
}
|
||||
}
|
||||
ha_json_builder_end_array(&c->jb);
|
||||
/* info 可选 */
|
||||
if (c->config.device.info_json && c->config.device.info_json[0]) {
|
||||
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
|
||||
}
|
||||
ha_json_builder_end_object(&c->jb); /* device */
|
||||
ha_json_builder_end_object(&c->jb); /* root */
|
||||
return ws_send_json(c);
|
||||
}
|
||||
|
||||
/* 构建 bind 消息 */
|
||||
static int send_bind(ha_client_t *c) {
|
||||
json_init(c);
|
||||
ha_json_builder_begin_object(&c->jb);
|
||||
ha_json_builder_string(&c->jb, "op", "bind");
|
||||
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
||||
ha_json_builder_string(&c->jb, "token", c->config.token);
|
||||
ha_json_builder_end_object(&c->jb);
|
||||
return ws_send_json(c);
|
||||
}
|
||||
|
||||
/* ======================== 消息处理 ======================== */
|
||||
|
||||
/* 在 handlers 表中查找命令处理函数 */
|
||||
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
|
||||
if (!name || !c->config.handlers) return NULL;
|
||||
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
|
||||
if (strcmp(h->command, name) == 0) return h;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
|
||||
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
|
||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||
const char *command = ha_json_get_string(msg, "command");
|
||||
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
|
||||
if (!req_id || !command) return;
|
||||
if (!cmd_type) cmd_type = "homeagent";
|
||||
|
||||
const char *handler_name = NULL;
|
||||
const char *args = command;
|
||||
|
||||
if (strcmp(cmd_type, "shell") == 0) {
|
||||
handler_name = "shell";
|
||||
/* args 保持为完整命令字符串 */
|
||||
} else {
|
||||
/* homeagent-* 命令:提取能力名作为 handler 名 */
|
||||
const char *cap = command;
|
||||
const char *p = command;
|
||||
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
|
||||
const char *space = strchr(p, ' ');
|
||||
if (space) {
|
||||
args = space + 1;
|
||||
/* handler_name 用静态缓冲区 */
|
||||
static char name_buf[128];
|
||||
int n = (int)(space - p);
|
||||
if (n > 127) n = 127;
|
||||
strncpy(name_buf, p, n);
|
||||
name_buf[n] = '\0';
|
||||
handler_name = name_buf;
|
||||
} else {
|
||||
handler_name = p;
|
||||
args = "";
|
||||
}
|
||||
}
|
||||
|
||||
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
|
||||
if (!def) {
|
||||
ha_client_send_result(c, req_id, "error", NULL,
|
||||
"unsupported command");
|
||||
return;
|
||||
}
|
||||
|
||||
/* 调用 handler,填写 result */
|
||||
ha_cmd_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
|
||||
|
||||
/* 自动回执 */
|
||||
if (st != HA_OK) {
|
||||
ha_client_send_result(c, req_id, "error", NULL,
|
||||
result.error ? result.error : "handler failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.has_binary && result.binary_data && result.binary_len > 0) {
|
||||
/* 二进制分块回传 */
|
||||
ha_client_send_data_chunked(c, req_id,
|
||||
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
|
||||
result.binary_data, result.binary_len);
|
||||
} else {
|
||||
/* 文本回传 */
|
||||
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
|
||||
result.output, result.error);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
|
||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||
const char *kind = ha_json_get_string(msg, "kind");
|
||||
const char *mime = ha_json_get_string(msg, "mime");
|
||||
if (!req_id) return;
|
||||
|
||||
/* 释放旧的聚合数据 */
|
||||
free(c->speech.data);
|
||||
memset(&c->speech, 0, sizeof(c->speech));
|
||||
|
||||
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
|
||||
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
|
||||
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
|
||||
c->speech.total = ha_json_get_int(msg, "total", 0);
|
||||
}
|
||||
|
||||
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
|
||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
|
||||
|
||||
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
|
||||
c->config.on_binary(c->speech.req_id, c->speech.kind,
|
||||
c->speech.mime, c->speech.data,
|
||||
c->speech.len, c->config.userdata);
|
||||
}
|
||||
|
||||
free(c->speech.data);
|
||||
memset(&c->speech, 0, sizeof(c->speech));
|
||||
}
|
||||
|
||||
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
|
||||
/* 解析 JSON */
|
||||
char *tmp = (char *)malloc(len + 1);
|
||||
if (!tmp) return;
|
||||
memcpy(tmp, payload, len);
|
||||
tmp[len] = '\0';
|
||||
|
||||
ha_json_node_t *root = ha_json_parse(tmp);
|
||||
if (!root) { free(tmp); return; }
|
||||
|
||||
const char *op = ha_json_get_string(root, "op");
|
||||
if (!op) { ha_json_free(root); free(tmp); return; }
|
||||
|
||||
switch (c->state) {
|
||||
case HA_STATE_HELLO_SENT:
|
||||
if (strcmp(op, "hello_ack") == 0) {
|
||||
c->state = HA_STATE_BIND_SENT;
|
||||
send_bind(c);
|
||||
}
|
||||
break;
|
||||
case HA_STATE_BIND_SENT:
|
||||
if (strcmp(op, "bind_ack") == 0) {
|
||||
c->state = HA_STATE_READY;
|
||||
if (c->config.on_state) {
|
||||
c->config.on_state(1, c->config.userdata);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case HA_STATE_READY:
|
||||
if (strcmp(op, "cmd") == 0) {
|
||||
handle_cmd_msg(c, root);
|
||||
} else if (strcmp(op, "cmd_speech_start") == 0) {
|
||||
handle_speech_start(c, root);
|
||||
} else if (strcmp(op, "cmd_speech_end") == 0) {
|
||||
handle_speech_end(c, root);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
ha_json_free(root);
|
||||
free(tmp);
|
||||
}
|
||||
|
||||
/* ======================== 连接管理 ======================== */
|
||||
|
||||
static int do_connect(ha_client_t *c) {
|
||||
c->state = HA_STATE_CONNECTING;
|
||||
c->reconnect_cnt++;
|
||||
|
||||
/* 解析 server 地址 */
|
||||
char host[256] = {0};
|
||||
uint16_t port = 9890;
|
||||
const char *p = c->config.server;
|
||||
if (!p) return -1;
|
||||
|
||||
/* 去掉 ws:// 前缀 */
|
||||
if (strncmp(p, "ws://", 5) == 0) p += 5;
|
||||
else if (strncmp(p, "wss://", 6) == 0) p += 6;
|
||||
|
||||
/* 提取 host:port */
|
||||
const char *colon = strchr(p, ':');
|
||||
const char *slash = strchr(p, '/');
|
||||
if (colon && (!slash || colon < slash)) {
|
||||
int host_len = (int)(colon - p);
|
||||
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
||||
memcpy(host, p, host_len);
|
||||
host[host_len] = '\0';
|
||||
port = (uint16_t)atoi(colon + 1);
|
||||
} else {
|
||||
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
|
||||
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
||||
memcpy(host, p, host_len);
|
||||
host[host_len] = '\0';
|
||||
}
|
||||
|
||||
c->state = HA_STATE_WS_UPGRADING;
|
||||
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
|
||||
"/api/v1/device/ws", c->config.token) != 0) {
|
||||
c->state = HA_STATE_DISCONNECTED;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 发送 hello */
|
||||
c->state = HA_STATE_HELLO_SENT;
|
||||
if (send_hello(c) != 0) {
|
||||
ha_ws_close(&c->ws);
|
||||
c->state = HA_STATE_DISCONNECTED;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ======================== 公共 API ======================== */
|
||||
|
||||
ha_client_t *ha_client_new(const ha_config_t *config) {
|
||||
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
|
||||
if (!c) return NULL;
|
||||
memcpy(&c->config, config, sizeof(ha_config_t));
|
||||
c->state = HA_STATE_INIT;
|
||||
c->reconnect_cnt = 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
ha_status_t ha_client_start(ha_client_t *client) {
|
||||
if (!client) return HA_ERR_INVALID;
|
||||
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
|
||||
|
||||
/* 默认心跳间隔 30 秒 */
|
||||
if (client->config.ping_interval <= 0) {
|
||||
client->config.ping_interval = 30;
|
||||
}
|
||||
|
||||
if (do_connect(client) != 0) {
|
||||
return HA_ERR_TRANSPORT;
|
||||
}
|
||||
|
||||
/* 等待 bind_ack(最多 5 秒) */
|
||||
int wait_ms = 5000;
|
||||
int step = 50;
|
||||
while (wait_ms > 0 && client->state != HA_STATE_READY) {
|
||||
/* 处理一帧 */
|
||||
ha_status_t st = ha_client_process(client);
|
||||
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
|
||||
return st;
|
||||
}
|
||||
if (client->state == HA_STATE_READY) return HA_OK;
|
||||
|
||||
/* 简单延时:靠 process 中的 recv 阻塞 */
|
||||
wait_ms -= step;
|
||||
}
|
||||
|
||||
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
ha_status_t ha_client_process(ha_client_t *client) {
|
||||
if (!client) return HA_ERR_INVALID;
|
||||
|
||||
if (client->state == HA_STATE_STOPPING) {
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
|
||||
/* 断线重连 */
|
||||
if (client->state == HA_STATE_DISCONNECTED ||
|
||||
client->state == HA_STATE_INIT) {
|
||||
if (client->config.max_reconnect >= 0 &&
|
||||
client->reconnect_cnt > client->config.max_reconnect) {
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
|
||||
if (!client->ws.connected) {
|
||||
client->state = HA_STATE_DISCONNECTED;
|
||||
if (client->config.on_state) {
|
||||
client->config.on_state(0, client->config.userdata);
|
||||
}
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
|
||||
/* 尝试读取一帧 */
|
||||
const uint8_t *payload = NULL;
|
||||
int len = 0;
|
||||
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
|
||||
|
||||
if (ret < 0) {
|
||||
/* 连接断开 */
|
||||
client->state = HA_STATE_DISCONNECTED;
|
||||
if (client->config.on_state) {
|
||||
client->config.on_state(0, client->config.userdata);
|
||||
}
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
|
||||
switch (ret) {
|
||||
case WS_OPCODE_TEXT:
|
||||
handle_text_message(client, payload, len);
|
||||
break;
|
||||
case WS_OPCODE_BINARY:
|
||||
/* 二进制帧:如果处于语音聚合状态,追加数据 */
|
||||
if (client->speech.req_id[0] && payload) {
|
||||
int new_len = client->speech.len + len;
|
||||
if (new_len > client->speech.cap) {
|
||||
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
|
||||
while (new_cap < new_len) new_cap *= 2;
|
||||
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
|
||||
if (!nd) break;
|
||||
client->speech.data = nd;
|
||||
client->speech.cap = new_cap;
|
||||
}
|
||||
memcpy(client->speech.data + client->speech.len, payload, len);
|
||||
client->speech.len = new_len;
|
||||
}
|
||||
break;
|
||||
case WS_OPCODE_PING:
|
||||
/* 回复 pong */
|
||||
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
|
||||
break;
|
||||
case WS_OPCODE_PONG:
|
||||
/* 收到 pong,忽略 */
|
||||
break;
|
||||
case WS_OPCODE_CLOSE:
|
||||
client->state = HA_STATE_DISCONNECTED;
|
||||
if (client->config.on_state) {
|
||||
client->config.on_state(0, client->config.userdata);
|
||||
}
|
||||
return HA_ERR_DISCONNECTED;
|
||||
}
|
||||
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
||||
const char *status, const char *output,
|
||||
const char *error) {
|
||||
if (!client || client->state != HA_STATE_READY) return;
|
||||
json_init(client);
|
||||
ha_json_builder_begin_object(&client->jb);
|
||||
ha_json_builder_string(&client->jb, "op", "cmd_result");
|
||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
|
||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||
if (output && output[0]) {
|
||||
ha_json_builder_string(&client->jb, "output", output);
|
||||
}
|
||||
if (error && error[0]) {
|
||||
ha_json_builder_string(&client->jb, "error", error);
|
||||
}
|
||||
ha_json_builder_end_object(&client->jb);
|
||||
ws_send_json(client);
|
||||
}
|
||||
|
||||
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
||||
const char *kind, const char *mime,
|
||||
const uint8_t *data, int len) {
|
||||
if (!client || client->state != HA_STATE_READY) return;
|
||||
|
||||
/* cmd_data_start */
|
||||
json_init(client);
|
||||
ha_json_builder_begin_object(&client->jb);
|
||||
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
|
||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
|
||||
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
|
||||
ha_json_builder_int(&client->jb, "total", len);
|
||||
ha_json_builder_int(&client->jb, "chunk_size", 8192);
|
||||
ha_json_builder_end_object(&client->jb);
|
||||
ws_send_json(client);
|
||||
|
||||
/* 二进制帧分块发送 */
|
||||
int off = 0;
|
||||
while (off < len) {
|
||||
int chunk = len - off;
|
||||
if (chunk > 8192) chunk = 8192;
|
||||
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
|
||||
off += chunk;
|
||||
}
|
||||
|
||||
/* cmd_data_end */
|
||||
json_init(client);
|
||||
ha_json_builder_begin_object(&client->jb);
|
||||
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
|
||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||
ha_json_builder_string(&client->jb, "status", "ok");
|
||||
ha_json_builder_int(&client->jb, "total", len);
|
||||
ha_json_builder_end_object(&client->jb);
|
||||
ws_send_json(client);
|
||||
}
|
||||
|
||||
void ha_client_send_event(ha_client_t *client, const char *type,
|
||||
const char *detail) {
|
||||
if (!client || client->state != HA_STATE_READY) return;
|
||||
json_init(client);
|
||||
ha_json_builder_begin_object(&client->jb);
|
||||
ha_json_builder_string(&client->jb, "op", "event");
|
||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||
ha_json_builder_string(&client->jb, "type", type ? type : "");
|
||||
if (detail && detail[0]) {
|
||||
ha_json_builder_string(&client->jb, "payload", detail);
|
||||
}
|
||||
ha_json_builder_end_object(&client->jb);
|
||||
ws_send_json(client);
|
||||
}
|
||||
|
||||
void ha_client_send_status(ha_client_t *client, const char *status) {
|
||||
if (!client || client->state != HA_STATE_READY) return;
|
||||
json_init(client);
|
||||
ha_json_builder_begin_object(&client->jb);
|
||||
ha_json_builder_string(&client->jb, "op", "status");
|
||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||
ha_json_builder_string(&client->jb, "status", status ? status : "online");
|
||||
ha_json_builder_end_object(&client->jb);
|
||||
ws_send_json(client);
|
||||
}
|
||||
|
||||
void ha_client_stop(ha_client_t *client) {
|
||||
if (!client) return;
|
||||
client->state = HA_STATE_STOPPING;
|
||||
if (client->ws.connected) {
|
||||
ha_ws_close(&client->ws);
|
||||
}
|
||||
}
|
||||
|
||||
void ha_client_destroy(ha_client_t *client) {
|
||||
if (!client) return;
|
||||
ha_client_stop(client);
|
||||
free(client->speech.data);
|
||||
free(client);
|
||||
}
|
||||
|
||||
/* ======================== 工具函数 ======================== */
|
||||
|
||||
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
||||
const char **args) {
|
||||
*cap = command;
|
||||
*args = "";
|
||||
|
||||
if (!command) {
|
||||
*cap = "";
|
||||
return;
|
||||
}
|
||||
|
||||
/* 去掉 homeagent- 前缀 */
|
||||
const char *p = command;
|
||||
if (strncmp(p, "homeagent-", 10) == 0) {
|
||||
p += 10;
|
||||
}
|
||||
|
||||
/* 按空格分割 */
|
||||
const char *space = strchr(p, ' ');
|
||||
if (space) {
|
||||
/* cap 指向 p 但不包含空格,需要临时拷贝 */
|
||||
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
|
||||
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
|
||||
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
|
||||
/* 用静态缓冲区或让调用方自己处理 */
|
||||
static char cap_buf[256];
|
||||
int n = (int)(space - p);
|
||||
if (n > 255) n = 255;
|
||||
strncpy(cap_buf, p, n);
|
||||
cap_buf[n] = '\0';
|
||||
*cap = cap_buf;
|
||||
*args = space + 1;
|
||||
} else {
|
||||
static char cap_buf[256];
|
||||
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
|
||||
cap_buf[sizeof(cap_buf) - 1] = '\0';
|
||||
*cap = cap_buf;
|
||||
*args = "";
|
||||
}
|
||||
}
|
||||
|
||||
void ha_cmd_parse_json(const char *command, const char **action,
|
||||
const char **json_str) {
|
||||
*action = "";
|
||||
*json_str = "";
|
||||
|
||||
if (!command) return;
|
||||
|
||||
const char *p = command;
|
||||
if (strncmp(p, "homeagent-", 10) == 0) {
|
||||
p += 10;
|
||||
}
|
||||
|
||||
const char *brace = strchr(p, '{');
|
||||
if (brace) {
|
||||
static char act_buf[256];
|
||||
int n = (int)(brace - p);
|
||||
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
|
||||
if (n > 255) n = 255;
|
||||
strncpy(act_buf, p, n);
|
||||
act_buf[n] = '\0';
|
||||
*action = act_buf;
|
||||
*json_str = brace;
|
||||
} else {
|
||||
static char act_buf[256];
|
||||
strncpy(act_buf, p, sizeof(act_buf) - 1);
|
||||
*action = act_buf;
|
||||
}
|
||||
}
|
||||
|
||||
const char *ha_version(void) {
|
||||
return HA_VERSION;
|
||||
}
|
||||
|
||||
325
remotedevice/src/ha_ws.c
Normal file
325
remotedevice/src/ha_ws.c
Normal file
@ -0,0 +1,325 @@
|
||||
#include "ha_ws.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* WS GUID 用于计算 Accept 值 */
|
||||
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
/* ======================== Base64 编码(用于 WS key) ======================== */
|
||||
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
|
||||
int i = 0, j = 0;
|
||||
uint8_t b[3];
|
||||
while (i < in_len) {
|
||||
int rem = in_len - i;
|
||||
if (rem >= 3) {
|
||||
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
|
||||
out[j++] = b64t[b[0] >> 2];
|
||||
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
||||
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
|
||||
out[j++] = b64t[b[2] & 0x3F];
|
||||
} else if (rem == 2) {
|
||||
b[0] = in[i++]; b[1] = in[i++];
|
||||
out[j++] = b64t[b[0] >> 2];
|
||||
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
||||
out[j++] = b64t[(b[1] & 0x0F) << 2];
|
||||
out[j++] = '=';
|
||||
} else {
|
||||
b[0] = in[i++];
|
||||
out[j++] = b64t[b[0] >> 2];
|
||||
out[j++] = b64t[(b[0] & 0x03) << 4];
|
||||
out[j++] = '=';
|
||||
out[j++] = '=';
|
||||
}
|
||||
}
|
||||
out[j] = '\0';
|
||||
}
|
||||
|
||||
/* 简单伪随机数生成器 */
|
||||
static uint32_t ws_rand_state = 0;
|
||||
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
|
||||
static uint32_t ws_rand(void) {
|
||||
ws_rand_state = ws_rand_state * 1103515245 + 12345;
|
||||
return ws_rand_state;
|
||||
}
|
||||
|
||||
/* 生成 WS 握手 key */
|
||||
static void ws_gen_key(char *out) {
|
||||
uint8_t buf[16];
|
||||
for (int i = 0; i < 16; i++) {
|
||||
buf[i] = (uint8_t)(ws_rand() & 0xFF);
|
||||
}
|
||||
base64_encode_bin(buf, 16, out);
|
||||
}
|
||||
|
||||
/* ======================== 从传输层接收指定字节数 ======================== */
|
||||
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
|
||||
int pos = 0;
|
||||
while (pos < len) {
|
||||
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
|
||||
if (n <= 0) return -1;
|
||||
pos += n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ======================== 发送 WS 帧 ======================== */
|
||||
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
|
||||
uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */
|
||||
int hdr_len = 0;
|
||||
|
||||
hdr[0] = 0x80 | opcode; /* FIN + opcode */
|
||||
hdr_len = 2;
|
||||
|
||||
int ext_len = 0;
|
||||
if (len < 126) {
|
||||
hdr[1] = 0x80 | len; /* mask bit + length */
|
||||
} else if (len < 65536) {
|
||||
hdr[1] = 0x80 | 126;
|
||||
hdr_len = 4;
|
||||
hdr[2] = (uint8_t)(len >> 8);
|
||||
hdr[3] = (uint8_t)(len & 0xFF);
|
||||
ext_len = 2;
|
||||
} else {
|
||||
hdr[1] = 0x80 | 127;
|
||||
hdr_len = 10;
|
||||
uint64_t l = (uint64_t)len;
|
||||
for (int i = 8; i > 0; i--) {
|
||||
hdr[1 + i] = (uint8_t)(l & 0xFF);
|
||||
l >>= 8;
|
||||
}
|
||||
ext_len = 8;
|
||||
}
|
||||
|
||||
/* mask key */
|
||||
uint8_t mask_key[4];
|
||||
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
|
||||
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
|
||||
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
|
||||
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
|
||||
|
||||
int mask_off = 2 + ext_len;
|
||||
hdr[mask_off] = mask_key[0];
|
||||
hdr[mask_off + 1] = mask_key[1];
|
||||
hdr[mask_off + 2] = mask_key[2];
|
||||
hdr[mask_off + 3] = mask_key[3];
|
||||
hdr_len = mask_off + 4;
|
||||
|
||||
/* 发送帧头 */
|
||||
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 发送掩码后的 payload */
|
||||
if (len > 0) {
|
||||
/* 如果 payload 不大,用栈缓冲区 */
|
||||
uint8_t stack_buf[2048];
|
||||
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
|
||||
if (!masked) return -1;
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
masked[i] = payload[i] ^ mask_key[i & 3];
|
||||
}
|
||||
|
||||
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
|
||||
|
||||
if (masked != stack_buf) free(masked);
|
||||
if (ret != 0) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ======================== 公共 API ======================== */
|
||||
|
||||
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
||||
const char *host, uint16_t port,
|
||||
const char *path, const char *token) {
|
||||
memset(ws, 0, sizeof(ha_ws_t));
|
||||
ws->transport = transport;
|
||||
ws->connected = 0;
|
||||
|
||||
strncpy(ws->host, host, sizeof(ws->host) - 1);
|
||||
ws->port = port;
|
||||
strncpy(ws->path, path, sizeof(ws->path) - 1);
|
||||
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
|
||||
|
||||
/* 种子 */
|
||||
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
|
||||
|
||||
/* 1. TCP 连接 */
|
||||
if (transport->connect(transport->ctx, host, port) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 2. 发送 WS 升级请求 */
|
||||
char key[32];
|
||||
ws_gen_key(key);
|
||||
|
||||
char req[1024];
|
||||
int n = snprintf(req, sizeof(req),
|
||||
"GET %s HTTP/1.1\r\n"
|
||||
"Host: %s:%u\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: %s\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n",
|
||||
path, host, (unsigned)port, key);
|
||||
|
||||
/* 如果 token 存在,加到路径参数中 */
|
||||
if (token && token[0]) {
|
||||
n = snprintf(req, sizeof(req),
|
||||
"GET %s?token=%s HTTP/1.1\r\n"
|
||||
"Host: %s:%u\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: %s\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n",
|
||||
path, token, host, (unsigned)port, key);
|
||||
}
|
||||
|
||||
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
|
||||
transport->close(transport->ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 3. 读取响应头(直到 \r\n\r\n) */
|
||||
char resp[1024];
|
||||
int resp_len = 0;
|
||||
int found = 0;
|
||||
while (resp_len < (int)sizeof(resp) - 1) {
|
||||
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
|
||||
if (n <= 0) {
|
||||
transport->close(transport->ctx);
|
||||
return -1;
|
||||
}
|
||||
resp_len += n;
|
||||
resp[resp_len] = '\0';
|
||||
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
transport->close(transport->ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 4. 检查状态码 101 */
|
||||
if (strstr(resp, " 101 ") == NULL) {
|
||||
transport->close(transport->ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
ws->connected = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
|
||||
if (!ws->connected) return -1;
|
||||
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
|
||||
}
|
||||
|
||||
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
|
||||
if (!ws->connected) return -1;
|
||||
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
|
||||
}
|
||||
|
||||
int ha_ws_send_ping(ha_ws_t *ws) {
|
||||
if (!ws->connected) return -1;
|
||||
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
|
||||
}
|
||||
|
||||
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
|
||||
if (!ws->connected) return -1;
|
||||
|
||||
*payload = NULL;
|
||||
*len = 0;
|
||||
|
||||
/* 读取帧头:2 字节 */
|
||||
uint8_t hdr[2];
|
||||
if (recv_all(ws, hdr, 2) != 0) {
|
||||
ws->connected = 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int opcode = hdr[0] & 0x0F;
|
||||
int masked = (hdr[1] & 0x80) ? 1 : 0;
|
||||
uint64_t frame_len = hdr[1] & 0x7F;
|
||||
|
||||
if (frame_len == 126) {
|
||||
uint8_t ext[2];
|
||||
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
|
||||
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
|
||||
} else if (frame_len == 127) {
|
||||
uint8_t ext[8];
|
||||
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
|
||||
frame_len = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
frame_len = (frame_len << 8) | ext[i];
|
||||
}
|
||||
}
|
||||
|
||||
/* 读取 mask key */
|
||||
uint8_t mask_key[4] = {0, 0, 0, 0};
|
||||
if (masked) {
|
||||
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
|
||||
}
|
||||
|
||||
/* 限制帧大小 */
|
||||
if (frame_len > sizeof(ws->read_buf)) {
|
||||
/* 帧太大,跳过 payload */
|
||||
uint64_t skip = frame_len;
|
||||
uint8_t tmp[256];
|
||||
while (skip > 0) {
|
||||
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
|
||||
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
|
||||
skip -= to_skip;
|
||||
}
|
||||
return -1; /* 返回错误,帧太大 */
|
||||
}
|
||||
|
||||
/* 读取 payload */
|
||||
if (frame_len > 0) {
|
||||
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
|
||||
ws->connected = 0;
|
||||
return -1;
|
||||
}
|
||||
/* 如果有 mask,解掩码 */
|
||||
if (masked) {
|
||||
for (uint64_t i = 0; i < frame_len; i++) {
|
||||
ws->read_buf[i] ^= mask_key[i & 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*payload = ws->read_buf;
|
||||
*len = (int)frame_len;
|
||||
|
||||
switch (opcode) {
|
||||
case WS_OPCODE_CLOSE:
|
||||
ws->connected = 0;
|
||||
return WS_OPCODE_CLOSE;
|
||||
case WS_OPCODE_PING:
|
||||
return WS_OPCODE_PING;
|
||||
case WS_OPCODE_PONG:
|
||||
return WS_OPCODE_PONG;
|
||||
case WS_OPCODE_TEXT:
|
||||
case WS_OPCODE_BINARY:
|
||||
return opcode;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
void ha_ws_close(ha_ws_t *ws) {
|
||||
if (ws->connected) {
|
||||
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
|
||||
ws->connected = 0;
|
||||
}
|
||||
ws->transport->close(ws->transport->ctx);
|
||||
}
|
||||
62
remotedevice/src/ha_ws.h
Normal file
62
remotedevice/src/ha_ws.h
Normal file
@ -0,0 +1,62 @@
|
||||
#ifndef HA_WS_H
|
||||
#define HA_WS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "../include/ha_remotedevice.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ======================== WS 帧类型 ======================== */
|
||||
#define WS_OPCODE_CONTINUATION 0x0
|
||||
#define WS_OPCODE_TEXT 0x1
|
||||
#define WS_OPCODE_BINARY 0x2
|
||||
#define WS_OPCODE_CLOSE 0x8
|
||||
#define WS_OPCODE_PING 0x9
|
||||
#define WS_OPCODE_PONG 0xA
|
||||
|
||||
/* ======================== WS 连接 ======================== */
|
||||
typedef struct {
|
||||
ha_transport_t *transport; /* 用户实现的传输层 */
|
||||
int connected; /* 是否已连接 */
|
||||
uint8_t read_buf[8192]; /* 读缓冲区 */
|
||||
int read_pos; /* 缓冲区中有效数据起始位置 */
|
||||
int read_len; /* 缓冲区中有效数据长度 */
|
||||
char host[256]; /* 缓存目标地址 */
|
||||
uint16_t port;
|
||||
char path[256];
|
||||
char token[256];
|
||||
} ha_ws_t;
|
||||
|
||||
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
|
||||
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
||||
const char *host, uint16_t port,
|
||||
const char *path, const char *token);
|
||||
|
||||
/* 发送文本帧。返回 0 成功。 */
|
||||
int ha_ws_send_text(ha_ws_t *ws, const char *text);
|
||||
|
||||
/* 发送二进制帧。返回 0 成功。 */
|
||||
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
|
||||
|
||||
/* 发送 ping。返回 0 成功。 */
|
||||
int ha_ws_send_ping(ha_ws_t *ws);
|
||||
|
||||
/* 读取一帧。
|
||||
* 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。
|
||||
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
|
||||
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
|
||||
|
||||
/* 发送原始 WS 帧(内部使用,用于回复 ping) */
|
||||
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
|
||||
|
||||
/* 关闭 WS 连接 */
|
||||
void ha_ws_close(ha_ws_t *ws);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_WS_H */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user