mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 01:18:02 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 140cd34b56 | |||
| b237787c90 | |||
| 69ff3089a4 | |||
| e839eb8220 | |||
| d893bfa76f | |||
| 93ab794a82 | |||
| 12cabcb290 | |||
| 44bd915fbf | |||
| ba49dfda44 | |||
| b2eafdf885 | |||
| 8c397ecf65 | |||
| 632f6743d3 | |||
| 9d930db4ea | |||
| 0a164fe4b9 | |||
| fd5a291df1 | |||
| 5175e7d6e0 | |||
| 71e3325439 | |||
| 18fec9b003 | |||
| fc236120e3 | |||
| a66739e59b |
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/>.
|
||||||
146
README.md
146
README.md
@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
## 版本与兼容性
|
||||||
|
|
||||||
|
当前:**SDK 1.2.0**(需内核 **1.2.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.0 |
|
||||||
|
|
||||||
|
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||||
|
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||||
|
|
||||||
|
**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.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 接口
|
## SDK API 接口
|
||||||
|
|
||||||
### Plugin 接口
|
### Plugin 接口
|
||||||
@ -220,18 +284,23 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
|
|
||||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
||||||
|
|
||||||
## plugindev 工具链
|
## hmapdev 工具链
|
||||||
|
|
||||||
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||||
|
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
||||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
||||||
|
|
||||||
|
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||||
|
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 从 release 附件下载(以 v1.0.0 / linux amd64 为例)
|
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
||||||
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||||
chmod +x plugindev
|
chmod +x hmapdev
|
||||||
|
|
||||||
# 或从源码自己编
|
# 或从源码自己编
|
||||||
cd tools/plugindev && go build -o plugindev .
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
```
|
```
|
||||||
|
|
||||||
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
||||||
@ -239,11 +308,11 @@ cd tools/plugindev && go build -o plugindev .
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||||
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||||
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||||
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||||
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||||
|
|
||||||
支持 **Go** 和 **Lua** 两种插件语言。
|
支持 **Go** 和 **Lua** 两种插件语言。
|
||||||
|
|
||||||
@ -350,7 +419,7 @@ return plugin
|
|||||||
|
|
||||||
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
||||||
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
||||||
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
|
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOnRemoveHandler(func() {
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
@ -368,6 +437,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 vs 完整 SDK
|
||||||
|
|
||||||
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||||
@ -399,6 +493,11 @@ enabled := sdk.AutoRestart()
|
|||||||
| [rss](example/rss) | Go | RSS 订阅 |
|
| [rss](example/rss) | Go | RSS 订阅 |
|
||||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||||
|
|
||||||
|
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
||||||
|
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**(`ProtocolVersion`
|
||||||
|
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
|
||||||
|
「插件坏了」而不是「版本不配套」。
|
||||||
|
|
||||||
## Remote Device SDK
|
## Remote Device SDK
|
||||||
|
|
||||||
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
||||||
@ -500,10 +599,10 @@ ha_transport_t my_transport = {
|
|||||||
|
|
||||||
### 使用方式
|
### 使用方式
|
||||||
|
|
||||||
通过 `plugindev` 工具链初始化项目:
|
通过 `hmapdev` 工具链初始化项目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev init my-adapter --type remotedevice
|
hmapdev init my-adapter --type remotedevice
|
||||||
```
|
```
|
||||||
|
|
||||||
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
||||||
@ -715,17 +814,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
|||||||
### 位置
|
### 位置
|
||||||
|
|
||||||
- **SDK 源码**: `remotedevice/`
|
- **SDK 源码**: `remotedevice/`
|
||||||
- **plugindev 模板**: `plugindev init --type remotedevice`
|
- **hmapdev 模板**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## 构建与安装
|
## 构建与安装
|
||||||
|
|
||||||
### 构建
|
### 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
|
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
|
|
||||||
@ -743,3 +842,16 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
```
|
```
|
||||||
|
|
||||||
或通过 WebUI 插件管理页面上传,也可手动将 `.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/`。
|
||||||
|
|||||||
166
README_EN.md
166
README_EN.md
@ -2,6 +2,83 @@
|
|||||||
|
|
||||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
## Version and Compatibility
|
||||||
|
|
||||||
|
Current: **SDK 1.2.0** (requires kernel **1.2.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 onward | 1.2.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.
|
||||||
|
|
||||||
|
**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
|
## SDK API Surface
|
||||||
|
|
||||||
### Plugin Interface
|
### Plugin Interface
|
||||||
@ -226,19 +303,24 @@ 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.
|
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. Prebuilt binaries ship as **release assets**
|
`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
|
(linux/darwin/windows × amd64/arm64); download from
|
||||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
|
[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
|
```bash
|
||||||
# From release assets (v1.0.0 / linux amd64 shown)
|
# From release assets (latest SDK release / linux amd64 shown)
|
||||||
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
||||||
chmod +x plugindev
|
chmod +x hmapdev
|
||||||
|
|
||||||
# Or build from source
|
# Or build from source
|
||||||
cd tools/plugindev && go build -o plugindev .
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
```
|
```
|
||||||
|
|
||||||
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
||||||
@ -247,11 +329,11 @@ cd tools/plugindev && go build -o plugindev .
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||||||
| `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||||||
| `plugindev clean` | Clean `build/` and `dist/` plus generated files |
|
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
|
||||||
| `plugindev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
||||||
| `plugindev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
||||||
|
|
||||||
Supports both **Go** and **Lua** plugin languages.
|
Supports both **Go** and **Lua** plugin languages.
|
||||||
|
|
||||||
@ -323,7 +405,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
|
|
||||||
- `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).
|
- `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.
|
- 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 `plugindev` template includes an onRemove demo.
|
- 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
|
```go
|
||||||
sdk.RegisterOnRemoveHandler(func() {
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
@ -341,6 +423,33 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
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
|
## Restricted SDK vs Full SDK
|
||||||
|
|
||||||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||||
@ -372,6 +481,13 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
|||||||
| [rss](example/rss) | Go | RSS subscriptions |
|
| [rss](example/rss) | Go | RSS subscriptions |
|
||||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
| [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
|
## Remote Device SDK
|
||||||
|
|
||||||
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||||||
@ -456,10 +572,10 @@ ha_transport_t my_transport = {
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
Initialize a project via the `plugindev` toolchain:
|
Initialize a project via the `hmapdev` toolchain:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev init my-adapter --type remotedevice
|
hmapdev init my-adapter --type remotedevice
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||||||
@ -669,17 +785,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
|||||||
### Location
|
### Location
|
||||||
|
|
||||||
- **SDK Source**: `remotedevice/`
|
- **SDK Source**: `remotedevice/`
|
||||||
- **plugindev template**: `plugindev init --type remotedevice`
|
- **hmapdev template**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## Building & Installing
|
## Building & Installing
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
|
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
|
### Install
|
||||||
|
|
||||||
@ -697,3 +813,19 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or upload via the WebUI plugin management page, 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/`.
|
||||||
|
|||||||
@ -5,7 +5,7 @@ ai_image plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -5,7 +5,7 @@ calendar plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -520,7 +520,8 @@ func (p *Plugin) checkReminders() {
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
for _, msg := range injectMsgs {
|
for _, msg := range injectMsgs {
|
||||||
p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
// NoMemory:日程到点提醒,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ lua main.lua # 使用 sdk.lua mock,不依赖内核
|
|||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|||||||
@ -292,8 +292,9 @@ func (p *Plugin) periodicCheck() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
p.sdk.InjectInterruptText(p.name, p.name,
|
// NoMemory:这是定时提醒,不是记忆内容。
|
||||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -85,6 +85,34 @@ type DownloadTask struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type qqAuthContext struct {
|
||||||
|
active bool
|
||||||
|
owner bool
|
||||||
|
messageID int64
|
||||||
|
userID int64
|
||||||
|
groupID int64
|
||||||
|
isGroup bool
|
||||||
|
generation uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`)
|
||||||
|
|
||||||
|
// 默认只开放公共信息与当前 QQ 会话所需能力。日历、邮件、记忆、知识库、
|
||||||
|
// 主机文件/命令、设备、配置、插件管理及 QQ 联系人/跨会话列表均不在白名单中。
|
||||||
|
const defaultPublicToolAllowlist = `["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]`
|
||||||
|
|
||||||
|
const defaultGroupToolAllowlists = `{"*":["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]}`
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 单轮 QQ 触发的工具调用总数上限(0 = 不限制)。只作跑飞兜底,
|
||||||
|
// 不应拦下正常的长时间多步任务。
|
||||||
|
defaultMaxQQToolCalls = 200
|
||||||
|
// 单轮 QQ 主动发送的不同消息条数上限(0 = 不限制)。
|
||||||
|
defaultMaxQQOutputCalls = 20
|
||||||
|
// 单轮内同一条消息(参数完全相同)允许重复发送的次数(0 = 不限制)。
|
||||||
|
defaultMaxDuplicateSend = 1
|
||||||
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
@ -93,7 +121,7 @@ type Plugin struct {
|
|||||||
remoteDir string
|
remoteDir string
|
||||||
filesDir string
|
filesDir string
|
||||||
webhookToken string
|
webhookToken string
|
||||||
adminIDs []int64
|
ownerIDs []int64
|
||||||
botID int64
|
botID int64
|
||||||
botNickname string
|
botNickname string
|
||||||
dmPolicy string
|
dmPolicy string
|
||||||
@ -108,12 +136,26 @@ type Plugin struct {
|
|||||||
typingMu sync.Mutex
|
typingMu sync.Mutex
|
||||||
typingMap map[int64]*typingState
|
typingMap map[int64]*typingState
|
||||||
|
|
||||||
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats)
|
authMu sync.RWMutex
|
||||||
msgMu sync.Mutex
|
auth qqAuthContext
|
||||||
msgMap map[int64]msgRef // message_id → {peer, time}
|
authGeneration uint64
|
||||||
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
|
authByMessageID map[int64]qqAuthContext
|
||||||
}
|
lastDenial string
|
||||||
|
denialLocked bool
|
||||||
|
toolCallCount int
|
||||||
|
outputCallCount int
|
||||||
|
outputSignatures map[string]int
|
||||||
|
maxQQToolCalls int
|
||||||
|
maxQQOutputCalls int
|
||||||
|
maxDuplicateSend int
|
||||||
|
groupToolAllowlists map[int64][]string // 0 表示通配配置 "*"
|
||||||
|
privateToolAllowlist []string
|
||||||
|
|
||||||
|
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats)
|
||||||
|
msgMu sync.Mutex
|
||||||
|
msgMap map[int64]msgRef // message_id → {peer, time}
|
||||||
|
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
|
||||||
|
}
|
||||||
|
|
||||||
type typingState struct {
|
type typingState struct {
|
||||||
userID int64
|
userID int64
|
||||||
@ -258,7 +300,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "owner", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ", Description: "Bot 所有者 QQ 号列表,逗号分隔。所有者无论私聊或群聊均拥有完整工具权限", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ(旧配置)", Description: "兼容旧版 admin 配置;owner 为空时作为 Bot 所有者列表", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_tool_allowlists", Default: defaultGroupToolAllowlists, Type: "string", DisplayName: "群聊工具白名单", Description: "JSON 对象:群号到允许工具名/前缀*的数组;* 为未单独配置群的默认白名单。Bot 所有者不受限制", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "private_tool_allowlist", Default: defaultPublicToolAllowlist, Type: "string", DisplayName: "非所有者私聊工具白名单", Description: "JSON 数组,支持工具精确名和尾部 * 前缀。硬性私人资源工具不能由此白名单放行;Bot 所有者不受权限限制", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_tool_calls", Default: defaultMaxQQToolCalls, Type: "int", DisplayName: "单轮 QQ 工具调用上限", Description: "QQ 输入触发的单轮推理最多调用工具次数(0=不限制);仅作跑飞兜底,不拦参数不同的必需调用", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_output_calls", Default: defaultMaxQQOutputCalls, Type: "int", DisplayName: "单轮 QQ 发送上限", Description: "单轮最多主动发送的不同消息条数(0=不限制);参数不同的消息不视为重复", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_duplicate_qq_send", Default: defaultMaxDuplicateSend, Type: "int", DisplayName: "单轮相同 QQ 发送上限", Description: "单轮内参数完全相同的 output_send__qq 允许重复的次数(0=不限制);这才是循环保险的真正触发条件", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
||||||
@ -274,7 +322,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
|
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
|
||||||
p.webhookToken = getSetting[string](settings, "webhook_token", "")
|
p.webhookToken = getSetting[string](settings, "webhook_token", "")
|
||||||
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
|
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
|
||||||
p.adminIDs = parseIDList(getSetting[string](settings, "admin", ""))
|
ownerRaw := getSetting[string](settings, "owner", "")
|
||||||
|
if strings.TrimSpace(ownerRaw) == "" {
|
||||||
|
ownerRaw = getSetting[string](settings, "admin", "")
|
||||||
|
}
|
||||||
|
p.ownerIDs = parseIDList(ownerRaw)
|
||||||
|
p.groupToolAllowlists = parseGroupToolAllowlists(getSetting[string](settings, "group_tool_allowlists", defaultGroupToolAllowlists))
|
||||||
|
p.privateToolAllowlist = parseToolAllowlist(getSetting[string](settings, "private_tool_allowlist", defaultPublicToolAllowlist))
|
||||||
|
p.maxQQToolCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_tool_calls", int64(defaultMaxQQToolCalls))), defaultMaxQQToolCalls)
|
||||||
|
p.maxQQOutputCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_output_calls", int64(defaultMaxQQOutputCalls))), defaultMaxQQOutputCalls)
|
||||||
|
p.maxDuplicateSend = nonNegativeOrDefault(int(getSetting[int64](settings, "max_duplicate_qq_send", int64(defaultMaxDuplicateSend))), defaultMaxDuplicateSend)
|
||||||
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
|
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
|
||||||
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
|
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
|
||||||
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
|
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
|
||||||
@ -312,10 +369,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
`发送QQ群聊/私聊消息,支持文字、语音、图片、文件。
|
`发送QQ群聊/私聊消息,支持文字、语音、图片、文件。
|
||||||
meta JSON 格式:
|
meta JSON 格式:
|
||||||
{
|
{
|
||||||
"group_id": 123456, // 群号(与 user_id 二选一)
|
"group_id": 123456, // 群号
|
||||||
"user_id": 123456, // QQ号(与 group_id 二选一)
|
"user_id": 123456, // QQ号
|
||||||
"reply_to": 12345 // 可选,回复指定消息 ID
|
"reply_to": 12345 // 可选,回复指定消息 ID
|
||||||
}
|
}
|
||||||
|
路由规则:仅 group_id 发群;仅 user_id 发私聊;两者同时存在时发到 group_id,并在消息头 @user_id。
|
||||||
type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图片URL)/ file(文件URL)`,
|
type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图片URL)/ file(文件URL)`,
|
||||||
sdk.ChannelDef{}, p.handleChannelOutput)
|
sdk.ChannelDef{}, p.handleChannelOutput)
|
||||||
|
|
||||||
@ -354,6 +412,10 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
Name: tp + "get_message", Description: botInfo + "通过 message_id 从 NapCat 实时获取消息正文、发送者、附件等信息。message_id 从中断消息的 message_id=N 获取,或从 reply_to 的 message_id 获取。",
|
Name: tp + "get_message", Description: botInfo + "通过 message_id 从 NapCat 实时获取消息正文、发送者、附件等信息。message_id 从中断消息的 message_id=N 获取,或从 reply_to 的 message_id 获取。",
|
||||||
NoMemory: false,
|
NoMemory: false,
|
||||||
Cleaner: cleaner,
|
Cleaner: cleaner,
|
||||||
|
// 消息正文只在当轮需要(决策怎么回复);用完即裁剪。
|
||||||
|
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
|
||||||
|
// 长会话下持续挤占 token 预算(§13.8)。
|
||||||
|
ContextPolicy: "prune",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object", "properties": map[string]interface{}{
|
"type": "object", "properties": map[string]interface{}{
|
||||||
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
|
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
|
||||||
@ -599,7 +661,13 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
NoMemory: true,
|
NoMemory: true,
|
||||||
}, p.handleSendLike)
|
}, p.handleSendLike)
|
||||||
|
|
||||||
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools)
|
// 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。
|
||||||
|
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
|
||||||
|
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
|
||||||
|
// 否则内核会把它误当作“结束整个推理”的最终响应。
|
||||||
|
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
|
||||||
|
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
|
||||||
|
|
||||||
// ---- HTTP server for NapCat webhook ----
|
// ---- HTTP server for NapCat webhook ----
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@ -757,8 +825,8 @@ func parseIDList(raw string) []int64 {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) isAdmin(userID int64) bool {
|
func (p *Plugin) isOwner(userID int64) bool {
|
||||||
for _, id := range p.adminIDs {
|
for _, id := range p.ownerIDs {
|
||||||
if id == userID {
|
if id == userID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -766,6 +834,261 @@ func (p *Plugin) isAdmin(userID int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseToolAllowlist(raw string) []string {
|
||||||
|
var patterns []string
|
||||||
|
if json.Unmarshal([]byte(raw), &patterns) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(patterns))
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if pattern = strings.TrimSpace(pattern); pattern != "" {
|
||||||
|
out = append(out, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroupToolAllowlists(raw string) map[int64][]string {
|
||||||
|
var encoded map[string][]string
|
||||||
|
if json.Unmarshal([]byte(raw), &encoded) != nil {
|
||||||
|
return map[int64][]string{}
|
||||||
|
}
|
||||||
|
out := make(map[int64][]string, len(encoded))
|
||||||
|
for key, patterns := range encoded {
|
||||||
|
var groupID int64
|
||||||
|
if key != "*" {
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupID = parsed
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(patterns))
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if pattern = strings.TrimSpace(pattern); pattern != "" {
|
||||||
|
clean = append(clean, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[groupID] = clean
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesToolAllowlist(name string, patterns []string) bool {
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if pattern == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(name, strings.TrimSuffix(pattern, "*")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonNegativeOrDefault 保留 0(表示“不限制”),仅把负数纠正为默认值。
|
||||||
|
func nonNegativeOrDefault(value, fallback int) int {
|
||||||
|
if value < 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHardPrivateTool 是不可由群/私聊白名单覆盖的私人资源边界。
|
||||||
|
// output_send__qq 及 QQ 当前会话工具在参数级另行约束,不在此处按名称误杀。
|
||||||
|
func isHardPrivateTool(name string) bool {
|
||||||
|
for _, prefix := range []string{
|
||||||
|
"calendar_", "email_", "mail_", "agentmail_", "memory_", "knowledge_",
|
||||||
|
"device_", "devicectl_", "terminal_", "shell_", "command_", "exec_",
|
||||||
|
"filesystem_", "agentfs_", "config_", "settings_", "plugin_", "plugins_",
|
||||||
|
} {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matchesToolAllowlist(name, []string{
|
||||||
|
"read_file", "write_file", "edit_file", "delete_file", "list_files", "run_command",
|
||||||
|
"homeagent_config", "homeagent_restart", "output_send__email", "output_send__mail",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func argInt64(args map[string]interface{}, key string) (int64, bool) {
|
||||||
|
value, exists := args[key]
|
||||||
|
if !exists || value == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
parsed, err := convInt64(value)
|
||||||
|
return parsed, err == nil && parsed != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) {
|
||||||
|
if !auth.active || auth.owner {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
currentPeer := auth.userID
|
||||||
|
if auth.isGroup {
|
||||||
|
currentPeer = auth.groupID
|
||||||
|
}
|
||||||
|
if currentPeer == 0 {
|
||||||
|
return false, "可信 QQ 会话身份不完整"
|
||||||
|
}
|
||||||
|
matchCurrentPeer := func() bool {
|
||||||
|
groupID, hasGroup := argInt64(args, "group_id")
|
||||||
|
userID, hasUser := argInt64(args, "user_id")
|
||||||
|
if auth.isGroup {
|
||||||
|
return hasGroup && groupID == auth.groupID && !hasUser
|
||||||
|
}
|
||||||
|
return hasUser && userID == auth.userID && !hasGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case p.name + "_get_history", p.name + "_mark_read":
|
||||||
|
if !matchCurrentPeer() {
|
||||||
|
return false, "只能访问当前 QQ 会话"
|
||||||
|
}
|
||||||
|
case p.name + "_get_message":
|
||||||
|
messageID, ok := argInt64(args, "message_id")
|
||||||
|
if !ok {
|
||||||
|
return false, "缺少有效 message_id"
|
||||||
|
}
|
||||||
|
if messageID == auth.messageID {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
peerID, isGroup, _, found := p.lookupMsgRef(messageID)
|
||||||
|
if !found || isGroup != auth.isGroup || peerID != currentPeer {
|
||||||
|
return false, "message_id 不属于当前 QQ 会话"
|
||||||
|
}
|
||||||
|
case p.name + "_get_group_member_info", p.name + "_get_group_files":
|
||||||
|
groupID, ok := argInt64(args, "group_id")
|
||||||
|
if !auth.isGroup || !ok || groupID != auth.groupID {
|
||||||
|
return false, "只能访问当前 QQ 群的数据"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
|
||||||
|
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
||||||
|
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
||||||
|
func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup bool) {
|
||||||
|
p.authMu.Lock()
|
||||||
|
defer p.authMu.Unlock()
|
||||||
|
if p.authByMessageID == nil {
|
||||||
|
p.authByMessageID = make(map[int64]qqAuthContext)
|
||||||
|
}
|
||||||
|
p.authGeneration++
|
||||||
|
next := qqAuthContext{
|
||||||
|
active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID,
|
||||||
|
groupID: groupID, isGroup: isGroup, generation: p.authGeneration,
|
||||||
|
}
|
||||||
|
if messageID != 0 {
|
||||||
|
p.authByMessageID[messageID] = next
|
||||||
|
if len(p.authByMessageID) > 2048 {
|
||||||
|
cutoff := p.authGeneration - 1024
|
||||||
|
for id, auth := range p.authByMessageID {
|
||||||
|
if auth.generation < cutoff {
|
||||||
|
delete(p.authByMessageID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !p.auth.active {
|
||||||
|
p.auth = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup {
|
||||||
|
p.auth.owner = p.auth.owner && next.owner
|
||||||
|
p.auth.messageID = next.messageID
|
||||||
|
p.auth.generation = next.generation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。
|
||||||
|
bothOwners := p.auth.owner && next.owner
|
||||||
|
sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID
|
||||||
|
p.auth.owner = bothOwners
|
||||||
|
p.auth.messageID = 0
|
||||||
|
p.auth.userID = 0
|
||||||
|
p.auth.isGroup = sameGroup
|
||||||
|
if sameGroup {
|
||||||
|
p.auth.groupID = groupID
|
||||||
|
} else {
|
||||||
|
p.auth.groupID = 0
|
||||||
|
}
|
||||||
|
p.auth.generation = next.generation
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageIDFromInput(raw string) int64 {
|
||||||
|
match := qqMessageIDRe.FindStringSubmatch(raw)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseInt(match[1], 10, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||||
|
ctx.RLock()
|
||||||
|
source, _ := ctx.Extra["input_source"].(string)
|
||||||
|
raw := ctx.RawMessage
|
||||||
|
ctx.RUnlock()
|
||||||
|
p.authMu.Lock()
|
||||||
|
defer p.authMu.Unlock()
|
||||||
|
if source != p.name {
|
||||||
|
p.auth = qqAuthContext{}
|
||||||
|
p.resetTurnGuardLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if messageID := messageIDFromInput(raw); messageID != 0 {
|
||||||
|
if auth, ok := p.authByMessageID[messageID]; ok {
|
||||||
|
p.auth = auth
|
||||||
|
delete(p.authByMessageID, messageID)
|
||||||
|
p.resetTurnGuardLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
|
||||||
|
p.auth = qqAuthContext{active: true}
|
||||||
|
p.resetTurnGuardLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) resetTurnGuardLocked() {
|
||||||
|
p.lastDenial = ""
|
||||||
|
p.denialLocked = false
|
||||||
|
p.toolCallCount = 0
|
||||||
|
p.outputCallCount = 0
|
||||||
|
p.outputSignatures = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
|
||||||
|
p.authMu.Lock()
|
||||||
|
p.auth = qqAuthContext{}
|
||||||
|
p.resetTurnGuardLocked()
|
||||||
|
p.authMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
|
||||||
|
p.authMu.RLock()
|
||||||
|
auth := p.auth
|
||||||
|
var patterns []string
|
||||||
|
if auth.active && !auth.owner {
|
||||||
|
if auth.isGroup {
|
||||||
|
patterns, _ = p.groupToolAllowlists[auth.groupID]
|
||||||
|
if patterns == nil {
|
||||||
|
patterns = p.groupToolAllowlists[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
patterns = p.privateToolAllowlist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.authMu.RUnlock()
|
||||||
|
if !auth.active || auth.owner {
|
||||||
|
return true, auth
|
||||||
|
}
|
||||||
|
return matchesToolAllowlist(name, patterns), auth
|
||||||
|
}
|
||||||
|
|
||||||
// isAtBot checks if the message contains an @-mention of the bot.
|
// isAtBot checks if the message contains an @-mention of the bot.
|
||||||
func (p *Plugin) isAtBot(msg interface{}) bool {
|
func (p *Plugin) isAtBot(msg interface{}) bool {
|
||||||
segments, ok := msg.([]interface{})
|
segments, ok := msg.([]interface{})
|
||||||
@ -824,19 +1147,110 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
|
func (p *Plugin) setDenial(ctx *sdk.StageContext, message string) {
|
||||||
|
ctx.Response = &message
|
||||||
|
p.authMu.Lock()
|
||||||
|
p.lastDenial = message
|
||||||
|
p.authMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) clearPreviousDenial(ctx *sdk.StageContext) {
|
||||||
|
p.authMu.Lock()
|
||||||
|
last := p.lastDenial
|
||||||
|
p.lastDenial = ""
|
||||||
|
p.authMu.Unlock()
|
||||||
|
if last != "" && ctx.Response != nil && *ctx.Response == last {
|
||||||
|
ctx.Response = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) clearDeniedResponse(ctx *sdk.StageContext) error {
|
||||||
ctx.Lock()
|
ctx.Lock()
|
||||||
defer ctx.Unlock()
|
defer ctx.Unlock()
|
||||||
|
p.clearPreviousDenial(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
||||||
|
ctx.Lock()
|
||||||
|
defer ctx.Unlock()
|
||||||
|
p.clearPreviousDenial(ctx)
|
||||||
if len(ctx.ToolCalls) == 0 {
|
if len(ctx.ToolCalls) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tc := &ctx.ToolCalls[0]
|
tc := &ctx.ToolCalls[0]
|
||||||
|
allowed, auth := p.currentToolAllowed(tc.Name)
|
||||||
|
if !auth.active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.authMu.Lock()
|
||||||
|
p.toolCallCount++
|
||||||
|
toolCount := p.toolCallCount
|
||||||
|
denialLocked := p.denialLocked
|
||||||
|
if tc.Name == "output_send__"+p.name {
|
||||||
|
p.outputCallCount++
|
||||||
|
signatureBytes, _ := json.Marshal(tc.Arguments)
|
||||||
|
signature := string(signatureBytes)
|
||||||
|
p.outputSignatures[signature]++
|
||||||
|
duplicateCount := p.outputSignatures[signature]
|
||||||
|
distinctCount := len(p.outputSignatures)
|
||||||
|
// 循环保险只拦“参数完全相同的重复调用”。参数不同的必需调用一律放行,
|
||||||
|
// 否则多次 cmd_run / update_schedule / 多条不同消息都会被误杀。
|
||||||
|
if p.maxDuplicateSend > 0 && duplicateCount > p.maxDuplicateSend {
|
||||||
|
p.authMu.Unlock()
|
||||||
|
msg := fmt.Sprintf("QQ 循环保险已阻止重复发送:本轮第 %d 次出现参数完全相同的消息;请勿重复发送同一内容", duplicateCount)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.maxQQOutputCalls > 0 && distinctCount > p.maxQQOutputCalls {
|
||||||
|
p.authMu.Unlock()
|
||||||
|
msg := fmt.Sprintf("QQ 循环保险已阻止本次发送:单轮主动发送的不同消息数已达上限 %d(0=不限制,可在插件配置调整)", p.maxQQOutputCalls)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.maxQQToolCalls > 0 && toolCount > p.maxQQToolCalls {
|
||||||
|
p.authMu.Unlock()
|
||||||
|
msg := fmt.Sprintf("QQ 循环保险已阻止工具调用:单轮工具调用总数已达上限 %d(0=不限制,可在插件配置调整)", p.maxQQToolCalls)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.authMu.Unlock()
|
||||||
|
|
||||||
|
if denialLocked && tc.Name != "output_send__"+p.name {
|
||||||
|
msg := fmt.Sprintf("QQ 权限策略已锁止本轮后续工具 %s;仅允许发送一次权限说明", tc.Name)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !auth.owner && isHardPrivateTool(tc.Name) {
|
||||||
|
p.authMu.Lock()
|
||||||
|
p.denialLocked = true
|
||||||
|
p.authMu.Unlock()
|
||||||
|
msg := fmt.Sprintf("QQ 权限策略拒绝私人资源工具 %s;该限制不能由群聊或私聊白名单覆盖,请不要重试", tc.Name)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
scope := "非所有者私聊"
|
||||||
|
if auth.isGroup {
|
||||||
|
scope = fmt.Sprintf("群聊 %d", auth.groupID)
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if argsAllowed, reason := p.sessionToolArgsAllowed(tc.Name, tc.Arguments, auth); !argsAllowed {
|
||||||
|
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s;请不要改用其他会话 ID 重试", tc.Name, reason)
|
||||||
|
p.setDenial(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if tc.Name == p.name+"_group_manage" {
|
if tc.Name == p.name+"_group_manage" {
|
||||||
cmd, _ := tc.Arguments["command"].(string)
|
cmd, _ := tc.Arguments["command"].(string)
|
||||||
if requiresConfirmGroupCommand(cmd) {
|
if requiresConfirmGroupCommand(cmd) {
|
||||||
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
|
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
|
||||||
msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
|
msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
|
||||||
ctx.Response = &msg
|
p.setDenial(ctx, msg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -846,7 +1260,7 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
|
|||||||
if requiresConfirmFriendCommand(cmd) {
|
if requiresConfirmFriendCommand(cmd) {
|
||||||
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
|
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
|
||||||
msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
|
msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
|
||||||
ctx.Response = &msg
|
p.setDenial(ctx, msg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -978,8 +1392,8 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
|
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
|
||||||
}
|
}
|
||||||
if p.isAdmin(evt.UserID) {
|
if p.isOwner(evt.UserID) {
|
||||||
interrupt = "【重要!老大消息】" + interrupt
|
interrupt = "【重要!Bot 所有者消息】" + interrupt
|
||||||
}
|
}
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
@ -1012,12 +1426,16 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 必须在注入前记录 OneBot 可信来源;权限判断绝不依赖昵称、正文或模型参数。
|
||||||
|
p.activateAuthContext(evt.MessageID, evt.UserID, evt.GroupID, evt.MessageType == "group")
|
||||||
|
|
||||||
if evt.MessageType == "private" {
|
if evt.MessageType == "private" {
|
||||||
p.startTyping(evt.UserID)
|
p.startTyping(evt.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
p.sdk.InjectInterruptText(p.name, p.name, interrupt)
|
// NoMemory:HTTP 侧来的中断提示,不是对话内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
@ -1320,6 +1738,8 @@ func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
|
|||||||
|
|
||||||
// handleChannelOutput — output_send(channel="qq") 的处理器
|
// handleChannelOutput — output_send(channel="qq") 的处理器
|
||||||
// args 包含 payload, type, (可选 meta)
|
// args 包含 payload, type, (可选 meta)
|
||||||
|
// 成功时只返回极简标记,不回传 NapCat 完整响应——避免"已发送"类富回执喂给模型
|
||||||
|
// 造成"看到成功→继续发下一条"的回声循环(issue: output loop echo)。
|
||||||
func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) {
|
||||||
payload, _ := args["payload"].(string)
|
payload, _ := args["payload"].(string)
|
||||||
rawType, _ := args["type"].(string)
|
rawType, _ := args["type"].(string)
|
||||||
@ -1352,10 +1772,15 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
p.stopTyping(userID)
|
p.stopTyping(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sendErr error
|
||||||
switch rawType {
|
switch rawType {
|
||||||
case "text":
|
case "text":
|
||||||
text := p.sensitiveFilter(payload)
|
text := p.sensitiveFilter(payload)
|
||||||
msg := map[string]interface{}{"message": text}
|
message := interface{}(text)
|
||||||
|
if groupID != 0 && userID != 0 {
|
||||||
|
message = messageWithMention(userID, map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": text}})
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{"message": message}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
@ -1365,9 +1790,10 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
msg["reply_to"] = replyTo
|
msg["reply_to"] = replyTo
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
return p.napcat("send_group_msg", msg)
|
_, sendErr = p.napcat("send_group_msg", msg)
|
||||||
|
} else {
|
||||||
|
_, sendErr = p.napcat("send_private_msg", msg)
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
|
||||||
|
|
||||||
case "voice", "audio":
|
case "voice", "audio":
|
||||||
text := p.sensitiveFilter(payload)
|
text := p.sensitiveFilter(payload)
|
||||||
@ -1387,21 +1813,23 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
os.Remove(audioFile)
|
os.Remove(audioFile)
|
||||||
uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest))
|
uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest))
|
||||||
cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri)
|
cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri)
|
||||||
msg := map[string]interface{}{"message": cqMsg}
|
message := interface{}(cqMsg)
|
||||||
|
if groupID != 0 && userID != 0 {
|
||||||
|
message = messageWithMention(userID, map[string]interface{}{"type": "record", "data": map[string]interface{}{"file": uri}})
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{"message": message}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
msg["user_id"] = userID
|
msg["user_id"] = userID
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
return p.napcat("send_group_msg", msg)
|
_, sendErr = p.napcat("send_group_msg", msg)
|
||||||
|
} else {
|
||||||
|
_, sendErr = p.napcat("send_private_msg", msg)
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
|
||||||
|
|
||||||
case "image", "file":
|
case "image", "file":
|
||||||
// 收敛到 output 通道:payload 支持本地路径或 http(s) URL。
|
|
||||||
// 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式),
|
|
||||||
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
|
|
||||||
uri := payload
|
uri := payload
|
||||||
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
|
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
|
||||||
!strings.HasPrefix(payload, "file://") {
|
!strings.HasPrefix(payload, "file://") {
|
||||||
@ -1423,34 +1851,48 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
if rawType == "image" {
|
if rawType == "image" {
|
||||||
cqTag = "image"
|
cqTag = "image"
|
||||||
}
|
}
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
|
message := interface{}(fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri))
|
||||||
|
if groupID != 0 && userID != 0 {
|
||||||
|
message = messageWithMention(userID, map[string]interface{}{"type": cqTag, "data": map[string]interface{}{"file": uri}})
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{"message": message}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
msg["user_id"] = userID
|
msg["user_id"] = userID
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
return p.napcat("send_group_msg", msg)
|
_, sendErr = p.napcat("send_group_msg", msg)
|
||||||
|
} else {
|
||||||
|
_, sendErr = p.napcat("send_private_msg", msg)
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("不支持的 type: %s(枚举值: text/voice/image/file)", rawType)
|
return nil, fmt.Errorf("不支持的 type: %s(枚举值: text/voice/image/file)", rawType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sendErr != nil {
|
||||||
|
return nil, sendErr
|
||||||
|
}
|
||||||
|
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
|
||||||
|
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
|
||||||
|
return "ok", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) buildOutputHelp() string {
|
func (p *Plugin) buildOutputHelp() string {
|
||||||
return `【参数】
|
return `【参数】
|
||||||
payload — 消息载荷。type=text时直接填文字,type=voice时填文字(自动转语音),type=image/file时填URL
|
payload — 消息载荷。type=text时直接填文字,type=voice时填文字(自动转语音),type=image/file时填URL
|
||||||
meta — JSON 元数据,必含 group_id(群聊)或 user_id(私聊),可选 reply_to
|
meta — JSON 元数据,含 group_id(群聊)和/或 user_id(私聊或群内@),可选 reply_to
|
||||||
type — text / voice / image / file
|
type — text / voice / image / file
|
||||||
|
|
||||||
【示例】
|
【示例】
|
||||||
群聊文字:output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text")
|
群聊文字:output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text")
|
||||||
私聊语音:output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice")
|
私聊语音:output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice")
|
||||||
|
群内@用户:output_send__qq(payload="你好", meta="{\"group_id\":123456789,\"user_id\":987654321}", type="text")
|
||||||
发送图片:output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image")
|
发送图片:output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image")
|
||||||
|
|
||||||
【注意】
|
【注意】
|
||||||
|
- group_id 与 user_id 同时存在时始终发送到 group_id,并在消息头 @user_id
|
||||||
- type=text 时 payload 直接是文字,无需 JSON 包裹
|
- type=text 时 payload 直接是文字,无需 JSON 包裹
|
||||||
- type=voice 时 payload 是文字内容,自动转语音发送
|
- type=voice 时 payload 是文字内容,自动转语音发送
|
||||||
- type=image/file 时 payload 是 URL 或路径`
|
- type=image/file 时 payload 是 URL 或路径`
|
||||||
@ -2088,8 +2530,10 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
|||||||
p.updateDownloadTask(t, "done", savePath, "")
|
p.updateDownloadTask(t, "done", savePath, "")
|
||||||
log.Printf("[qq] 文件下载完成: %s", savePath)
|
log.Printf("[qq] 文件下载完成: %s", savePath)
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
p.sdk.InjectInterruptText(p.name, p.name,
|
// NoMemory:下载完成的状态通知,不是记忆内容。
|
||||||
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath))
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
|
||||||
|
sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errMsg = "下载失败,文件可能已过期"
|
errMsg = "下载失败,文件可能已过期"
|
||||||
@ -2557,6 +3001,13 @@ func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{},
|
|||||||
|
|
||||||
// ======== NapCat HTTP Client ========
|
// ======== NapCat HTTP Client ========
|
||||||
|
|
||||||
|
func messageWithMention(userID int64, content interface{}) []interface{} {
|
||||||
|
return []interface{}{
|
||||||
|
map[string]interface{}{"type": "at", "data": map[string]interface{}{"qq": strconv.FormatInt(userID, 10)}},
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
|
||||||
data, _ := json.Marshal(params)
|
data, _ := json.Marshal(params)
|
||||||
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
|
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
|
||||||
@ -2675,12 +3126,19 @@ func convInt64(v interface{}) (int64, error) {
|
|||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
return &Plugin{
|
return &Plugin{
|
||||||
name: name,
|
name: name,
|
||||||
allowFrom: make(map[int64]struct{}),
|
allowFrom: make(map[int64]struct{}),
|
||||||
groupAllowFrom: make(map[int64]struct{}),
|
groupAllowFrom: make(map[int64]struct{}),
|
||||||
downloadTasks: make([]*DownloadTask, 0),
|
authByMessageID: make(map[int64]qqAuthContext),
|
||||||
typingMap: make(map[int64]*typingState),
|
outputSignatures: make(map[string]int),
|
||||||
dmPolicy: "open",
|
maxQQToolCalls: defaultMaxQQToolCalls,
|
||||||
groupPolicy: "open",
|
maxQQOutputCalls: defaultMaxQQOutputCalls,
|
||||||
|
maxDuplicateSend: defaultMaxDuplicateSend,
|
||||||
|
groupToolAllowlists: parseGroupToolAllowlists(defaultGroupToolAllowlists),
|
||||||
|
privateToolAllowlist: parseToolAllowlist(defaultPublicToolAllowlist),
|
||||||
|
downloadTasks: make([]*DownloadTask, 0),
|
||||||
|
typingMap: make(map[int64]*typingState),
|
||||||
|
dmPolicy: "open",
|
||||||
|
groupPolicy: "open",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
204
example/qq/plugin_test.go
Normal file
204
example/qq/plugin_test.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ rss plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -300,7 +300,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
|||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
// 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算,
|
||||||
|
// 原文仍进上下文(模型当轮看得到)。
|
||||||
|
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
|
||||||
|
sdk.InjectOptions{NoMemory: true})
|
||||||
p.saveData()
|
p.saveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ weather plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
27
meta/meta.go
27
meta/meta.go
@ -28,11 +28,28 @@ var (
|
|||||||
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
||||||
// 不调就不受影响。想用新字段的插件重编即可。
|
// 不调就不受影响。想用新字段的插件重编即可。
|
||||||
//
|
//
|
||||||
|
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**:
|
||||||
|
// - InjectOptions{NoMemory, ContextPolicy}
|
||||||
|
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
|
||||||
|
// - ChannelDef.ContextPolicy(顺带给 ChannelDef 补上 JSON tag:
|
||||||
|
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
|
||||||
|
// 手写字段白名单,新增字段会被静默丢掉)
|
||||||
|
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
|
||||||
|
// 不裁剪),因此存量插件不需要改一行也不需要重编。
|
||||||
|
// 裁剪(ContextPolicy=prune)必须显式声明——它会归档丢弃低相关事件。
|
||||||
|
//
|
||||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||||
//
|
//
|
||||||
// 现为 1.2.0:1.1.x 线正在发布中(release/v1.1.x 上定版 1.1.0),
|
// 现为 1.2.0:核心的 1.2.x 线正在发布中(release/v1.2.x 承载 1.2.0),
|
||||||
// main 在积攒 1.2 的东西。1.2.0 本身还没有任何 tag。
|
// 但 **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.2.0"
|
Version = "1.2.0"
|
||||||
|
|
||||||
// Commit 是构建时的 Git commit hash。
|
// Commit 是构建时的 Git commit hash。
|
||||||
@ -44,7 +61,7 @@ var (
|
|||||||
// SDKName 是 SDK 名称。
|
// SDKName 是 SDK 名称。
|
||||||
SDKName = "HomeAgent SDK"
|
SDKName = "HomeAgent SDK"
|
||||||
|
|
||||||
// CoreModule 是核心仓的 Go module path,供 plugindev 生成 go.mod 时使用。
|
// CoreModule 是核心仓的 Go module path,供 hmapdev 生成 go.mod 时使用。
|
||||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||||
|
|
||||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||||
@ -56,6 +73,10 @@ var (
|
|||||||
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
||||||
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
||||||
// 媒体接口是可选能力,不用就不受影响。
|
// 媒体接口是可选能力,不用就不受影响。
|
||||||
|
//
|
||||||
|
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
|
||||||
|
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
|
||||||
|
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
|
||||||
CoreVersion = "1.0.0"
|
CoreVersion = "1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
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"
|
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
|
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")}"
|
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:-}"
|
GOCACHE="${GOCACHE:-}"
|
||||||
GOPATH="${GOPATH:-}"
|
GOPATH="${GOPATH:-}"
|
||||||
|
|
||||||
@ -28,7 +35,7 @@ case "$TARGET" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown target: $TARGET"
|
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
|
exit 1
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@ -42,12 +49,12 @@ export CGO_ENABLED=0
|
|||||||
|
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
build_plugindev() {
|
build_hmapdev() {
|
||||||
local src="tools/plugindev"
|
local src="tools/hmapdev"
|
||||||
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
|
local out="$BUILD_DIR/hmapdev${SUFFIX:+_$SUFFIX}"
|
||||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
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"
|
cd "$PROJECT_ROOT/$src"
|
||||||
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
||||||
-o "$out" .
|
-o "$out" .
|
||||||
@ -55,9 +62,47 @@ build_plugindev() {
|
|||||||
cd "$PROJECT_ROOT"
|
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
|
case "$COMPONENT" in
|
||||||
all|plugindev) build_plugindev ;;
|
all)
|
||||||
|
# 工具链必须先建完:示例用它来构建(同源保证协议一致)。
|
||||||
|
build_hmapdev
|
||||||
|
build_examples
|
||||||
|
;;
|
||||||
|
hmapdev) build_hmapdev ;;
|
||||||
|
examples) build_examples ;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown component: $COMPONENT"
|
echo "Unknown component: $COMPONENT"
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@ -48,7 +48,7 @@ Function pageConfirm
|
|||||||
${EndIf}
|
${EndIf}
|
||||||
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
||||||
Pop $0
|
Pop $0
|
||||||
${NSD_CreateLabel} 15u 20u 100% 12u "• plugindev.exe — 插件开发工具"
|
${NSD_CreateLabel} 15u 20u 100% 12u "• hmapdev.exe — 插件开发工具"
|
||||||
Pop $0
|
Pop $0
|
||||||
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
||||||
Pop $0
|
Pop $0
|
||||||
@ -64,11 +64,11 @@ Section "Install" SEC_INSTALL
|
|||||||
SetOutPath "$INSTDIR"
|
SetOutPath "$INSTDIR"
|
||||||
|
|
||||||
DetailPrint "复制工具链文件..."
|
DetailPrint "复制工具链文件..."
|
||||||
File "plugindev.exe"
|
File "hmapdev.exe"
|
||||||
|
|
||||||
DetailPrint "创建快捷方式..."
|
DetailPrint "创建快捷方式..."
|
||||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
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 "配置环境变量..."
|
DetailPrint "配置环境变量..."
|
||||||
; Add to system PATH
|
; Add to system PATH
|
||||||
@ -93,23 +93,23 @@ Section "Install" SEC_INSTALL
|
|||||||
DetailPrint "Git 已安装: $1"
|
DetailPrint "Git 已安装: $1"
|
||||||
${Else}
|
${Else}
|
||||||
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
||||||
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
|
DetailPrint "安装完成后请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
${If} $hasGit == "1"
|
${If} $hasGit == "1"
|
||||||
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
|
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 $0
|
||||||
Pop $1
|
Pop $1
|
||||||
${If} $0 == 0
|
${If} $0 == 0
|
||||||
StrCpy $sdkInstallOk "1"
|
StrCpy $sdkInstallOk "1"
|
||||||
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
||||||
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
|
Pop $0
|
||||||
${Else}
|
${Else}
|
||||||
DetailPrint "SDK 下载失败 (错误码: $0)"
|
DetailPrint "SDK 下载失败 (错误码: $0)"
|
||||||
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
|
DetailPrint "请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
@ -126,10 +126,10 @@ SectionEnd
|
|||||||
|
|
||||||
Section "Uninstall"
|
Section "Uninstall"
|
||||||
Delete "$INSTDIR\Uninstall.exe"
|
Delete "$INSTDIR\Uninstall.exe"
|
||||||
Delete "$INSTDIR\plugindev.exe"
|
Delete "$INSTDIR\hmapdev.exe"
|
||||||
RMDir /r "$INSTDIR\sdk"
|
RMDir /r "$INSTDIR\sdk"
|
||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
|
Delete "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk"
|
||||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||||
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
||||||
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
||||||
|
|||||||
@ -49,15 +49,17 @@ type TextMemoryAPI interface {
|
|||||||
//
|
//
|
||||||
// 两个方向共用一个类型:
|
// 两个方向共用一个类型:
|
||||||
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||||
// - 读出(Query):内核只填 Digest/MIME/Description,**不回 Data**——
|
// - 读出(Query):内核只填 Digest/MIME,**不回 Data**——
|
||||||
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||||
// 需要字节时拿 Digest 单独取。
|
// 需要字节时拿 Digest 单独取。
|
||||||
|
//
|
||||||
|
// 刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
|
||||||
|
// 它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
|
||||||
type MediaAttachment struct {
|
type MediaAttachment struct {
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
MIME string `json:"mime,omitempty"`
|
MIME string `json:"mime,omitempty"`
|
||||||
Data []byte `json:"data,omitempty"`
|
Data []byte `json:"data,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextEvent struct {
|
type TextEvent struct {
|
||||||
@ -74,8 +76,8 @@ type DocMemoryAPI interface {
|
|||||||
Insert(doc *Doc) error
|
Insert(doc *Doc) error
|
||||||
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
// 插件无需自己拼标记:内核会把 `[mime <短 digest>] <描述>` 补进 Content,
|
// 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
// 让向量检索和后续蒸馏都能看到这份媒体。
|
// 因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
Remove(id string)
|
Remove(id string)
|
||||||
Stats() map[string]interface{}
|
Stats() map[string]interface{}
|
||||||
|
|||||||
155
sdk/plugin.go
155
sdk/plugin.go
@ -35,12 +35,63 @@ const (
|
|||||||
StageAfterOutput Stage = "after_output"
|
StageAfterOutput Stage = "after_output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
//
|
||||||
|
// 默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
// 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
// 插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||||
|
func ValidContextPolicy(policy string) bool {
|
||||||
|
switch policy {
|
||||||
|
case "", ContextPolicyNone, ContextPolicyPrune:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
|
//
|
||||||
|
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
|
// 因此调用方只有在确实需要改变行为时才需要填它。
|
||||||
|
//
|
||||||
|
// 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||||
|
// 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||||
|
// 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||||
|
//
|
||||||
|
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
//
|
||||||
|
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
|
//
|
||||||
|
// CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||||
|
//
|
||||||
|
// 空串 = 按注入的 source 查通道定义(既有行为)。
|
||||||
|
// 为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||||
|
// 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||||
|
// 不指定就只能退到「按 source 查不到就不清洗」。
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool
|
||||||
|
ContextPolicy string
|
||||||
|
CleanerName string
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
|
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
//
|
||||||
|
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
|
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
|
// 那样新增字段会被静默丢掉。
|
||||||
type ChannelDef struct {
|
type ChannelDef struct {
|
||||||
NoMemory bool
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
Cleaner func(string) string
|
Cleaner func(string) string `json:"-"`
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StageContext provides context for stage handlers.
|
// StageContext provides context for stage handlers.
|
||||||
@ -100,12 +151,13 @@ type ToolResult struct {
|
|||||||
|
|
||||||
// ToolDef describes a tool that the plugin exposes.
|
// ToolDef describes a tool that the plugin exposes.
|
||||||
type ToolDef struct {
|
type ToolDef struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Plugin string `json:"plugin,omitempty"`
|
Plugin string `json:"plugin,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
||||||
}
|
}
|
||||||
|
|
||||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
@ -124,6 +176,17 @@ type IOInjector interface {
|
|||||||
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
|
||||||
|
// 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
//
|
||||||
|
// 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
// 保留它们是为了不破坏已有插件;新代码应当用 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventType identifies the kind of system event.
|
// EventType identifies the kind of system event.
|
||||||
@ -504,54 +567,94 @@ func (s *PluginSDK) injector() IOInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||||
if io := s.injector(); io != nil {
|
s.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
|
||||||
io.InjectInterruptText(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectText injects a text message into the agent pipeline.
|
// InjectText injects a text message into the agent pipeline.
|
||||||
|
// 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||||
if io := s.injector(); io != nil {
|
s.InjectTextOpts(source, channel, text, InjectOptions{})
|
||||||
io.InjectText(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectTextNoMemory injects a text message without generating memory.
|
// InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
// 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||||
if io := s.injector(); io != nil {
|
s.InjectTextOpts(source, channel, text, InjectOptions{NoMemory: true})
|
||||||
io.InjectTextNoMemory(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||||
// to the source channel by the caller.
|
// to the source channel by the caller.
|
||||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||||
io := s.injector()
|
return s.InjectInputSyncOpts(source, channel, text, InjectOptions{})
|
||||||
if io == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return io.InjectInputSync(source, channel, text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
// InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||||
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||||
// 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
// 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||||
// 的「下一轮 tool message」语义。
|
// 的「下一轮 tool message」语义。
|
||||||
|
// 等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||||
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||||
if io := s.injector(); io != nil {
|
s.InjectInputMediaOpts(source, channel, text, blocks, InjectOptions{})
|
||||||
io.InjectInputMedia(source, channel, text, blocks)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||||
|
// 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||||
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||||
|
return s.InjectInputMediaSyncOpts(source, channel, text, blocks, InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入(声明记忆/裁剪行为)----
|
||||||
|
|
||||||
|
// InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectTextOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||||
|
//
|
||||||
|
// 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||||
|
// 是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||||
|
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInterruptTextOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||||
io := s.injector()
|
io := s.injector()
|
||||||
if io == nil {
|
if io == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return io.InjectInputMediaSync(source, channel, text, blocks)
|
return io.InjectInputSyncOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInputMediaOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||||
|
io := s.injector()
|
||||||
|
if io == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return io.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||||
|
|||||||
@ -43,6 +43,7 @@ type injectCall struct {
|
|||||||
channel string
|
channel string
|
||||||
text string
|
text string
|
||||||
blocks []ContentBlock
|
blocks []ContentBlock
|
||||||
|
opts InjectOptions // 调用点声明的记忆/裁剪行为
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingInjector) record(c injectCall) {
|
func (r *recordingInjector) record(c injectCall) {
|
||||||
@ -93,6 +94,41 @@ func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBloc
|
|||||||
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
|
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
|
||||||
|
r.nText.Add(1)
|
||||||
|
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
|
||||||
|
r.nInterrupt.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
func (r *recordingInjector) snapshot() []injectCall {
|
func (r *recordingInjector) snapshot() []injectCall {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@ -463,11 +499,10 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
|||||||
data[i] = byte(i * 7 % 256)
|
data[i] = byte(i * 7 % 256)
|
||||||
}
|
}
|
||||||
att := MediaAttachment{
|
att := MediaAttachment{
|
||||||
Digest: strings.Repeat("a", 64),
|
Digest: strings.Repeat("a", 64),
|
||||||
MIME: "image/png",
|
MIME: "image/png",
|
||||||
Data: data,
|
Data: data,
|
||||||
Name: "图片-名字 with space & 符号.png",
|
Name: "图片-名字 with space & 符号.png",
|
||||||
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
|
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(att)
|
b, err := json.Marshal(att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -485,7 +520,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
|||||||
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
|
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
|
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
|
||||||
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
|
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,15 @@ type BuildConfig struct {
|
|||||||
Replaces []string
|
Replaces []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildFailed 记录本次构建是否有平台失败。
|
||||||
|
//
|
||||||
|
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return,而 cmdBuild 返回
|
||||||
|
// void,于是**构建失败却以 0 退出**。调用方(批量重编脚本、CI、发版脚本)
|
||||||
|
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
|
||||||
|
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
|
||||||
|
// 判成功要看退出码,不能靠人读日志。
|
||||||
|
var buildFailed bool
|
||||||
|
|
||||||
func cmdBuild(args []string) {
|
func cmdBuild(args []string) {
|
||||||
// Read all config from plg.json first
|
// Read all config from plg.json first
|
||||||
plg, err := readPlgJSON("plg.json")
|
plg, err := readPlgJSON("plg.json")
|
||||||
@ -92,11 +101,18 @@ func cmdBuild(args []string) {
|
|||||||
|
|
||||||
if bundle || len(targets) == 0 {
|
if bundle || len(targets) == 0 {
|
||||||
buildBundle(plg, outDir, sdkPath)
|
buildBundle(plg, outDir, sdkPath)
|
||||||
return
|
} else {
|
||||||
|
for _, t := range targets {
|
||||||
|
buildTarget(plg, t, outDir, sdkPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range targets {
|
// 以非零码退出:调用方(批量重编、CI、发版脚本)靠退出码判成败。
|
||||||
buildTarget(plg, t, outDir, sdkPath)
|
// 以前这里直接 return,失败也退 0,于是「构建失败」只能靠人翻日志发现——
|
||||||
|
// 实测中就因此把一次部分失败当成了全绿。
|
||||||
|
if buildFailed {
|
||||||
|
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,13 +121,33 @@ func cmdBuild(args []string) {
|
|||||||
// 子进程模式下各平台产物同名(plugin.bin)——进程边界即 ABI 边界,
|
// 子进程模式下各平台产物同名(plugin.bin)——进程边界即 ABI 边界,
|
||||||
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
|
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
|
||||||
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
|
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
|
||||||
|
//
|
||||||
|
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
|
||||||
|
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
|
||||||
|
// Windows 句柄模型无法表达)。Windows 用户走 WSL2,而 WSL2 就是 linux/amd64。
|
||||||
var allBundleTargets = []struct {
|
var allBundleTargets = []struct {
|
||||||
target string
|
target string
|
||||||
entry string // 二进制在 zip 中的文件名
|
entry string // 二进制在 zip 中的文件名
|
||||||
}{
|
}{
|
||||||
{"linux/amd64", "plugin.bin.linux.amd64"},
|
{"linux/amd64", "plugin.bin.linux.amd64"},
|
||||||
{"darwin/amd64", "plugin.bin.darwin.amd64"},
|
{"darwin/amd64", "plugin.bin.darwin.amd64"},
|
||||||
{"windows/amd64", "plugin.bin.windows.amd64"},
|
}
|
||||||
|
|
||||||
|
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
|
||||||
|
//
|
||||||
|
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
|
||||||
|
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
|
||||||
|
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
|
||||||
|
// 「undefined: attachUnifiedShm」——看起来像代码 bug,实际是平台策略。
|
||||||
|
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
|
||||||
|
func checkTargetSupported(target string) error {
|
||||||
|
if strings.HasPrefix(target, "windows/") {
|
||||||
|
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
|
||||||
|
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
|
||||||
|
"段内偏移解引用,Windows 句柄模型无法表达)。Windows 请用 WSL2——" +
|
||||||
|
"它就是 linux/amd64,用 --target linux/amd64 即可")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||||
@ -122,6 +158,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
runtimeCleanup, err := generateProcRuntime()
|
runtimeCleanup, err := generateProcRuntime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" error: %v\n", err)
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer runtimeCleanup()
|
defer runtimeCleanup()
|
||||||
@ -132,9 +169,15 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
var binaries []binEntry
|
var binaries []binEntry
|
||||||
|
|
||||||
for _, bt := range allBundleTargets {
|
for _, bt := range allBundleTargets {
|
||||||
|
if err := checkTargetSupported(bt.target); err != nil {
|
||||||
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
cfg, errMsg := resolveBuild(bt.target)
|
cfg, errMsg := resolveBuild(bt.target)
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
fmt.Printf(" error: %s\n", errMsg)
|
fmt.Printf(" error: %s\n", errMsg)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,7 +194,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
|
|
||||||
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
// 单平台失败即整包失败:bundle 少一个平台就是个坏包,
|
||||||
|
// 却仍会生成 .hmap 让人以为打包成功。
|
||||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
|
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
|
||||||
@ -395,8 +441,8 @@ func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
|
|||||||
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
|
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK:\n", sdkModule)
|
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK:\n", sdkModule)
|
||||||
fmt.Printf(" plugindev sdk install latest # 装一份到 ~/.homeagent/plugindev/sdk\n")
|
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
|
||||||
fmt.Printf(" plugindev build --sdk-path <路径> # 或直接指定源码目录\n")
|
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,17 +457,12 @@ func findLocalSDK(sdkPath string) string {
|
|||||||
candidates = append(candidates, abs)
|
candidates = append(candidates, abs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// plugindev 自身所在位置往上三级(tools/plugindev/plugindev → SDK 根)
|
// hmapdev 自身所在位置往上三级(tools/hmapdev/hmapdev → SDK 根)
|
||||||
if self, err := os.Executable(); err == nil {
|
if self, err := os.Executable(); err == nil {
|
||||||
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
|
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
|
||||||
}
|
}
|
||||||
// plugindev sdk use 选定的版本
|
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
|
||||||
store := os.Getenv("HOMEAGENT_SDK_DIR")
|
store := sdkStore()
|
||||||
if store == "" {
|
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
|
||||||
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if store != "" {
|
if store != "" {
|
||||||
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||||
if ver := strings.TrimSpace(string(d)); ver != "" {
|
if ver := strings.TrimSpace(string(d)); ver != "" {
|
||||||
@ -465,7 +506,7 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
|
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Detect from plugindev's own location (internal dev)
|
// Detect from hmapdev's own location (internal dev)
|
||||||
self, err := os.Executable()
|
self, err := os.Executable()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
||||||
@ -473,14 +514,8 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
return cand
|
return cand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Active SDK via plugindev sdk use
|
// Active SDK via hmapdev sdk use
|
||||||
store := os.Getenv("HOMEAGENT_SDK_DIR")
|
store := sdkStore()
|
||||||
if store == "" {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
if home != "" {
|
|
||||||
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if store != "" {
|
if store != "" {
|
||||||
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||||
ver := strings.TrimSpace(string(d))
|
ver := strings.TrimSpace(string(d))
|
||||||
@ -492,7 +527,7 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'plugindev sdk use'\n")
|
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -544,6 +579,13 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
}
|
}
|
||||||
defer runtimeCleanup()
|
defer runtimeCleanup()
|
||||||
|
|
||||||
|
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported)。
|
||||||
|
if err := checkTargetSupported(target); err != nil {
|
||||||
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
||||||
thirdpartCleanup := linkThirdpart(plg, target)
|
thirdpartCleanup := linkThirdpart(plg, target)
|
||||||
defer thirdpartCleanup()
|
defer thirdpartCleanup()
|
||||||
@ -562,6 +604,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7,12 +7,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi"
|
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tmplLuaDebug is the temporary Lua debug script template
|
// tmplLuaDebug is the temporary Lua debug script template
|
||||||
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
||||||
-- Generated by plugindev debug --lua
|
-- Generated by hmapdev debug --lua
|
||||||
sdk = require("sdk")
|
sdk = require("sdk")
|
||||||
local ok, plugin = pcall(dofile, "main.lua")
|
local ok, plugin = pcall(dofile, "main.lua")
|
||||||
if not ok then
|
if not ok then
|
||||||
@ -115,7 +115,7 @@ func debugLua(dir, sdkPath, luaPath string) {
|
|||||||
luaBin, err := exec.LookPath("lua")
|
luaBin, err := exec.LookPath("lua")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("error: lua interpreter not found in PATH")
|
fmt.Println("error: lua interpreter not found in PATH")
|
||||||
fmt.Println(" install Lua 5.1+ or use plugindev build to compile your plugin")
|
fmt.Println(" install Lua 5.1+ or use hmapdev build to compile your plugin")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ type TemplateData struct {
|
|||||||
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
||||||
//
|
//
|
||||||
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
||||||
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `plugindev build`
|
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `hmapdev build`
|
||||||
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
||||||
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
||||||
SDKLocalPath string
|
SDKLocalPath string
|
||||||
@ -71,7 +71,7 @@ type TemplateData struct {
|
|||||||
|
|
||||||
func cmdInit(args []string) {
|
func cmdInit(args []string) {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
|
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +192,7 @@ func cmdInit(args []string) {
|
|||||||
if isLua {
|
if isLua {
|
||||||
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||||||
}
|
}
|
||||||
fmt.Printf(" cd %s && plugindev build\n", dir)
|
fmt.Printf(" cd %s && hmapdev build\n", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
||||||
@ -14,7 +14,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const sdkDirName = "plugindev/sdk"
|
const sdkDirName = "hmapdev/sdk"
|
||||||
|
|
||||||
|
// legacySDKDirName 是改名前的存储目录。工具链在 1.2.0 从 plugindev 改名 hmapdev;
|
||||||
|
// 已装过旧版的机器上 SDK 仍在旧路径,直接换名会让它找不到已装 SDK
|
||||||
|
// (表现为「没有活动版本」)。新目录不存在而旧目录存在时沿用旧目录。
|
||||||
|
const legacySDKDirName = "plugindev/sdk"
|
||||||
|
|
||||||
// sdkStore returns the root directory for stored SDK versions.
|
// sdkStore returns the root directory for stored SDK versions.
|
||||||
func sdkStore() string {
|
func sdkStore() string {
|
||||||
@ -26,7 +31,15 @@ func sdkStore() string {
|
|||||||
fmt.Printf("error: cannot determine home directory: %v\n", err)
|
fmt.Printf("error: cannot determine home directory: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".homeagent", sdkDirName)
|
dir := filepath.Join(home, ".homeagent", sdkDirName)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if legacy := filepath.Join(home, ".homeagent", legacySDKDirName); legacy != "" {
|
||||||
|
if _, err := os.Stat(legacy); err == nil {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
func sdkCurrentDir() string {
|
func sdkCurrentDir() string {
|
||||||
@ -50,13 +63,13 @@ func cmdSDK(args []string) {
|
|||||||
cmdSDKList()
|
cmdSDKList()
|
||||||
case "install":
|
case "install":
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Println("Usage: plugindev sdk install <version>")
|
fmt.Println("Usage: hmapdev sdk install <version>")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cmdSDKInstall(args[1])
|
cmdSDKInstall(args[1])
|
||||||
case "use":
|
case "use":
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Println("Usage: plugindev sdk use <version>")
|
fmt.Println("Usage: hmapdev sdk use <version>")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cmdSDKUse(args[1])
|
cmdSDKUse(args[1])
|
||||||
@ -72,7 +85,7 @@ func cmdSDK(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sdkHelp() {
|
func sdkHelp() {
|
||||||
fmt.Print(`Usage: plugindev sdk <command>
|
fmt.Print(`Usage: hmapdev sdk <command>
|
||||||
|
|
||||||
Manage installed HomeAgent SDK versions.
|
Manage installed HomeAgent SDK versions.
|
||||||
|
|
||||||
@ -85,9 +98,9 @@ Commands:
|
|||||||
latest Show the latest available version from remote
|
latest Show the latest available version from remote
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
plugindev sdk install v0.7.1
|
hmapdev sdk install v0.7.1
|
||||||
plugindev sdk install latest
|
hmapdev sdk install latest
|
||||||
plugindev sdk use v0.7.1
|
hmapdev sdk use v0.7.1
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +140,7 @@ func cmdSDKList() {
|
|||||||
fmt.Printf(" %s %s\n", mark, v)
|
fmt.Printf(" %s %s\n", mark, v)
|
||||||
}
|
}
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Println("\nNo version active. Use 'plugindev sdk use <version>' to set one.")
|
fmt.Println("\nNo version active. Use 'hmapdev sdk use <version>' to set one.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,7 +305,7 @@ func cmdSDKUse(version string) {
|
|||||||
verDir := sdkVersionDir(version)
|
verDir := sdkVersionDir(version)
|
||||||
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
||||||
fmt.Printf("SDK version %s is not installed.\n", version)
|
fmt.Printf("SDK version %s is not installed.\n", version)
|
||||||
fmt.Printf("Install it first: plugindev sdk install %s\n", version)
|
fmt.Printf("Install it first: hmapdev sdk install %s\n", version)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
setCurrentVersion(store, version)
|
setCurrentVersion(store, version)
|
||||||
@ -305,7 +318,7 @@ func cmdSDKPath() {
|
|||||||
current := resolveCurrentVersion(store)
|
current := resolveCurrentVersion(store)
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Println("No active SDK version set.")
|
fmt.Println("No active SDK version set.")
|
||||||
fmt.Println("Use 'plugindev sdk use <version>' to set one.")
|
fmt.Println("Use 'hmapdev sdk use <version>' to set one.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println(sdkVersionDir(current))
|
fmt.Println(sdkVersionDir(current))
|
||||||
@ -440,21 +453,21 @@ func parseSemver(tag string) [3]int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// activeSDKRoot returns the path to the active SDK root.
|
// activeSDKRoot returns the path to the active SDK root.
|
||||||
// It replaces the old runtime.Caller(0) approach so plugindev can work
|
// It replaces the old runtime.Caller(0) approach so hmapdev can work
|
||||||
// independently of its own build location.
|
// independently of its own build location.
|
||||||
func activeSDKRoot() string {
|
func activeSDKRoot() string {
|
||||||
store := sdkStore()
|
store := sdkStore()
|
||||||
current := resolveCurrentVersion(store)
|
current := resolveCurrentVersion(store)
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Printf("error: no active SDK version set\n")
|
fmt.Printf("error: no active SDK version set\n")
|
||||||
fmt.Printf(" Install one: plugindev sdk install latest\n")
|
fmt.Printf(" Install one: hmapdev sdk install latest\n")
|
||||||
fmt.Printf(" Or set one: plugindev sdk use <version>\n")
|
fmt.Printf(" Or set one: hmapdev sdk use <version>\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
root := sdkVersionDir(current)
|
root := sdkVersionDir(current)
|
||||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
|
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
|
||||||
fmt.Printf(" Reinstall: plugindev sdk install %s\n", current)
|
fmt.Printf(" Reinstall: hmapdev sdk install %s\n", current)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return root
|
return root
|
||||||
@ -1,4 +1,4 @@
|
|||||||
module github.com/JianFeeeee/homeagent-sdk/tools/plugindev
|
module gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev
|
||||||
|
|
||||||
go 1.21.0
|
go 1.21.0
|
||||||
|
|
||||||
@ -30,14 +30,14 @@ func help() {
|
|||||||
fmt.Print(`HomeAgent Plugin Dev Tool
|
fmt.Print(`HomeAgent Plugin Dev Tool
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
plugindev init <name> Scaffold a new plugin project
|
hmapdev init <name> Scaffold a new plugin project
|
||||||
plugindev init <name> --lua Create Lua plugin
|
hmapdev init <name> --lua Create Lua plugin
|
||||||
plugindev init <name> --type remotedevice
|
hmapdev init <name> --type remotedevice
|
||||||
Create C remote device adapter
|
Create C remote device adapter
|
||||||
plugindev build [flags] Compile and package plugin
|
hmapdev build [flags] Compile and package plugin
|
||||||
plugindev clean Clean build/dist artifacts
|
hmapdev clean Clean build/dist artifacts
|
||||||
plugindev debug [dir] Interpret and debug plugin source
|
hmapdev debug [dir] Interpret and debug plugin source
|
||||||
plugindev sdk <command> Manage SDK versions
|
hmapdev sdk <command> Manage SDK versions
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--outdir Output directory (default: dist)
|
--outdir Output directory (default: dist)
|
||||||
@ -56,7 +56,7 @@ const procGenFile = "z_proc_gen.go"
|
|||||||
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
|
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
|
||||||
// 写入插件目录,返回清理函数。
|
// 写入插件目录,返回清理函数。
|
||||||
func generateProcRuntime() (func(), error) {
|
func generateProcRuntime() (func(), error) {
|
||||||
// 清理历史 C ABI 产物:旧版 plugindev 生成过这两个文件,残留下来会与
|
// 清理历史 C ABI 产物:旧版 hmapdev(原名 plugindev)生成过这两个文件,残留下来会与
|
||||||
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
|
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
|
||||||
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
|
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
|
||||||
os.Remove(stale)
|
os.Remove(stale)
|
||||||
@ -92,8 +92,9 @@ func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
|||||||
required := []string{
|
required := []string{
|
||||||
// 注册面
|
// 注册面
|
||||||
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
||||||
// IO 注入
|
// IO 注入(io.injectTextNoMem 见下方 deprecated:内核保留为兼容旧二进制,
|
||||||
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
|
// 当前模板改走 io.injectText + NoMemory 标志位,不再发那个 id)
|
||||||
|
"io.injectText", "io.injectInterrupt", "io.injectInputSync",
|
||||||
"io.setToolBlocks",
|
"io.setToolBlocks",
|
||||||
// 多模态注入(1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
|
// 多模态注入(1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
|
||||||
// 模板不发这个 RPC,内核也就永远收不到,而两边都不报错。
|
// 模板不发这个 RPC,内核也就永远收不到,而两边都不报错。
|
||||||
@ -131,15 +132,37 @@ func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
|||||||
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内核保留、但**当前模板不再发送**的 method id。
|
||||||
|
//
|
||||||
|
// 它们不是「公开接口新增却忘记接线」,而是刻意的向后兼容面:
|
||||||
|
// 内核必须继续接受用旧模板编出的插件二进制发来的 id,而当前模板没有理由再发。
|
||||||
|
//
|
||||||
|
// 可复查的判据:ba49dfd 之前的模板里 InjectTextNoMemory 发的就是
|
||||||
|
// "io.injectTextNoMem";注入标志位落地后它改走 "io.injectText" + NoMemory。
|
||||||
|
//
|
||||||
|
// 为何要单独列而不是直接从 required 删掉:这条守卫的价值在于「新接口必须接线」,
|
||||||
|
// 而把「内核有、模板就必须发」当不变量,会让它常驻误报——常驻误报的守卫迟早
|
||||||
|
// 被人习惯性忽略,那时真漏接线也就没人看见了。
|
||||||
|
deprecated := map[string]string{
|
||||||
|
"io.injectTextNoMem": "旧模板经此表达「不进记忆」;现由 io.injectText + NoMemory 表达",
|
||||||
|
}
|
||||||
|
// 反向保护:allowlist 条目一旦又出现在模板里,说明它已过期,必须删掉,
|
||||||
|
// 否则这里会悄悄变成「永久豁免」的垃圾抽屉。
|
||||||
|
for m, why := range deprecated {
|
||||||
|
if strings.Contains(src, `"`+m+`"`) {
|
||||||
|
t.Errorf("deprecated 里的 %q 又出现在模板里(%s)——条目已过期,请从 deprecated 移除", m, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export)。
|
// 模板必须处理内核发来的全部调用(含无法 JSON 序列化的 Cleaner 回调)。
|
||||||
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
||||||
src := loadProcTemplate(t)
|
src := loadProcTemplate(t)
|
||||||
for _, m := range []string{
|
for _, m := range []string{
|
||||||
"handshake",
|
"handshake",
|
||||||
"plugin.init", "plugin.start", "plugin.stop",
|
"plugin.init", "plugin.start", "plugin.stop",
|
||||||
"tool.invoke", "stage.invoke", "output.invoke",
|
"tool.invoke", "cleaner.invoke", "stage.invoke", "output.invoke",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(src, `case "`+m+`"`) {
|
if !strings.Contains(src, `case "`+m+`"`) {
|
||||||
t.Errorf("模板未处理内核调用 %q", m)
|
t.Errorf("模板未处理内核调用 %q", m)
|
||||||
@ -147,6 +170,45 @@ func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具调用的 payload 必须走内核标定的**调用帧**(funccall 模型)。
|
||||||
|
//
|
||||||
|
// 共享内存是内核内部实现(插件作者只看到普通 map),但模板必须在传输层
|
||||||
|
// 正确读写 frame / args_len / result_ref。漏接线的后果很隐蔽:参数被静默
|
||||||
|
// 丢弃、结果只走内联,性能退化而不报错。
|
||||||
|
func TestProcTemplate_ToolInvokeUsesSharedRef(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
for _, want := range []string{"frame", "args_len", "result_ref"} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("模板的 tool.invoke 必须处理 %q(payload 走内核标定的调用帧)", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板必须通过 arena.alloc / arena.free 向内核申请与归还共享内存。
|
||||||
|
//
|
||||||
|
// 共享内存是内核独占管理的**内部实现**:插件不能自己维护分配游标。
|
||||||
|
// 历史上两版跨进程分配器(bump 游标 / 模板内位图 CAS)都因为把可变
|
||||||
|
// 分配状态放在共享内存里而出竞态,所以这里做回归保护。
|
||||||
|
func TestProcTemplate_UsesKernelArenaRPC(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
for _, m := range []string{`"arena.alloc"`, `"arena.free"`} {
|
||||||
|
if !strings.Contains(src, m) {
|
||||||
|
t.Errorf("模板缺少内核共享内存 RPC %s(插件必须向内核申请/归还)", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止插件侧再出现本地分配器符号。
|
||||||
|
//
|
||||||
|
// 只查代码不查注释:注释里会解释“为什么不再这么做”。
|
||||||
|
code := stripComments(t, src)
|
||||||
|
for _, forbidden := range []string{"arenaUsed", "arenaWrite"} {
|
||||||
|
if strings.Contains(code, forbidden) {
|
||||||
|
t.Errorf("模板不应再出现插件侧分配器 %q(共享内存由内核独占管理)", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
||||||
//
|
//
|
||||||
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
||||||
@ -281,10 +343,11 @@ func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
|
// 协议与共享内存区域版本/魔数不匹配必须拒绝,不得半兼容运行。
|
||||||
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
||||||
src := loadProcTemplate(t)
|
src := loadProcTemplate(t)
|
||||||
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
|
// §13.1 起共享段合并为单一「统一区域」,魔数校验文案随之更新。
|
||||||
|
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "统一区域魔数不匹配"} {
|
||||||
if !strings.Contains(src, want) {
|
if !strings.Contains(src, want) {
|
||||||
t.Errorf("握手应校验并拒绝 %q", want)
|
t.Errorf("握手应校验并拒绝 %q", want)
|
||||||
}
|
}
|
||||||
@ -511,7 +511,7 @@ const tmplReadme = `# {{.Plg.Name}}
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
` + "```bash" + `
|
` + "```bash" + `
|
||||||
plugindev build
|
hmapdev build
|
||||||
` + "```" + `
|
` + "```" + `
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
@ -25,9 +25,35 @@ import (
|
|||||||
|
|
||||||
// ---- 协议常量(须与内核 internal/plugin/proc/protocol.go 一致)----
|
// ---- 协议常量(须与内核 internal/plugin/proc/protocol.go 一致)----
|
||||||
|
|
||||||
const procProtocolVersion = 1
|
// procProtocolVersion 必须与内核的 proc.ProtocolVersion 完全一致。
|
||||||
|
//
|
||||||
|
// v2:内核→插件的 payload 改用调用帧(tool/cleaner/output),媒体块改走
|
||||||
|
// blocks_ref。v1 插件只读内联 args,遇上 v2 内核会拿到空参数;反过来 v2
|
||||||
|
// 插件发 blocks_ref,v1 内核也会静默忽略。两边错配都不报错、只是静默失效,
|
||||||
|
// 所以靠这个常量在握手上显式拦下。
|
||||||
|
const procProtocolVersion = 2
|
||||||
|
|
||||||
// ---- 共享段布局(须与内核 internal/plugin/proc/shm.go 一致)----
|
// ---- 统一共享内存区域布局(与内核 internal/plugin/proc/unified.go 一致)----
|
||||||
|
|
||||||
|
const (
|
||||||
|
unifiedMagic = 0x554D5352 // "UMSR" — Unified Memory Shared Region
|
||||||
|
unifiedVersion = 1
|
||||||
|
superBlockSize = 64
|
||||||
|
|
||||||
|
sbOffMagic = 0
|
||||||
|
sbOffVersion = 4
|
||||||
|
sbOffGeneration = 8
|
||||||
|
sbOffCapacity = 16
|
||||||
|
sbOffCtxOff = 20
|
||||||
|
sbOffCtxSize = 24
|
||||||
|
sbOffEvtOff = 28
|
||||||
|
sbOffEvtSize = 32
|
||||||
|
sbOffArenaOff = 36
|
||||||
|
sbOffArenaCap = 40
|
||||||
|
sbOffArenaUsed = 44
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- StageContext 段内部布局(与内核 internal/plugin/proc/shm.go 一致)----
|
||||||
|
|
||||||
const (
|
const (
|
||||||
shmStageFieldCount = 18
|
shmStageFieldCount = 18
|
||||||
@ -72,6 +98,197 @@ const (
|
|||||||
flagResponseSet = 1
|
flagResponseSet = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SharedRef 跨进程共享内存描述符。
|
||||||
|
//
|
||||||
|
// ⚠️ 这是**内部实现细节**:插件开发者永远看不到它。公开 SDK 只暴露普通
|
||||||
|
// 字符串与 Map;模板运行时在传输层按 payload 大小自动选择内联 JSON 还是
|
||||||
|
// 共享槽。直接使用 SharedRef 属于运行时内部行为,不是插件 API。
|
||||||
|
type SharedRef struct {
|
||||||
|
Offset uint32 `json:"offset"`
|
||||||
|
Length uint32 `json:"length"`
|
||||||
|
Generation uint32 `json:"generation"`
|
||||||
|
Flags uint32 `json:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r SharedRef) IsZero() bool { return r.Offset == 0 && r.Length == 0 }
|
||||||
|
func (r SharedRef) Slice(data []byte) []byte {
|
||||||
|
if r.IsZero() || int(r.Offset)+int(r.Length) > len(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data[r.Offset : r.Offset+r.Length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedRef.Flags 语义位(须与内核 internal/plugin/proc/arena.go 一致)。
|
||||||
|
const (
|
||||||
|
sharedRefFlagJSON = 1 << 0 // 载荷是 JSON
|
||||||
|
sharedRefFlagExpand = 1 << 1 // 引用指向插件申请的扩容块
|
||||||
|
)
|
||||||
|
|
||||||
|
// region 是内核传入的统一共享区域 mmap(handshake 时设置)。
|
||||||
|
var region []byte
|
||||||
|
|
||||||
|
// arenaAlloc 向内核申请一块共享内存,内核返回偏移与大小(Length 为槽容量)。
|
||||||
|
//
|
||||||
|
// 分配器由内核独占管理(见内核 proc/arena.go):插件只申请与归还,
|
||||||
|
// 不做任何分配决策,因此不存在跨进程分配器的竞争。
|
||||||
|
func arenaAlloc(size uint32) (SharedRef, error) {
|
||||||
|
raw, err := callCore("arena.alloc", map[string]interface{}{"size": size})
|
||||||
|
if err != nil {
|
||||||
|
return SharedRef{}, err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Ref SharedRef `json:"ref"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return SharedRef{}, err
|
||||||
|
}
|
||||||
|
if r.Ref.IsZero() {
|
||||||
|
return SharedRef{}, fmt.Errorf("arena.alloc: 内核返回空引用")
|
||||||
|
}
|
||||||
|
return r.Ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// arenaFree 通知内核回收先前申请的共享内存。
|
||||||
|
func arenaFree(ref SharedRef) {
|
||||||
|
if ref.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callCoreVoid("arena.free", map[string]interface{}{"ref": ref})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 调用帧(funccall 模型)辅助 ----
|
||||||
|
//
|
||||||
|
// 工具调用/清洗由内核发起:内核标定一块内存帧交给插件,插件在帧内工作,
|
||||||
|
// 只有结果超出内核预留的预算时才向内核申请扩容块。
|
||||||
|
|
||||||
|
// frameInput 返回帧内的输入段(内核写入的参数/输入文本)。
|
||||||
|
func frameInput(frame SharedRef, inputLen uint32) []byte {
|
||||||
|
if frame.IsZero() || int(inputLen) > len(frame.Slice(region)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return frame.Slice(region)[:inputLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameOutput 尝试把 payload 写进帧的结果区(帧内 [inputLen, frame.Length))。
|
||||||
|
// 放不下时返回错误,由调用方决定是否申请扩容块。
|
||||||
|
func frameOutput(frame SharedRef, inputLen uint32, payload []byte, jsonFlag bool) (SharedRef, error) {
|
||||||
|
if frame.IsZero() {
|
||||||
|
return SharedRef{}, fmt.Errorf("无调用帧")
|
||||||
|
}
|
||||||
|
area := frame.Slice(region)
|
||||||
|
start := int(inputLen)
|
||||||
|
if start > len(area) || len(payload) > len(area)-start {
|
||||||
|
return SharedRef{}, fmt.Errorf("帧内空间不足(需 %d,剩 %d)", len(payload), len(area)-start)
|
||||||
|
}
|
||||||
|
copy(region[frame.Offset+uint32(start):], payload)
|
||||||
|
ref := SharedRef{
|
||||||
|
Offset: frame.Offset + uint32(start),
|
||||||
|
Length: uint32(len(payload)),
|
||||||
|
Generation: frame.Generation,
|
||||||
|
}
|
||||||
|
if jsonFlag {
|
||||||
|
ref.Flags |= sharedRefFlagJSON
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// arenaPut 申请一块扩容块并写入 payload,引用上打 sharedRefFlagExpand
|
||||||
|
// 告知内核该块需单独归还(插件只申请,回收由内核做)。
|
||||||
|
func arenaPut(payload []byte, jsonFlag bool) (SharedRef, error) {
|
||||||
|
ref, err := arenaAlloc(uint32(len(payload)))
|
||||||
|
if err != nil {
|
||||||
|
return SharedRef{}, err
|
||||||
|
}
|
||||||
|
if len(payload) > int(ref.Length) {
|
||||||
|
arenaFree(ref)
|
||||||
|
return SharedRef{}, fmt.Errorf("扩容块容量不足(需 %d,得 %d)", len(payload), ref.Length)
|
||||||
|
}
|
||||||
|
copy(region[ref.Offset:ref.Offset+uint32(len(payload))], payload)
|
||||||
|
ref.Length = uint32(len(payload))
|
||||||
|
ref.Flags |= sharedRefFlagExpand
|
||||||
|
if jsonFlag {
|
||||||
|
ref.Flags |= sharedRefFlagJSON
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlinePayloadLimit 是走内联 JSON 的上限。
|
||||||
|
//
|
||||||
|
// 小 payload 走内联省两次 RPC(申请 + 归还);大 payload 走共享内存,
|
||||||
|
// 避免把长文本塞进 NDJSON 帧。这是纯传输层优化,插件开发者无感。
|
||||||
|
const inlinePayloadLimit = 512
|
||||||
|
|
||||||
|
// putInArena 把 payload 写入内核分配的共享槽,返回可随业务 RPC 回传的引用。
|
||||||
|
//
|
||||||
|
// 任一步失败都返回 ok=false,让调用方退回内联:共享内存只是优化,
|
||||||
|
// 池满或超限绝不能影响功能。
|
||||||
|
func putInArena(payload string) (SharedRef, bool) {
|
||||||
|
if len(payload) <= inlinePayloadLimit || len(region) == 0 {
|
||||||
|
return SharedRef{}, false
|
||||||
|
}
|
||||||
|
ref, err := arenaAlloc(uint32(len(payload)))
|
||||||
|
if err != nil {
|
||||||
|
return SharedRef{}, false
|
||||||
|
}
|
||||||
|
if len(payload) > int(ref.Length) || int(ref.Offset)+len(payload) > len(region) {
|
||||||
|
arenaFree(ref)
|
||||||
|
return SharedRef{}, false
|
||||||
|
}
|
||||||
|
copy(region[ref.Offset:ref.Offset+uint32(len(payload))], payload)
|
||||||
|
ref.Length = uint32(len(payload))
|
||||||
|
return ref, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// putValueInArena 把任意值 JSON 序列化后放进共享槽,太小或 arena 不可用时
|
||||||
|
// 返回 false(调用方退到内联)。
|
||||||
|
func putValueInArena(v interface{}) (SharedRef, bool) {
|
||||||
|
blob, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return SharedRef{}, false
|
||||||
|
}
|
||||||
|
return putInArena(string(blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithText 按 payload 大小自动选择共享槽或内联,发起一次带文本的业务 RPC。
|
||||||
|
//
|
||||||
|
// 共享内存对插件开发者完全透明:SDK 层只看得到 string。
|
||||||
|
func callWithText(method, source, channel, text string) (json.RawMessage, error) {
|
||||||
|
return callWithTextOpts(method, source, channel, text, sdk.InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithTextOpts 是 callWithText 的带标志位版本。
|
||||||
|
//
|
||||||
|
// 只在标志位非零时才写入参数:零值(记入记忆 + 不裁剪)与旧参数形态完全一致,
|
||||||
|
// 便于内核侧做兼容与灰度。
|
||||||
|
func callWithTextOpts(method, source, channel, text string, opts sdk.InjectOptions) (json.RawMessage, error) {
|
||||||
|
if ref, ok := putInArena(text); ok {
|
||||||
|
defer arenaFree(ref)
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"source": source, "channel": channel, "text_ref": ref,
|
||||||
|
}
|
||||||
|
applyInjectOpts(args, opts)
|
||||||
|
return callCore(method, args)
|
||||||
|
}
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"source": source, "channel": channel, "text": text,
|
||||||
|
}
|
||||||
|
applyInjectOpts(args, opts)
|
||||||
|
return callCore(method, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyInjectOpts 把 InjectOptions 摊进注入参数字典(仅非零值)。
|
||||||
|
func applyInjectOpts(args map[string]interface{}, opts sdk.InjectOptions) {
|
||||||
|
if opts.NoMemory {
|
||||||
|
args["no_memory"] = true
|
||||||
|
}
|
||||||
|
if opts.ContextPolicy != "" {
|
||||||
|
args["context_policy"] = opts.ContextPolicy
|
||||||
|
}
|
||||||
|
if opts.CleanerName != "" {
|
||||||
|
args["cleaner_name"] = opts.CleanerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 全局状态 ----
|
// ---- 全局状态 ----
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -88,15 +305,20 @@ var (
|
|||||||
|
|
||||||
handlerMu sync.RWMutex
|
handlerMu sync.RWMutex
|
||||||
toolHandlers = map[string]sdk.ToolHandler{}
|
toolHandlers = map[string]sdk.ToolHandler{}
|
||||||
|
toolCleaners = map[string]func(string) string{}
|
||||||
|
inputCleaners = map[string]func(string) string{}
|
||||||
stageHandlers = map[string]sdk.StageHandler{}
|
stageHandlers = map[string]sdk.StageHandler{}
|
||||||
outputHandlers = map[string]sdk.ToolHandler{}
|
outputHandlers = map[string]sdk.ToolHandler{}
|
||||||
|
outputCleaners = map[string]func(string) string{}
|
||||||
|
|
||||||
shm []byte
|
shm []byte
|
||||||
|
|
||||||
|
// region 见文件头部 SharedRef 注释(handshake 时设置)。
|
||||||
|
|
||||||
// 事件环(§3.6):fd 4 = 事件环段 mmap,fd 5 = eventfd 读端
|
// 事件环(§3.6):fd 4 = 事件环段 mmap,fd 5 = eventfd 读端
|
||||||
evtRingData []byte
|
evtRingData []byte
|
||||||
evtNotifier evtWaiter
|
evtNotifier evtWaiter
|
||||||
evtHandlers = map[uint32]func(*sdk.Event){}
|
evtHandlers = map[uint32]func(*sdk.Event){}
|
||||||
evtHandlerMu sync.RWMutex
|
evtHandlerMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -416,9 +638,14 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
handlerMu.Lock()
|
handlerMu.Lock()
|
||||||
toolHandlers[toolName] = handler
|
toolHandlers[toolName] = handler
|
||||||
|
if def.Cleaner != nil {
|
||||||
|
toolCleaners[toolName] = def.Cleaner
|
||||||
|
} else {
|
||||||
|
delete(toolCleaners, toolName)
|
||||||
|
}
|
||||||
handlerMu.Unlock()
|
handlerMu.Unlock()
|
||||||
return callCoreVoid("tool.register", map[string]interface{}{
|
return callCoreVoid("tool.register", map[string]interface{}{
|
||||||
"name": toolName, "def": def,
|
"name": toolName, "def": def, "has_cleaner": def.Cleaner != nil,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
func(stage sdk.Stage, handler sdk.StageHandler) {
|
func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
@ -437,10 +664,16 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
func(chName string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
|
func(chName string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
|
||||||
handlerMu.Lock()
|
handlerMu.Lock()
|
||||||
outputHandlers[chName] = handler
|
outputHandlers[chName] = handler
|
||||||
|
if def.Cleaner != nil {
|
||||||
|
outputCleaners[chName] = def.Cleaner
|
||||||
|
} else {
|
||||||
|
delete(outputCleaners, chName)
|
||||||
|
}
|
||||||
handlerMu.Unlock()
|
handlerMu.Unlock()
|
||||||
return callCoreVoid("output.register", map[string]interface{}{
|
return callCoreVoid("output.register", map[string]interface{}{
|
||||||
"name": chName, "caps": caps, "desc": desc,
|
"name": chName, "caps": caps, "desc": desc,
|
||||||
"def": map[string]interface{}{"NoMemory": def.NoMemory},
|
"def": map[string]interface{}{"NoMemory": def.NoMemory},
|
||||||
|
"has_cleaner": def.Cleaner != nil,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -454,9 +687,19 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
base.SetTextMemoryAPI(procTextMemory{})
|
base.SetTextMemoryAPI(procTextMemory{})
|
||||||
base.SetPluginMgrAPI(procPluginMgr{})
|
base.SetPluginMgrAPI(procPluginMgr{})
|
||||||
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
|
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
|
||||||
|
handlerMu.Lock()
|
||||||
|
if def.Cleaner != nil {
|
||||||
|
inputCleaners[chName] = def.Cleaner
|
||||||
|
} else {
|
||||||
|
delete(inputCleaners, chName)
|
||||||
|
}
|
||||||
|
handlerMu.Unlock()
|
||||||
return callCoreVoid("input.register", map[string]interface{}{
|
return callCoreVoid("input.register", map[string]interface{}{
|
||||||
"name": chName,
|
"name": chName,
|
||||||
"def": map[string]interface{}{"NoMemory": def.NoMemory},
|
// 整个结构体:手写字段白名单会把新增字段静默丢掉
|
||||||
|
// (ChannelDef.Cleaner 已标 json:"-",可以整体 marshal)。
|
||||||
|
"def": def,
|
||||||
|
"has_cleaner": def.Cleaner != nil,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return base
|
return base
|
||||||
@ -464,17 +707,32 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
|
|
||||||
type procIO struct{}
|
type procIO struct{}
|
||||||
|
|
||||||
|
// 下面六个三参数方法是 *Opts 变体的零值糖:记入记忆 + 不裁剪。
|
||||||
|
|
||||||
func (procIO) InjectText(s, c, t string) {
|
func (procIO) InjectText(s, c, t string) {
|
||||||
callCoreVoid("io.injectText", map[string]string{"source": s, "channel": c, "text": t})
|
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{})
|
||||||
}
|
}
|
||||||
func (procIO) InjectInterruptText(s, c, t string) {
|
func (procIO) InjectInterruptText(s, c, t string) {
|
||||||
callCoreVoid("io.injectInterrupt", map[string]string{"source": s, "channel": c, "text": t})
|
procIO{}.InjectInterruptTextOpts(s, c, t, sdk.InjectOptions{})
|
||||||
}
|
}
|
||||||
func (procIO) InjectTextNoMemory(s, c, t string) {
|
func (procIO) InjectTextNoMemory(s, c, t string) {
|
||||||
callCoreVoid("io.injectTextNoMem", map[string]string{"source": s, "channel": c, "text": t})
|
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
func (procIO) InjectInputSync(s, c, t string) string {
|
func (procIO) InjectInputSync(s, c, t string) string {
|
||||||
raw, err := callCore("io.injectInputSync", map[string]string{"source": s, "channel": c, "text": t})
|
return procIO{}.InjectInputSyncOpts(s, c, t, sdk.InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以下为带标志位的注入:opts 决定这次注入是否进记忆、是否据此裁剪上下文。
|
||||||
|
|
||||||
|
func (procIO) InjectTextOpts(s, c, t string, opts sdk.InjectOptions) {
|
||||||
|
// 忽略错误:注入是 fire-and-forget,与内联路径语义一致
|
||||||
|
_, _ = callWithTextOpts("io.injectText", s, c, t, opts)
|
||||||
|
}
|
||||||
|
func (procIO) InjectInterruptTextOpts(s, c, t string, opts sdk.InjectOptions) {
|
||||||
|
_, _ = callWithTextOpts("io.injectInterrupt", s, c, t, opts)
|
||||||
|
}
|
||||||
|
func (procIO) InjectInputSyncOpts(s, c, t string, opts sdk.InjectOptions) string {
|
||||||
|
raw, err := callWithTextOpts("io.injectInputSync", s, c, t, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -485,7 +743,18 @@ func (procIO) InjectInputSync(s, c, t string) string {
|
|||||||
return r.Reply
|
return r.Reply
|
||||||
}
|
}
|
||||||
func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
|
func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
|
||||||
if err := callCoreVoid("io.setToolBlocks", map[string]interface{}{"blocks": blocks}); err != nil {
|
// 媒体块经共享内存(blocks_ref):本地生成的图/音频是 base64 data URL,
|
||||||
|
// 一张图可达数 MB;内联时整份 base64 还要在 RPC 报文里再编码/再拷贝一遍。
|
||||||
|
// 更重要的是内容本体落在共享段里,插件回调才能就地改写。
|
||||||
|
// 小 payload(如纯文本块)仍走内联,省一次 RPC。
|
||||||
|
args := map[string]interface{}{}
|
||||||
|
if ref, ok := putValueInArena(blocks); ok {
|
||||||
|
defer arenaFree(ref)
|
||||||
|
args["blocks_ref"] = ref
|
||||||
|
} else {
|
||||||
|
args["blocks"] = blocks
|
||||||
|
}
|
||||||
|
if err := callCoreVoid("io.setToolBlocks", args); err != nil {
|
||||||
log.Printf("SetToolBlocks: %v", err)
|
log.Printf("SetToolBlocks: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -493,15 +762,29 @@ func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
|
|||||||
// 带媒体的注入:插件主动发起一轮带图/音频的对话。
|
// 带媒体的注入:插件主动发起一轮带图/音频的对话。
|
||||||
// 与 SetToolBlocks 的区别是媒体在**本轮**就到模型手上,而不是等下一条 tool message。
|
// 与 SetToolBlocks 的区别是媒体在**本轮**就到模型手上,而不是等下一条 tool message。
|
||||||
func (procIO) InjectInputMedia(s, c, t string, blocks []sdk.ContentBlock) {
|
func (procIO) InjectInputMedia(s, c, t string, blocks []sdk.ContentBlock) {
|
||||||
callCoreVoid("io.injectMedia", map[string]interface{}{
|
procIO{}.InjectInputMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
|
||||||
"source": s, "channel": c, "text": t, "blocks": blocks,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) string {
|
func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) string {
|
||||||
raw, err := callCore("io.injectMediaSync", map[string]interface{}{
|
return procIO{}.InjectInputMediaSyncOpts(s, c, t, blocks, sdk.InjectOptions{})
|
||||||
"source": s, "channel": c, "text": t, "blocks": blocks,
|
}
|
||||||
})
|
|
||||||
|
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
|
||||||
|
procIO{}.InjectInterruptMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (procIO) InjectInputMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
|
||||||
|
args, free := mediaArgsOwned(s, c, t, blocks)
|
||||||
|
defer free()
|
||||||
|
applyInjectOpts(args, opts)
|
||||||
|
callCoreVoid("io.injectMedia", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (procIO) InjectInputMediaSyncOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) string {
|
||||||
|
args, free := mediaArgsOwned(s, c, t, blocks)
|
||||||
|
defer free()
|
||||||
|
applyInjectOpts(args, opts)
|
||||||
|
raw, err := callCore("io.injectMediaSync", args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -512,10 +795,26 @@ func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) st
|
|||||||
return r.Reply
|
return r.Reply
|
||||||
}
|
}
|
||||||
|
|
||||||
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
|
func (procIO) InjectInterruptMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
|
||||||
callCoreVoid("io.injectInterruptMedia", map[string]interface{}{
|
args, free := mediaArgsOwned(s, c, t, blocks)
|
||||||
"source": s, "channel": c, "text": t, "blocks": blocks,
|
defer free()
|
||||||
})
|
applyInjectOpts(args, opts)
|
||||||
|
callCoreVoid("io.injectInterruptMedia", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaArgsOwned 构造媒体注入参数,并返回释放函数。
|
||||||
|
//
|
||||||
|
// 为什么要返回释放函数而不是自己 defer:调用方可能是需要等应答的同步调用
|
||||||
|
// (injectMediaSync),槽在应答到达前不能被回收,否则内核读到的是已释放的内存。
|
||||||
|
func mediaArgsOwned(s, c, t string, blocks []sdk.ContentBlock) (map[string]interface{}, func()) {
|
||||||
|
args := map[string]interface{}{"source": s, "channel": c, "text": t}
|
||||||
|
ref, ok := putValueInArena(blocks)
|
||||||
|
if !ok {
|
||||||
|
args["blocks"] = blocks
|
||||||
|
return args, func() {}
|
||||||
|
}
|
||||||
|
args["blocks_ref"] = ref
|
||||||
|
return args, func() { arenaFree(ref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
type procMemory struct{}
|
type procMemory struct{}
|
||||||
@ -583,7 +882,19 @@ func (procDocMemory) Query(text string, topK int) []*sdk.Doc {
|
|||||||
return r.Docs
|
return r.Docs
|
||||||
}
|
}
|
||||||
func (procDocMemory) Insert(d *sdk.Doc) error {
|
func (procDocMemory) Insert(d *sdk.Doc) error {
|
||||||
return callCoreVoid("doc.insert", map[string]interface{}{"doc": d})
|
return callCoreVoid("doc.insert", docInsertArgs(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// docInsertArgs 构造 doc.insert 参数:正文优先走共享内存。
|
||||||
|
//
|
||||||
|
// doc_content 可达几十 KB~数 MB,内联时整份要在 RPC 报文里再编码再拷贝一遍;
|
||||||
|
// 且内容本体落在共享段里,插件回调才能就地改写。小文档仍内联。
|
||||||
|
func docInsertArgs(d *sdk.Doc) map[string]interface{} {
|
||||||
|
if ref, ok := putValueInArena(d); ok {
|
||||||
|
defer arenaFree(ref)
|
||||||
|
return map[string]interface{}{"doc_ref": ref}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"doc": d}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertWithMedia 写入文档并关联媒体。
|
// InsertWithMedia 写入文档并关联媒体。
|
||||||
@ -591,9 +902,28 @@ func (procDocMemory) Insert(d *sdk.Doc) error {
|
|||||||
// 内核会把 `[mime <短digest>] <描述>` 标记补进 Content 并挂上引用,回传的
|
// 内核会把 `[mime <短digest>] <描述>` 标记补进 Content 并挂上引用,回传的
|
||||||
// doc 带着补好的 Content/ID/MediaDigests——回写进 d 让调用方能拿到这些。
|
// doc 带着补好的 Content/ID/MediaDigests——回写进 d 让调用方能拿到这些。
|
||||||
func (procDocMemory) InsertWithMedia(d *sdk.Doc, atts []sdk.MediaAttachment) error {
|
func (procDocMemory) InsertWithMedia(d *sdk.Doc, atts []sdk.MediaAttachment) error {
|
||||||
raw, err := callCore("doc.insertWithMedia", map[string]interface{}{
|
// 文档正文与附件(含媒体 data URL)都优先走共享内存。
|
||||||
"doc": d, "attachments": atts,
|
args := map[string]interface{}{}
|
||||||
})
|
var frees []func()
|
||||||
|
defer func() {
|
||||||
|
for _, f := range frees {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if ref, ok := putValueInArena(d); ok {
|
||||||
|
frees = append(frees, func() { arenaFree(ref) })
|
||||||
|
args["doc_ref"] = ref
|
||||||
|
} else {
|
||||||
|
args["doc"] = d
|
||||||
|
}
|
||||||
|
if ref, ok := putValueInArena(atts); ok {
|
||||||
|
frees = append(frees, func() { arenaFree(ref) })
|
||||||
|
args["attachments_ref"] = ref
|
||||||
|
} else {
|
||||||
|
args["attachments"] = atts
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := callCore("doc.insertWithMedia", args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -632,6 +962,13 @@ func (procKnowledge) Search(q string, topK int) ([]*sdk.Knowledge, error) {
|
|||||||
return r.Results, nil
|
return r.Results, nil
|
||||||
}
|
}
|
||||||
func (procKnowledge) Add(name, content string) error {
|
func (procKnowledge) Add(name, content string) error {
|
||||||
|
// 知识正文可达数十 KB,优先走共享内存(内容是 JSON 字符串)。
|
||||||
|
if ref, ok := putValueInArena(content); ok {
|
||||||
|
defer arenaFree(ref)
|
||||||
|
return callCoreVoid("knowledge.add", map[string]interface{}{
|
||||||
|
"name": name, "content_ref": ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
return callCoreVoid("knowledge.add", map[string]string{"name": name, "content": content})
|
return callCoreVoid("knowledge.add", map[string]string{"name": name, "content": content})
|
||||||
}
|
}
|
||||||
func (procKnowledge) List() ([]string, error) {
|
func (procKnowledge) List() ([]string, error) {
|
||||||
@ -943,10 +1280,27 @@ func handleKernelRequest(req *rpcRequest) {
|
|||||||
|
|
||||||
case "tool.invoke":
|
case "tool.invoke":
|
||||||
var p struct {
|
var p struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Args map[string]interface{} `json:"args"`
|
Args map[string]interface{} `json:"args"`
|
||||||
|
Frame SharedRef `json:"frame"`
|
||||||
|
ArgsLen uint32 `json:"args_len"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(req.Params, &p)
|
json.Unmarshal(req.Params, &p)
|
||||||
|
|
||||||
|
// 参数:内核标定帧的前段。只有直连 RPC 的调用方(无帧)才走
|
||||||
|
// 内联 Args——生产路径永远走帧。
|
||||||
|
args := p.Args
|
||||||
|
if !p.Frame.IsZero() {
|
||||||
|
if blob := frameInput(p.Frame, p.ArgsLen); len(blob) > 0 {
|
||||||
|
var decoded map[string]interface{}
|
||||||
|
if err := json.Unmarshal(blob, &decoded); err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("解析共享参数: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handlerMu.RLock()
|
handlerMu.RLock()
|
||||||
h, ok := toolHandlers[p.Name]
|
h, ok := toolHandlers[p.Name]
|
||||||
handlerMu.RUnlock()
|
handlerMu.RUnlock()
|
||||||
@ -954,12 +1308,68 @@ func handleKernelRequest(req *rpcRequest) {
|
|||||||
respondErr(req.ID, fmt.Errorf("未注册的工具: %s", p.Name))
|
respondErr(req.ID, fmt.Errorf("未注册的工具: %s", p.Name))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := h(p.Args)
|
res, err := h(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondErr(req.ID, err)
|
respondErr(req.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond(req.ID, map[string]interface{}{"result": res})
|
|
||||||
|
blob, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("序列化结果: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 结果优先写进内核标定的帧;放不下才申请扩容块。
|
||||||
|
outRef, err := frameOutput(p.Frame, p.ArgsLen, blob, true)
|
||||||
|
if err != nil {
|
||||||
|
outRef, err = arenaPut(blob, true)
|
||||||
|
if err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("结果扩容失败: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(req.ID, map[string]interface{}{"result_ref": outRef})
|
||||||
|
|
||||||
|
case "cleaner.invoke":
|
||||||
|
var p struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Frame SharedRef `json:"frame"`
|
||||||
|
InputLen uint32 `json:"input_len"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("解析 Cleaner 参数: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlerMu.RLock()
|
||||||
|
var cleaner func(string) string
|
||||||
|
switch p.Scope {
|
||||||
|
case "tool":
|
||||||
|
cleaner = toolCleaners[p.Name]
|
||||||
|
case "input":
|
||||||
|
cleaner = inputCleaners[p.Name]
|
||||||
|
case "output":
|
||||||
|
cleaner = outputCleaners[p.Name]
|
||||||
|
}
|
||||||
|
handlerMu.RUnlock()
|
||||||
|
if cleaner == nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("%s %s 未注册 Cleaner", p.Scope, p.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := string(frameInput(p.Frame, p.InputLen))
|
||||||
|
output := cleaner(input)
|
||||||
|
|
||||||
|
// 结果优先写进内核标定的帧;放不下才申请扩容块。
|
||||||
|
outRef, err := frameOutput(p.Frame, p.InputLen, []byte(output), false)
|
||||||
|
if err != nil {
|
||||||
|
outRef, err = arenaPut([]byte(output), false)
|
||||||
|
if err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("结果扩容失败: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(req.ID, map[string]interface{}{"text_ref": outRef})
|
||||||
|
|
||||||
case "stage.invoke":
|
case "stage.invoke":
|
||||||
handleStageInvoke(req)
|
handleStageInvoke(req)
|
||||||
@ -968,8 +1378,23 @@ func handleKernelRequest(req *rpcRequest) {
|
|||||||
var p struct {
|
var p struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
Args map[string]interface{} `json:"args"`
|
Args map[string]interface{} `json:"args"`
|
||||||
|
Frame SharedRef `json:"frame"`
|
||||||
|
ArgsLen uint32 `json:"args_len"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(req.Params, &p)
|
json.Unmarshal(req.Params, &p)
|
||||||
|
// 参数:内核标定帧的前段(§13.6)。只有直连 RPC 的调用方(无帧)
|
||||||
|
// 才走内联 Args——生产路径永远走帧,大 payload 不再爆管道。
|
||||||
|
args := p.Args
|
||||||
|
if !p.Frame.IsZero() {
|
||||||
|
if blob := frameInput(p.Frame, p.ArgsLen); len(blob) > 0 {
|
||||||
|
var decoded map[string]interface{}
|
||||||
|
if err := json.Unmarshal(blob, &decoded); err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("解析共享输出参数: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
handlerMu.RLock()
|
handlerMu.RLock()
|
||||||
h, ok := outputHandlers[p.Channel]
|
h, ok := outputHandlers[p.Channel]
|
||||||
handlerMu.RUnlock()
|
handlerMu.RUnlock()
|
||||||
@ -978,7 +1403,7 @@ func handleKernelRequest(req *rpcRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 同步返回真实结果——内核据此告知模型成功/失败,不再假成功(§9.4)
|
// 同步返回真实结果——内核据此告知模型成功/失败,不再假成功(§9.4)
|
||||||
res, err := h(p.Args)
|
res, err := h(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondErr(req.ID, err)
|
respondErr(req.ID, err)
|
||||||
return
|
return
|
||||||
@ -1045,10 +1470,10 @@ func handleKernelRequest(req *rpcRequest) {
|
|||||||
func handleHandshake(req *rpcRequest) {
|
func handleHandshake(req *rpcRequest) {
|
||||||
var p struct {
|
var p struct {
|
||||||
Protocol int `json:"protocol"`
|
Protocol int `json:"protocol"`
|
||||||
ShmVersion uint32 `json:"shm_version"`
|
ShmVersion uint32 `json:"shm_version"`
|
||||||
ShmSize int `json:"shm_size"`
|
ShmSize int `json:"shm_size"`
|
||||||
PluginName string `json:"plugin_name"`
|
PluginName string `json:"plugin_name"`
|
||||||
EvtRingSize int `json:"evt_ring_size,omitempty"`
|
EvtRingSize int `json:"evt_ring_size,omitempty"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(req.Params, &p)
|
json.Unmarshal(req.Params, &p)
|
||||||
|
|
||||||
@ -1065,41 +1490,44 @@ func handleHandshake(req *rpcRequest) {
|
|||||||
pluginName = p.PluginName
|
pluginName = p.PluginName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂载 StageContext 共享段。
|
// 挂载统一共享内存区域。
|
||||||
// 传递机制按平台不同(Unix 用继承的 fd,Windows 用命名段),
|
// fd 3 (Unix) / 命名对象 (Windows) 传给插件子进程,包含 SuperBlock +
|
||||||
// 由 z_proc_shm_*.go 承担——本文件保持平台无关。
|
// StageContext + EvtRing 两段。SuperBlock 记录各段的偏移与大小。
|
||||||
if p.ShmSize > 0 {
|
if p.ShmSize > 0 {
|
||||||
m, err := attachStageShm(p.ShmSize)
|
m, err := attachUnifiedShm(p.ShmSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondErr(req.ID, fmt.Errorf("挂载共享段失败: %w", err))
|
respondErr(req.ID, fmt.Errorf("挂载统一共享区域失败: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got := binary.LittleEndian.Uint32(m[shmOffMagic:]); got != shmMagic {
|
if got := binary.LittleEndian.Uint32(m[sbOffMagic:]); got != unifiedMagic {
|
||||||
respondErr(req.ID, fmt.Errorf("共享段魔数不匹配(0x%x)", got))
|
respondErr(req.ID, fmt.Errorf("统一区域魔数不匹配(0x%x,期望 0x%x)", got, unifiedMagic))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shm = m
|
ctxOff := binary.LittleEndian.Uint32(m[sbOffCtxOff:])
|
||||||
}
|
ctxSize := binary.LittleEndian.Uint32(m[sbOffCtxSize:])
|
||||||
|
evtOff := binary.LittleEndian.Uint32(m[sbOffEvtOff:])
|
||||||
// 挂载事件环段 + 打开通知句柄(§3.6)
|
evtSize := binary.LittleEndian.Uint32(m[sbOffEvtSize:])
|
||||||
if p.EvtRingSize > 0 {
|
// shm 指向 StageContext 段,后续代码用 shm[off...] 访问该段内部字段
|
||||||
er, err := attachEvtRingShm(p.EvtRingSize)
|
shm = m[ctxOff : ctxOff+ctxSize]
|
||||||
if err != nil {
|
// region 保存完整 mmap 区域。arena 的偏移与大小由内核在
|
||||||
respondErr(req.ID, fmt.Errorf("挂载事件环段失败: %w", err))
|
// arena.alloc 的应答里下发,插件侧不再自己解析槽池布局。
|
||||||
return
|
region = m
|
||||||
|
// 挂载事件环段 + 打开通知句柄(§13.1:EvtRing 在统一区域内)
|
||||||
|
if p.EvtRingSize > 0 && evtSize > 0 {
|
||||||
|
er := m[evtOff : evtOff+evtSize]
|
||||||
|
if got := binary.LittleEndian.Uint32(er[evtOffMagic : evtOffMagic+4]); got != evtRingMagic {
|
||||||
|
respondErr(req.ID, fmt.Errorf("事件环魔数不匹配(0x%x)", got))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifier, err := openEvtNotifier()
|
||||||
|
if err != nil {
|
||||||
|
respondErr(req.ID, fmt.Errorf("打开事件通知句柄失败: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evtRingData = er
|
||||||
|
evtNotifier = notifier
|
||||||
|
go evtConsumerLoop()
|
||||||
}
|
}
|
||||||
if got := binary.LittleEndian.Uint32(er[evtOffMagic : evtOffMagic+4]); got != evtRingMagic {
|
|
||||||
respondErr(req.ID, fmt.Errorf("事件环魔数不匹配(0x%x)", got))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
notifier, err := openEvtNotifier()
|
|
||||||
if err != nil {
|
|
||||||
respondErr(req.ID, fmt.Errorf("打开事件通知句柄失败: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
evtRingData = er
|
|
||||||
evtNotifier = notifier
|
|
||||||
go evtConsumerLoop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(req.ID, map[string]interface{}{
|
respond(req.ID, map[string]interface{}{
|
||||||
@ -10,31 +10,23 @@ import (
|
|||||||
|
|
||||||
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
|
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
|
||||||
//
|
//
|
||||||
// fd 布局(与内核 internal/plugin/proc/plugin.go 的 ExtraFiles 顺序一致):
|
// 统一共享内存区域布局(§13.1):
|
||||||
//
|
//
|
||||||
// fd 3 = StageContext 段(memfd / 已 unlink 的临时文件)
|
// fd 3 = 统一区域(SuperBlock + StageContext + EvtRing)
|
||||||
// fd 4 = 事件环段
|
// fd 4 = 事件通知(Linux eventfd / macOS pipe 读端)
|
||||||
// fd 5 = 事件通知(Linux eventfd / macOS pipe 读端)
|
|
||||||
//
|
//
|
||||||
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
|
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
|
||||||
const (
|
const (
|
||||||
fdStageShm = 3
|
fdUnifiedShm = 3
|
||||||
fdEvtRingShm = 4
|
fdEvtNotifier = 4
|
||||||
fdEvtNotifier = 5
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// attachStageShm 挂载 StageContext 共享段。
|
// attachUnifiedShm 挂载统一共享内存区域。
|
||||||
//
|
//
|
||||||
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
|
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
|
||||||
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
|
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
|
||||||
func attachStageShm(size int) ([]byte, error) {
|
func attachUnifiedShm(size int) ([]byte, error) {
|
||||||
return syscall.Mmap(fdStageShm, 0, size,
|
return syscall.Mmap(fdUnifiedShm, 0, size,
|
||||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachEvtRingShm 挂载事件环段。
|
|
||||||
func attachEvtRingShm(size int) ([]byte, error) {
|
|
||||||
return syscall.Mmap(fdEvtRingShm, 0, size,
|
|
||||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
|
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/traefik/yaegi/interp"
|
"github.com/traefik/yaegi/interp"
|
||||||
"github.com/traefik/yaegi/stdlib"
|
"github.com/traefik/yaegi/stdlib"
|
||||||
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi/mocksdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi/mocksdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type YaegiDebugger struct {
|
type YaegiDebugger struct {
|
||||||
@ -83,24 +83,52 @@ type ToolResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ToolDef struct {
|
type ToolDef struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Plugin string `json:"plugin,omitempty"`
|
Plugin string `json:"plugin,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
NoMemory bool `json:"no_memory,omitempty"`
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
Cleaner func(string) string `json:"-"`
|
Cleaner func(string) string `json:"-"`
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上下文策略取值,与公共 SDK 一致。
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
|
||||||
|
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool
|
||||||
|
ContextPolicy string
|
||||||
|
CleanerName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type IOInjector interface {
|
type IOInjector interface {
|
||||||
InjectInterruptText(source, channel, text string)
|
InjectInterruptText(source, channel, text string)
|
||||||
InjectText(source, channel, text string)
|
InjectText(source, channel, text string)
|
||||||
InjectTextNoMemory(source, channel, text string)
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
// InjectInputSync 注入输入事件并同步等待 agent 回复(无回复时返回空串)。
|
||||||
|
// 通道类插件(qq / a2a 等)靠它完成「收到入站 → agent 处理 → 回复取回」闭环,
|
||||||
|
// 而 mock 此前只有带 flags 的 InjectInputSyncOpts、没有这个零值糖——
|
||||||
|
// 于是一个能在 plugin.bin 里编译通过、在 yaegi 下却调不通的方法就长住了。
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
|
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
|
||||||
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
|
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
|
||||||
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
SetToolBlocks(blocks []ContentBlock)
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
|
||||||
|
// 1.2.0 带标志位的注入,与公共 SDK 同构。
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentBlock 与公共 SDK 同构(OpenAI 多模态内容块格式)。
|
// ContentBlock 与公共 SDK 同构(OpenAI 多模态内容块格式)。
|
||||||
@ -391,6 +419,34 @@ func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
|
|||||||
logf("set_tool_blocks: blocks=%d", len(blocks))
|
logf("set_tool_blocks: blocks=%d", len(blocks))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入 ----
|
||||||
|
|
||||||
|
func (IOInjectorImpl) InjectInputSync(source, channel, text string) string {
|
||||||
|
logf("inject_sync: source=%s channel=%s", source, channel)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||||
|
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||||
|
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
type PluginSDK struct {
|
type PluginSDK struct {
|
||||||
Name string
|
Name string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
Reference in New Issue
Block a user