mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
5 Commits
feature/re
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 22a5af6534 | |||
| 56af9e2053 | |||
| 9a61efb173 | |||
| 7a57a14c6f | |||
| 579d7dbaee |
8
.gitignore
vendored
8
.gitignore
vendored
@ -31,7 +31,6 @@ cmd/gui/dist/
|
||||
third_party/homeagent-sdk/bin/
|
||||
third_party/homeagent-sdk/tools/
|
||||
third_party/homeagent-sdk/package/
|
||||
third_party/homeagent-sdk/scripts/
|
||||
third_party/homeagent-sdk/.gitignore
|
||||
third_party/homeagent-sdk/README*
|
||||
third_party/homeagent-sdk/example/
|
||||
@ -49,12 +48,7 @@ codegraph.json
|
||||
/adapters/
|
||||
/knowledge/
|
||||
/memory/
|
||||
# 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
|
||||
# 不是运行期产物:deploy/systemd/embed-sidecar.service 直接引用
|
||||
# scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向不存在的文件。
|
||||
# 只忽略其中的缓存。
|
||||
/scripts/__pycache__/
|
||||
__pycache__/
|
||||
/scripts/
|
||||
terminal_locked_log.txt
|
||||
|
||||
dist/
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# golangci-lint 配置 —— 「超大函数/超大文件」治理的防复发闸门。
|
||||
#
|
||||
# 背景:main 上曾有 4 个 ≥300 行函数、12 个 ≥200 行函数(见审查报告)。
|
||||
# 没有复杂度 linter 是它们能长期存活的直接原因。本配置先以 **warn-only**
|
||||
# 起步:`issues.exit-code: 0`,只产出清单、不阻断构建。等历史债降到可接受
|
||||
# 水位后,再把 exit-code 改成 1 收成硬门禁。
|
||||
#
|
||||
# 运行:make lint-full(需先 `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`)
|
||||
# 注意:这是 golangci-lint v1 的配置格式(v2 的 `linters.default` 写法不同)。
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- funlen # 函数长度
|
||||
- gocyclo # 圈复杂度
|
||||
- lll # 行宽
|
||||
- dupl # 重复代码
|
||||
- govet # 与 make lint 对齐的基线
|
||||
|
||||
linters-settings:
|
||||
funlen:
|
||||
lines: 150
|
||||
statements: 100
|
||||
gocyclo:
|
||||
min-complexity: 30
|
||||
lll:
|
||||
line-length: 140
|
||||
dupl:
|
||||
threshold: 200
|
||||
|
||||
issues:
|
||||
# 起步阶段不阻断(warn-only)。收紧后改为 1。
|
||||
exit-code: 0
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
# 测试与生成/夹具代码不受长度类规则约束。
|
||||
- path: _test\.go
|
||||
linters: [funlen, dupl, gocyclo]
|
||||
- path: internal/plugin/proc/testdata
|
||||
linters: [funlen, dupl]
|
||||
- path: third_party/
|
||||
linters: [funlen, dupl, gocyclo, lll]
|
||||
661
LICENSE
661
LICENSE
@ -1,661 +0,0 @@
|
||||
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/>.
|
||||
11
Makefile
11
Makefile
@ -61,14 +61,3 @@ fmt:
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
|
||||
# lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。
|
||||
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
|
||||
.PHONY: lint-full
|
||||
lint-full:
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint 未安装,跳过(阈值见 .golangci.yml)"; \
|
||||
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||
fi
|
||||
|
||||
62
README.md
62
README.md
@ -12,8 +12,6 @@
|
||||
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
```
|
||||
|
||||
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia`、`InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆,digest 是回到字节的钥匙。
|
||||
|
||||
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
||||
|
||||
## 设计要点
|
||||
@ -190,43 +188,14 @@ internal/
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||
**v1.1.0** — 记忆系统支持二进制多媒体节点。此前四层记忆(L0 活跃上下文 / L1 文本 / L2 文档 / L3 图库)全部只存文字,图片音频经视觉模型转成描述后原始字节即丢弃,"那张紫蓝红三色带图"再也取不回来。本版新增内容寻址媒体存储(CAS,`internal/memory/media`):元数据进 SQLite、blob 按 sha256 落盘去重,L0/L2/L3 各层只记 digest 并通过 `media_refs` 维护引用计数,容量上限由后台 GC 真正兑现(被引用的内容即便超限也永不删除)。**描述文本才是持久语义记忆,blob 只是缓存**——描述随记忆各层一直留存并可检索,原始字节可被容量 GC 淘汰,因此几个月后仍能从图库句子反查到那张图(若尚在则逐字节取回)。描述由后台循环经视觉源生成(默认关闭,开启后每 30s 最多 4 条,不与对话抢配额),放在对话路径上会给每张图的回复加十几秒而收益为零——那一轮模型本来就直接看着图。同时修五个缺陷:`core.New` 漏接 `rc.SetMediaStore` 致 L0→L2 引用转移在生产静默失效;三元组全被实体名校验拒绝时仍释放引用并删除文档(数据丢失,已反向验证);媒体入图库曾依赖 NLP 提取器碰巧提出合规三元组而时好时坏,改为按媒体标记确定性产出;L3 媒体检索一度没有任何调用方(能存进去、agent 拿不出来);`remotedevice` 网关与 `agentcli` 终端各一处数据竞争。配套 `-tags medialive` 自动触发链实测:只注入一个图片事件,落盘/描述/归档/图库绑定/GC 保护/二轮召回七个阶段全由生产代码自行触发,真实视觉模型下 agent 在不给图的第二轮准确答出三条色带的颜色与近似 hex。插件 ABI 未变(`SDKCompatibleVersion` 仍为 1.0.0),存量 `plugin.bin` 无需重编。
|
||||
|
||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
- **媒体是图数据库的一等节点与边**:**彻底删除**「用文本描述式索引图片」这套将就机制,
|
||||
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
|
||||
是块的**迁移**,不是复制、也不靠引用保活。
|
||||
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
|
||||
RPC 只传偏移描述符;**RPC 协议升到 2**,fd3 布局改变,**不支持滚动升级**——
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
|
||||
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`(**默认仍记入记忆、默认不裁剪**);
|
||||
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**。
|
||||
- **发行包默认启用** ONNX 向量空间,并把模型(754MB)与 ONNX Runtime(24MB)随
|
||||
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2;jieba 词库内嵌进二进制。
|
||||
- 修掉三个**安装链静默失败**:`initconfig` 因 `CGO_ENABLED=0` 是空操作(打印凭据却一个字节
|
||||
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件)、deb 的 `postinst`
|
||||
查错 unit 路径导致 `enable` 从未执行。
|
||||
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`,**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
|
||||
@ -251,7 +220,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导,含 AGPL 许可页)。自 v1.2.0 起因 `homed` 不再支持 Windows 原生(依赖 fd 继承与共享内存段内偏移解引用),安装器改为引导到 **WSL2**,并把 Linux 包送进发行版里按 Linux 方式安装。
|
||||
- Windows:`HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
@ -266,26 +235,3 @@ make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
|
||||
## 许可
|
||||
|
||||
本项目以 **GNU Affero 通用公共许可证第 3 版(AGPL-3.0-only)** 发布,全文见 [LICENSE](LICENSE)。
|
||||
|
||||
它是 GPL 家族里**传染性最强**的一档:不仅分发时须提供完整对应源码,
|
||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||
|
||||
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||
|
||||
### 随包分发的第三方组件
|
||||
|
||||
| 组件 | 许可 | 位置 |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16(ONNX 产物) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba 词库(内嵌进二进制) | MIT | 源码 `internal/memory/jiebadict/` |
|
||||
| Go 依赖(go-sqlite3、gojieba、bubbletea 等) | MIT / BSD-3 / Apache-2.0 | 均为宽松许可,与 AGPL-3.0 兼容 |
|
||||
|
||||
这些组件**保持各自原有许可**,不在本项目的 AGPL 授权范围内;发行包把它们的许可全文放在
|
||||
`/usr/share/doc/homeagent/licenses/`,并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`。
|
||||
|
||||
94
README_EN.md
94
README_EN.md
@ -12,11 +12,6 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
|
||||
homed (kernel, zero IO) ← PluginSDK → plugins (all IO capabilities)
|
||||
```
|
||||
|
||||
**Since v1.1.1 media reaches the plugin boundary**: plugins and the model can both read and
|
||||
write images/audio in memory (`InsertWithMedia`, `InjectInputMedia`). Media lives in plain-text
|
||||
memory as a `[<mime> <short digest>] <description>` marker — the description is the searchable
|
||||
semantic memory, the digest is the key back to the bytes.
|
||||
|
||||
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
|
||||
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
|
||||
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
|
||||
@ -179,68 +174,14 @@ internal/
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
||||
**v1.1.0** — Binary/multimedia nodes in the memory system. All four tiers (L0 active context / L1 text / L2 documents / L3 graph) previously stored text only: an image or audio clip was turned into a description by a vision model and the original bytes were dropped, so "that purple-blue-red banded image" could never be retrieved again. This release adds a content-addressed media store (CAS, `internal/memory/media`): metadata in SQLite, blobs deduplicated on disk by sha256, with every tier holding only digests and reference counts maintained through `media_refs`, so the capacity cap is finally enforced by a background GC (referenced content is never deleted, even over the limit). **The description text is the durable semantic memory; the blob is only a cache** — descriptions persist across all tiers and stay searchable while raw bytes may be evicted, so months later a graph sentence still resolves back to that image (byte-for-byte if it survives). Descriptions are generated by a background loop through a vision source (off by default; at most 4 items per 30s when enabled, so it never competes with conversations for quota) — doing it inline would add tens of seconds to every image reply for no gain, since the model is looking at the image in that turn anyway. Five defects fixed as well: `core.New` never called `rc.SetMediaStore`, silently disabling L0→L2 reference transfer in production; references were released and the document deleted even when every triple was rejected by entity-name validation (data loss, reverse-verified); media entering the graph depended on the NLP extractor happening to produce valid triples and was therefore intermittent, now replaced by deterministic triples derived from media markers; L3 media lookup had no callers at all (stored fine, unreachable by the agent); and one data race each in the `remotedevice` gateway and the `agentcli` terminal. Ships with a `-tags medialive` auto-trigger integration test: a single injected image event drives all seven stages — CAS write, description, archival, graph binding, GC protection, second-turn recall — entirely through production code paths, and with a real vision model the agent names all three band colours and their approximate hex values in a second turn that includes no image. Plugin ABI unchanged (`SDKCompatibleVersion` stays 1.0.0); existing `plugin.bin` files need no rebuild.
|
||||
|
||||
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
|
||||
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
||||
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
|
||||
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
|
||||
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
|
||||
not a copy, and not kept alive by references.
|
||||
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
|
||||
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
|
||||
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade** —
|
||||
kernel and all plugins must be rebuilt and installed together.
|
||||
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
|
||||
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
|
||||
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
|
||||
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
|
||||
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
|
||||
favour of WSL2; the jieba dictionary is embedded in the binary.
|
||||
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
|
||||
that printed credentials without writing any, fresh installs were misdetected as "already
|
||||
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
|
||||
`postinst` looked for the unit in the wrong path so `enable` never ran.
|
||||
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
|
||||
plugins must match — see License).
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
plugins and the model. The public SDK gains media fields and three media injection methods
|
||||
(paired with [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),
|
||||
shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge
|
||||
layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a
|
||||
plugin were discarded, `Doc` kept only three fields, and `Remove` never released references
|
||||
(media stayed "referenced" forever, so GC could never reclaim it). `processTextInput` and
|
||||
`processMediaInput` were unified into a single `processInput`, which finally gives the media
|
||||
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
|
||||
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
|
||||
chat log** (the media path published a map while the subscriber asserted a string),
|
||||
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
|
||||
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
|
||||
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
|
||||
|
||||
**v1.1.0** — Memory system supports **binary multimedia nodes**. Content-addressed media store
|
||||
(CAS + SQLite metadata + on-disk blobs, `Get` always re-verifies the digest) wired through L0
|
||||
(context events) / L2 (documents) / L3 (graph sentences), with reference-counted GC (referenced
|
||||
items are never deleted). The description text produced by the vision model is the durable
|
||||
semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
|
||||
@ -265,7 +206,7 @@ semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
|
||||
- Windows: `HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
@ -280,30 +221,3 @@ make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the **GNU Affero General Public License, version 3
|
||||
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
|
||||
|
||||
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
|
||||
source when you distribute the software, **you must also offer the source to users who interact
|
||||
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
|
||||
as a network service therefore has to make the modified source available to that service's users.
|
||||
|
||||
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||
|
||||
### Third-party components shipped with the packages
|
||||
|
||||
| Component | License | Location |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
|
||||
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
|
||||
|
||||
These components keep their own licenses and are not relicensed by this project. Full texts are
|
||||
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
|
||||
as `AGPL-3.0-only`.
|
||||
|
||||
@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
RelevanceContext — In-memory events[] + JSON persistence
|
||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||
agent→Response, user→Input, cold_storage→Input+Response
|
||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
||||
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||
├── Keep → timeline → chronologically sorted → system prompt
|
||||
└── Low score → Document layer archive (original timestamp)
|
||||
Save: 5s debounce write to disk
|
||||
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
↓ Prune archive ↑ LLM active recall
|
||||
|
||||
② Document (File Memory)
|
||||
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
|
||||
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||
Read:
|
||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
||||
@ -132,22 +132,11 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
→ triples → GraphDB.Commit
|
||||
```
|
||||
|
||||
### Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)
|
||||
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
|
||||
|
||||
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
|
||||
error and never pretends to succeed**:
|
||||
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
||||
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
|
||||
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
|
||||
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
|
||||
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
|
||||
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
|
||||
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
|
||||
|
||||
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**Model sources** (aligned 300d)
|
||||
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
||||
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
|
||||
@ -200,43 +189,6 @@ dimension** participate in fusion (mixing coordinate systems yields a direction
|
||||
| `doc_query` | Search from Document |
|
||||
| `doc_commit` | Write to Document |
|
||||
|
||||
Since v1.1.1 `memory_commit` and `doc_commit` accept `media_digests`, and the kernel appends the
|
||||
`[<mime> <short digest>] <description>` marker into the sentence/body — **the kernel builds the
|
||||
marker, the model only supplies the digest**. Requiring the caller to know the format would mean a
|
||||
single typo silently breaks reference binding with no error anywhere in the chain. `memory_commit`
|
||||
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
|
||||
nowhere to attach them.
|
||||
|
||||
### Media Memory (since v1.2.0: first-class memory blocks)
|
||||
|
||||
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
|
||||
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
|
||||
structural edges (e.g. `sentence --contains--> block`) express ownership.
|
||||
|
||||
| Concern | Approach | Why |
|
||||
|---|---|---|
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
|
||||
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
|
||||
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
|
||||
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
|
||||
|
||||
**Description-based indexing is gone**: the old implementation embedded a
|
||||
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
|
||||
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
|
||||
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
|
||||
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
|
||||
unified space, and no media marker is written into the body.
|
||||
|
||||
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
|
||||
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
|
||||
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
|
||||
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
|
||||
another model's vector**.
|
||||
|
||||
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
|
||||
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
|
||||
|
||||
### Other Memory Layers
|
||||
|
||||
- **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types
|
||||
@ -307,7 +259,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
|
||||
| Method | Registration Mechanism | Compilation | Usage |
|
||||
|--------|----------------------|-------------|-------|
|
||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
@ -326,7 +278,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
|
||||
| Dimension | Built-in Plugin | External Plugin |
|
||||
|-----------|----------------|-----------------|
|
||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
|
||||
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
|
||||
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
|
||||
@ -344,7 +296,7 @@ Common ground:
|
||||
|
||||
| Plane | Mechanism | Why this choice |
|
||||
|---|---|---|
|
||||
| Control | stdio JSON-RPC (NDJSON frames), 55 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
|
||||
| Control | stdio JSON-RPC (NDJSON frames), 51 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
|
||||
| Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly |
|
||||
| Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter |
|
||||
|
||||
@ -382,23 +334,8 @@ sdk.Memory().Recall/Commit
|
||||
sdk.Knowledge().Search/Create
|
||||
sdk.Settings().Get/Set/List
|
||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)
|
||||
|
||||
// v1.1.1 media APIs (all additive, no signature changes)
|
||||
sdk.DocMemory().InsertWithMedia(doc, attachments) // attachments with Data land in CAS; Digest-only ones reference existing content
|
||||
sdk.InjectInputMedia(source, channel, text, blocks) // media reaches the model in *this* turn
|
||||
sdk.InjectInputMediaSync(...) // same, and waits for the reply
|
||||
sdk.InjectInterruptMedia(...) // media-bearing interrupt, can preempt current processing
|
||||
```
|
||||
|
||||
How media injection differs from `SetToolBlocks`: the latter is only callable inside a tool handler
|
||||
and its media reaches the model with the **next** tool message; these three let a plugin
|
||||
**initiate a turn that carries media** — it goes out with this turn's message and is automatically
|
||||
stored in CAS with a memory reference attached. `Triple` and `Doc` gained `MediaDigests` /
|
||||
`Attachments` correspondingly.
|
||||
|
||||
`internal/sdk/` is the bridge implementation for this layer and is not subject to the public
|
||||
interface freeze (see `docs/git-branching.md` §6).
|
||||
|
||||
### Plugin Interface
|
||||
|
||||
```go
|
||||
|
||||
@ -25,19 +25,6 @@ The significance lies in clear responsibility boundaries: the kernel focuses on
|
||||
|
||||
Three progressive layers — context, cold archive, long-term graph memory — form an information decay and consolidation pipeline from short-term to persistent storage.
|
||||
|
||||
**Media Memory (since v1.1.0)** — Images and audio are not attachments; they are a kind of node in all three layers:
|
||||
- **Content-addressed store (CAS)**: addressed by digest, metadata in SQLite and blobs on disk, identical bytes
|
||||
stored once. Every `Get` re-verifies the digest (silently returning corrupt data is worse than an error).
|
||||
- **Reference-counted GC**: `owner_kind/owner_id/digest` is the primary key; context events, documents and graph
|
||||
sentences each hold their own references. **Referenced items are never deleted** — only unowned content past
|
||||
`minAge` is reclaimed.
|
||||
- **The description text is the durable semantic memory**: what the vision model produced is written into
|
||||
plain-text memory as a `[<mime> <short digest>] <description>` marker and participates in vector retrieval and
|
||||
distillation; the blob is only a cache that capacity GC may evict. Months later "that purple-blue-red
|
||||
three-band chart" is still findable — via the description, not the bytes.
|
||||
- **Reaches the plugin boundary since v1.1.1**: plugins read and write media through `InsertWithMedia` /
|
||||
`InjectInputMedia`; the model attaches media via the `media_digests` argument of `memory_commit` / `doc_commit`.
|
||||
|
||||
## What It Actually Does
|
||||
|
||||
Code is in the project root, implemented in Go.
|
||||
|
||||
@ -29,80 +29,75 @@ type Plugin interface {
|
||||
|
||||
| Method | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
|
||||
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 1. Quick Start: Using the hmapdev Toolchain
|
||||
## 1. Quick Start: Using the plugindev Toolchain
|
||||
|
||||
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
|
||||
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
|
||||
|
||||
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
|
||||
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
|
||||
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# Add hmapdev to PATH or use directly
|
||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# Add plugindev to PATH or use directly
|
||||
```
|
||||
|
||||
### SDK Version Management
|
||||
|
||||
`hmapdev sdk` manages local SDK versions:
|
||||
`plugindev sdk` manages local SDK versions:
|
||||
|
||||
```bash
|
||||
hmapdev sdk list # list installed SDK versions
|
||||
hmapdev sdk current # show current SDK version
|
||||
hmapdev sdk latest # show latest available version
|
||||
hmapdev sdk install v1.2.0 # install a specific version
|
||||
hmapdev sdk use v1.2.0 # switch to a version
|
||||
hmapdev sdk path # show current SDK path
|
||||
plugindev sdk list # list installed SDK versions
|
||||
plugindev sdk current # show current SDK version
|
||||
plugindev sdk latest # show latest available version
|
||||
plugindev sdk install v0.8.0 # install a specific version
|
||||
plugindev sdk use v0.8.0 # switch to a version
|
||||
plugindev sdk path # show current SDK path
|
||||
```
|
||||
|
||||
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
|
||||
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
|
||||
|
||||
### Source Debugging
|
||||
|
||||
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
|
||||
```bash
|
||||
hmapdev debug [dir] # dir defaults to the current directory
|
||||
plugindev debug [dir] # dir defaults to the current directory
|
||||
```
|
||||
|
||||
### Creating a Go Plugin
|
||||
|
||||
```bash
|
||||
hmapdev init myplugin
|
||||
plugindev init myplugin
|
||||
cd myplugin
|
||||
# Edit plugin code
|
||||
vim plugin.go
|
||||
# Build and package (default is a multi-platform bundle, see below)
|
||||
hmapdev build
|
||||
plugindev build
|
||||
# Output: dist/myplugin_bundle.hmap
|
||||
# Single-platform build:
|
||||
hmapdev build --no-bundle
|
||||
plugindev build --no-bundle
|
||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||
```
|
||||
|
||||
### Creating a Lua Plugin
|
||||
|
||||
```bash
|
||||
hmapdev init myluaplugin --lua
|
||||
plugindev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# Edit plugin code
|
||||
vim main.lua
|
||||
# Local test
|
||||
lua main.lua
|
||||
# Build and package
|
||||
hmapdev build
|
||||
plugindev build
|
||||
# Output: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -133,16 +128,16 @@ myluaplugin/
|
||||
|
||||
### Build & Package
|
||||
|
||||
`hmapdev build` automatically handles compilation and packaging:
|
||||
`plugindev build` automatically handles compilation and packaging:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
hmapdev build # default bundle mode (multi-platform)
|
||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
hmapdev build --outdir dist # output directory (default: dist)
|
||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
plugindev build # default bundle mode (multi-platform)
|
||||
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
plugindev build --outdir dist # output directory (default: dist)
|
||||
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
```
|
||||
|
||||
Execution process:
|
||||
@ -176,7 +171,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||
> skips legacy artifacts with an actionable error instead of crashing.
|
||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
||||
> rebuild with the new `hmapdev` (formerly `plugindev`).
|
||||
> rebuild with the new `plugindev`.
|
||||
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
||||
@ -185,12 +180,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
|
||||
### Build Targets & Multi-platform Bundle
|
||||
|
||||
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
|
||||
```bash
|
||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
||||
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||
```
|
||||
|
||||
Notes:
|
||||
@ -280,7 +275,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### Entry Point
|
||||
|
||||
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
which is the entry point when the kernel loads the plugin:
|
||||
|
||||
```go
|
||||
@ -289,7 +284,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
At build time, `hmapdev build` auto-generates subprocess runtime code
|
||||
At build time, `plugindev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
@ -599,26 +594,23 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
The `sdk.*` API of Lua plugins is aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
|
||||
|
||||
> Historical note: the 1.1–1.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding).
|
||||
The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
|
||||
|
||||
**Registration**
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sdk.log(level, msg)` | Log output |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) |
|
||||
| `sdk.register_api(name)` | Register API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above |
|
||||
| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
|
||||
|
||||
**Stage hook context**
|
||||
|
||||
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`.
|
||||
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`.
|
||||
|
||||
**Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability):
|
||||
|
||||
@ -647,37 +639,20 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
||||
| `sdk.inject_text(source, channel, text)` | Deliver text message |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Unavailable in Lua**: always returns `(nil, err)`. It waits for this turn's reply while a Lua callback holds the plugin lock, so it would self-deadlock. Use a Go plugin for synchronous waits, or the async injectors below |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same (unavailable) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Unavailable in Lua** (same as `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
|
||||
|
||||
Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form.
|
||||
|
||||
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
|
||||
|
||||
| Sub-table | Functions |
|
||||
|-----------|-----------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` |
|
||||
| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`, `decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
|
||||
|
||||
Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content.
|
||||
|
||||
> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall.
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
||||
RelevanceContext — 内存 events[] + JSON持久化
|
||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
||||
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||
├── 保留 → timeline → 按时间排序 → system prompt
|
||||
└── 低分 → Document 层归档 (原始时间戳)
|
||||
Save: 5s debounce 写盘
|
||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
||||
↓ Prune 归档 ↑ LLM 主动召回
|
||||
|
||||
② Document (文件记忆)
|
||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||
读取:
|
||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||
@ -132,20 +132,11 @@ eventLoop() → processTextInput()
|
||||
→ 三元组 → GraphDB.Commit
|
||||
```
|
||||
|
||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
||||
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||
|
||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
||||
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
||||
|
||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**模型来源**(词对齐 300 维)
|
||||
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||
@ -198,37 +189,6 @@ eventLoop() → processTextInput()
|
||||
| `doc_query` | 从 Document 搜索 |
|
||||
| `doc_commit` | 写入 Document |
|
||||
|
||||
`memory_commit` 与 `doc_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
|
||||
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(v1.2.0 起:一等记忆块)
|
||||
|
||||
媒体不是外挂内容,而是**记忆的一等节点**:`internal/memory/media/` 是内容寻址仓储(CAS),
|
||||
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
|
||||
|
||||
| 关注点 | 做法 | 为何 |
|
||||
|---|---|---|
|
||||
| 寻址 | sha256 digest;元数据在 SQLite,blob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
|
||||
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
|
||||
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
|
||||
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
|
||||
|
||||
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
|
||||
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
|
||||
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
|
||||
正文里不再有 media marker。
|
||||
|
||||
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
|
||||
`reembedStaleMedia()` 把 `vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
|
||||
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
|
||||
**绝不拿别的模型的向量顶替**。
|
||||
|
||||
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
|
||||
不报错不 panic。
|
||||
|
||||
### 其他记忆层
|
||||
|
||||
- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型
|
||||
@ -297,7 +257,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
|
||||
| 方式 | 注册机制 | 编译 | 用途 |
|
||||
|------|----------|------|------|
|
||||
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
||||
|
||||
@ -316,7 +276,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
| 维度 | 内置插件 | 外部插件 |
|
||||
|------|----------|----------|
|
||||
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
||||
@ -334,14 +294,10 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
|
||||
| 面 | 机制 | 为何这么选 |
|
||||
|---|---|---|
|
||||
| 控制面 | stdio JSON-RPC(NDJSON 帧),55 个 `core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
|
||||
| 控制面 | stdio JSON-RPC(NDJSON 帧),51 个 `core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
|
||||
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,lost update 原样复现 |
|
||||
| 通知面 | 事件环 + 平台通知(Linux eventfd / macOS pipe / Windows Event) | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
|
||||
|
||||
v1.1.1 新增 4 个 method(51 → 55):`doc.insertWithMedia`、`io.injectMedia`、
|
||||
`io.injectMediaSync`、`io.injectInterruptMedia`。**媒体块走 JSON 而非共享段二进制通道**——
|
||||
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要跟其余 51 个 method 分道。
|
||||
|
||||
**子进程生命周期管理**:
|
||||
- 每子进程一根专职 `waitLoop`(`cmd.Wait()` 唯一调用点)——不依赖 stdout EOF,
|
||||
因为插件 fork 的孙子进程(browser 拉 chromium、editdoc 拉 python)继承同一 stdout,
|
||||
@ -374,20 +330,8 @@ sdk.Memory().Recall/Commit
|
||||
sdk.Knowledge().Search/Create
|
||||
sdk.Settings().Get/Set/List
|
||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道,详见 output_send__qq_help", handler)
|
||||
|
||||
// v1.1.1 媒体接口(全部新增,无签名变更)
|
||||
sdk.DocMemory().InsertWithMedia(doc, attachments) // 带 Data 的落进 CAS,只给 Digest 的引用已有内容
|
||||
sdk.InjectInputMedia(source, channel, text, blocks) // 媒体在「本轮」就发给模型
|
||||
sdk.InjectInputMediaSync(...) // 同上并同步等回复
|
||||
sdk.InjectInterruptMedia(...) // 带媒体的中断,可抢占当前处理
|
||||
```
|
||||
|
||||
媒体注入与 `SetToolBlocks` 的区别:后者只能在工具处理函数内部调用,且媒体要等**下一条**
|
||||
tool message 才到模型手上;前三个是插件**主动发起一轮带媒体的对话**,媒体随本轮消息发出,
|
||||
并自动落进 CAS、挂上媒体记忆引用。`Triple` 与 `Doc` 相应新增 `MediaDigests`、`Attachments`。
|
||||
|
||||
`internal/sdk/` 是这层的桥接实现,不受公开接口冻结约束(见 `docs/git-branching.md` §六)。
|
||||
|
||||
### Plugin 接口
|
||||
|
||||
```go
|
||||
|
||||
@ -25,17 +25,6 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
|
||||
|
||||
三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。
|
||||
|
||||
**媒体记忆(v1.1.0 起)** — 图片/音频不是附属物,而是三层里的一类节点:
|
||||
- **内容寻址存储(CAS)**:digest 寻址,元数据在 SQLite、blob 在磁盘,相同字节只存一份,
|
||||
每次 `Get` 重校 digest(磁盘损坏静默返回脏数据比报错更危险)
|
||||
- **引用计数 GC**:`owner_kind/owner_id/digest` 三元组为主键,上下文事件/文档/图谱句子各自持引用;
|
||||
**有引用者绝不删除**,仅回收无主且超过 `minAge` 的内容
|
||||
- **描述文本才是持久语义记忆**:视觉模型生成的描述以
|
||||
`[<mime> <短digest>] <描述>` 标记形式写进纯文本记忆,参与向量检索与蒸馏;
|
||||
blob 只是可被容量 GC 淘汰的缓存。几个月后“那张紫蓝红三色带图”仍可检索,靠的是描述而不是字节
|
||||
- **v1.1.1 起贯通插件边界**:插件可通过 `InsertWithMedia` / `InjectInputMedia` 读写媒体,
|
||||
模型可用 `memory_commit` / `doc_commit` 的 `media_digests` 参数关联媒体
|
||||
|
||||
## 它实际做了什么
|
||||
|
||||
代码位于项目仓库根目录,Go 语言实现。
|
||||
|
||||
@ -30,80 +30,75 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 一、快速开始:使用 hmapdev 工具链
|
||||
## 一、快速开始:使用 plugindev 工具链
|
||||
|
||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
||||
|
||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
||||
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# 将 hmapdev 加入 PATH 或直接使用
|
||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# 将 plugindev 加入 PATH 或直接使用
|
||||
```
|
||||
|
||||
### SDK 版本管理
|
||||
|
||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
||||
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||
|
||||
```bash
|
||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
||||
hmapdev sdk latest # 显示最新可用版本
|
||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
||||
hmapdev sdk path # 显示当前 SDK 路径
|
||||
plugindev sdk list # 列出已安装的 SDK 版本
|
||||
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||
plugindev sdk latest # 显示最新可用版本
|
||||
plugindev sdk install v0.8.0 # 安装指定版本
|
||||
plugindev sdk use v0.8.0 # 切换使用版本
|
||||
plugindev sdk path # 显示当前 SDK 路径
|
||||
```
|
||||
|
||||
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/`,`hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/`,`plugindev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
|
||||
### 源码调试
|
||||
|
||||
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
|
||||
```bash
|
||||
hmapdev debug [dir] # dir 默认当前目录
|
||||
plugindev debug [dir] # dir 默认当前目录
|
||||
```
|
||||
|
||||
### 创建 Go 插件
|
||||
|
||||
```bash
|
||||
hmapdev init myplugin
|
||||
plugindev init myplugin
|
||||
cd myplugin
|
||||
# 编辑插件代码
|
||||
vim plugin.go
|
||||
# 编译打包
|
||||
hmapdev build # 默认多平台 bundle(见下节)
|
||||
plugindev build # 默认多平台 bundle(见下节)
|
||||
# 输出: dist/myplugin_bundle.hmap
|
||||
# 单平台构建:
|
||||
hmapdev build --no-bundle
|
||||
plugindev build --no-bundle
|
||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||
```
|
||||
|
||||
### 创建 Lua 插件
|
||||
|
||||
```bash
|
||||
hmapdev init myluaplugin --lua
|
||||
plugindev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# 编辑插件代码
|
||||
vim main.lua
|
||||
# 本地测试
|
||||
lua main.lua
|
||||
# 编译打包
|
||||
hmapdev build
|
||||
plugindev build
|
||||
# 输出: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -134,16 +129,16 @@ myluaplugin/
|
||||
|
||||
### 编译打包
|
||||
|
||||
`hmapdev build` 会自动完成编译和打包:
|
||||
`plugindev build` 会自动完成编译和打包:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
plugindev build # 默认 bundle 模式(多平台合集)
|
||||
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
```
|
||||
|
||||
执行过程:
|
||||
@ -159,7 +154,7 @@ hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
| 文件 | 用途 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
|
||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||
|
||||
@ -174,7 +169,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||
> 会跳过并报可操作错误,不崩溃。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
@ -183,12 +178,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
**`hmapdev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
**`plugindev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
|
||||
```bash
|
||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # 显式开启 bundle(同上)
|
||||
plugindev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -278,7 +273,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### 入口点
|
||||
|
||||
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -286,7 +281,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
@ -592,26 +587,23 @@ lua main.lua
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐至 **SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
**注册类**
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `sdk.log(level, msg)` | 日志输出 |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`context_policy`(`"none"`/`"prune"`)、`cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
|
||||
| `sdk.register_api(name)` | 注册 API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`context_policy`、`cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
|
||||
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
|
||||
|
||||
**阶段钩子上下文**
|
||||
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`reasoning_content`、`final_text`、`no_memory`、`context_msgs`、`token_usage`、`memory`、`extra`、`errors`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
|
||||
**Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
|
||||
|
||||
@ -640,37 +632,20 @@ end)
|
||||
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Lua 中不可用**:恒返回 `(nil, err)`。它要等本轮回复而 Lua 回调持有插件锁,必然自锁。需要同步等待请用 Go 插件,或用下面的异步注入 |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上(不可用) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Lua 中不可用**(同 `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
|
||||
|
||||
`blocks` 每项形如:`{ type="text", text="..." }`、`{ type="image_url", image_url={ url="...", detail="high" } }`、`{ type="audio_url", audio_url={ url="..." } }`。`opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。
|
||||
|
||||
**数据类(与子进程外部插件对齐,均返回 `(result, err)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`(triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`)、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`insert_with_media(doc, attachments)`、`remove(id)`、`stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.llm.*` | `list_sources()`、`set_source(name)`、`current_source()` |
|
||||
| `sdk.social.*`(只读) | `get_person(name)`、`get_network(name, depth)`、`get_trait(name, trait)`、`get_relations(name)`、`list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数;handler 收到 `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`、`list_loaded()`、`is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`、`decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`、`post(url, body, content_type)` |
|
||||
|
||||
`attachments` 每项:`{ digest=, mime=, name=, data=<base64> }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。
|
||||
|
||||
> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
@ -696,10 +696,6 @@ async function refreshDataOnly() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -779,10 +775,6 @@ async function refreshAll() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -1012,249 +1004,6 @@ function statCard(l, v) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 运行态面板:阶段管道 + 中断队列 =====
|
||||
//
|
||||
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
|
||||
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
|
||||
// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。
|
||||
var RT_LEVELS = [
|
||||
{ lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" },
|
||||
{ lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" },
|
||||
{ lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" },
|
||||
{ lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" },
|
||||
];
|
||||
// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
|
||||
// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
|
||||
// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
|
||||
var RT_PIPE_GROUPS = [
|
||||
{ zh: "输入", en: "in", ico: "in" },
|
||||
{ zh: "行动", en: "act", ico: "act" },
|
||||
{ zh: "工具", en: "tool", ico: "tool", loop: true },
|
||||
{ zh: "输出", en: "out", ico: "out" },
|
||||
{ zh: "结束", en: "done", ico: "done" },
|
||||
];
|
||||
// 图标一律内联 SVG(24x24 / currentColor),不用 emoji/符号字符充当图标。
|
||||
var RT_ICO = {
|
||||
in: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M21 12H8"/><path d="M13 6l-6 6 6 6"/></svg>',
|
||||
act: '<svg class="rt-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"/></svg>',
|
||||
tool: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M14.5 6.5a3.8 3.8 0 0 1 5 5L10 21l-5-5z"/><path d="M14.5 6.5 17.5 9.5"/></svg>',
|
||||
out: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg>',
|
||||
done: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
loop: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M21 3.5V9h-5.5"/></svg>',
|
||||
};
|
||||
|
||||
function rtPhaseGroup(phase) {
|
||||
switch (phase) {
|
||||
case "on_input":
|
||||
return 0;
|
||||
case "pre_action":
|
||||
case "post_action":
|
||||
return 1;
|
||||
case "before_toolcall":
|
||||
case "after_toolcall":
|
||||
return 2;
|
||||
case "before_output":
|
||||
return 3;
|
||||
case "after_output":
|
||||
return 4;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function rtShortTool(name) {
|
||||
var n = String(name || "");
|
||||
var i = n.lastIndexOf("__");
|
||||
if (i >= 0) n = n.slice(i + 2);
|
||||
return n.length > 14 ? n.slice(0, 13) + "…" : n;
|
||||
}
|
||||
// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。
|
||||
// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩文字,看上去就是「这块空着」。
|
||||
function rtSlots(depth, slots, cls) {
|
||||
var n = Math.max(5, Math.min(16, slots || 5));
|
||||
var d = depth || 0;
|
||||
var out = '<span class="rt-slots ' + (cls || "") + '">';
|
||||
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
|
||||
// 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去
|
||||
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
|
||||
return out + "</span>";
|
||||
}
|
||||
// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里;
|
||||
// 同一阶段重复的同一条(如同一工具连调 3 次)只累加计数,不刷屏。
|
||||
function rtTrailPush(g, kind, label, short) {
|
||||
if (!state.stageTrail) state.stageTrail = [];
|
||||
var arr = state.stageTrail;
|
||||
var last = arr.length ? arr[arr.length - 1] : null;
|
||||
if (last && last.g === g && last.kind === kind && last.short === short) {
|
||||
last.n = (last.n || 1) + 1;
|
||||
return;
|
||||
}
|
||||
arr.push({ g: g, kind: kind, label: label, short: short, n: 1 });
|
||||
if (arr.length > 24) arr.shift();
|
||||
}
|
||||
|
||||
function renderRuntimePanel() {
|
||||
var title = __("运行态", "Runtime");
|
||||
var rt = state.runtime;
|
||||
// 工具格的滑入动画只在「新到一条工具调用」那一次播放:overview 是整块
|
||||
// innerHTML 重建,节点每次都是新的;若无条件带动画类,任何重渲染都会闪一下。
|
||||
var toolFlash = !!state.toolFlash;
|
||||
state.toolFlash = false;
|
||||
if (!rt) {
|
||||
return (
|
||||
'<div class="card"><h2>' + title + '</h2><p class="rt-empty">' +
|
||||
__("运行态数据不可用", "runtime unavailable") + "</p></div>"
|
||||
);
|
||||
}
|
||||
var sc = rt.scheduler || {};
|
||||
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
|
||||
var pending = sc.pending_interrupts || 0;
|
||||
var ready = sc.ready_queue_depth || 0;
|
||||
var stack = sc.suspend_stack || 0;
|
||||
var maxStack = sc.max_suspend_depth || 4;
|
||||
var residents = rt.residents || [];
|
||||
var byLv = sc.interrupts_by_level || [];
|
||||
var preLv = sc.preempts_by_level || [];
|
||||
var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
|
||||
// 至少 5 格:0 时也有可见形状
|
||||
var qSlots = Math.max(5, Math.min(16, maxQ));
|
||||
|
||||
var html = '<div class="card"><h2>' + title + "</h2>";
|
||||
// 四个数字块(沿用本 app 的 statCard 风格)
|
||||
html +=
|
||||
'<div class="grid-4">' +
|
||||
statCard(__("排队", "Ready"), ready, "") +
|
||||
statCard(__("中断", "Pending"), pending, "") +
|
||||
statCard(__("栈", "Stack"), stack + "/" + maxStack, "") +
|
||||
statCard(__("子代理", "Subagents"), residents.length, "") +
|
||||
"</div>";
|
||||
|
||||
// ---- 阶段管道:五个等大表框 ----
|
||||
var g = rtPhaseGroup(state.pipelinePhase || "");
|
||||
html +=
|
||||
'<div class="rt-section-title">' + __("阶段管道", "Stage pipeline") +
|
||||
(g < 0 ? " " + __("(空闲)", "(idle)") : "") + "</div>";
|
||||
html += '<div class="rt-pipe-row' + (g < 0 ? " rt-pipe-idle" : "") + '">';
|
||||
html += RT_PIPE_GROUPS.map(function (s, i) {
|
||||
var items = (state.stageTrail || []).filter(function (t) {
|
||||
return (t.g | 0) === i;
|
||||
});
|
||||
// 「工具」是循环格:一轮里可能调几十次工具/输出通道,全部追加会把这一格
|
||||
// 撑成长条,反而看不出「现在在调什么」。只留**最新一条**,右侧给本轮累计
|
||||
// 次数(与 WebUI 同一口径,见 internal/plugins/webui/dashboard.js)。
|
||||
var cls = "rt-pipe-events";
|
||||
var body;
|
||||
if (!items.length) {
|
||||
body = '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
|
||||
} else if (s.loop) {
|
||||
cls += " rt-pipe-scroll";
|
||||
var total = 0;
|
||||
for (var k = 0; k < items.length; k++) total += items[k].n || 1;
|
||||
var latest = items[items.length - 1];
|
||||
var lkind = latest.kind || "stage";
|
||||
var lico = lkind === "output" ? RT_ICO.out : lkind === "tool" ? RT_ICO.tool : "";
|
||||
body =
|
||||
'<span class="rt-chip rt-chip-' + lkind + (toolFlash ? " rt-chip-enter" : "") +
|
||||
'" title="' + escHtml(latest.label) + '">' + lico +
|
||||
'<b class="rt-chip-t">' + escHtml(latest.short || latest.label) + "</b>" +
|
||||
(latest.n > 1 ? '<i class="rt-chip-n">x' + latest.n + "</i>" : "") +
|
||||
"</span>" +
|
||||
'<i class="rt-scroll-count" title="' +
|
||||
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
|
||||
} else {
|
||||
body = items
|
||||
.map(function (t) {
|
||||
var kind = t.kind || "stage";
|
||||
var ico =
|
||||
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
|
||||
return (
|
||||
'<span class="rt-chip rt-chip-' + kind + '" title="' +
|
||||
escHtml(t.label) + '">' + ico +
|
||||
'<b class="rt-chip-t">' + escHtml(t.short || t.label) + "</b>" +
|
||||
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return (
|
||||
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
|
||||
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
|
||||
"<b>" + __(s.zh, s.en) + "</b>" +
|
||||
(s.loop
|
||||
? '<em class="rt-loop" title="' +
|
||||
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
|
||||
'">' + RT_ICO.loop + "</em>"
|
||||
: "") +
|
||||
'</div><div class="' + cls + '">' + body + "</div></div>"
|
||||
);
|
||||
}).join("");
|
||||
html += "</div>";
|
||||
|
||||
// ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)----
|
||||
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
|
||||
html += '<div class="rt-queues">';
|
||||
RT_LEVELS.forEach(function (L) {
|
||||
var depth = q[L.lv] || 0;
|
||||
var reg = byLv[L.lv] || 0;
|
||||
var pre = preLv[L.lv] || 0;
|
||||
var desc = __(L.zh, L.en);
|
||||
html +=
|
||||
'<div class="rt-qcell ' + L.cls + (depth ? " rt-active" : "") +
|
||||
'" title="' + escHtml(desc) + '">' +
|
||||
'<div class="rt-qhead"><b>' + L.name + "</b><span>" + escHtml(desc) + "</span></div>" +
|
||||
'<div class="rt-qnum">' + depth + "</div>" +
|
||||
rtSlots(depth, qSlots, L.cls) +
|
||||
'<div class="rt-qmeta">' + reg + " " + __("登记", "reg") + " · " +
|
||||
pre + " " + __("抢占", "pre") + "</div></div>";
|
||||
});
|
||||
// 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级)
|
||||
html +=
|
||||
'<div class="rt-qcell rt-qcell-queued rt-lv-q' + (ready ? " rt-active" : "") +
|
||||
'" title="' + __("排队(无级别,纯 FIFO)", "queued (no priority, FIFO)") + '">' +
|
||||
'<div class="rt-qhead"><b>' + __("排队", "queued") + "</b><span>FIFO</span></div>" +
|
||||
'<div class="rt-qnum">' + ready + "</div>" +
|
||||
rtSlots(ready, qSlots, "rt-lv-q") +
|
||||
'<div class="rt-qmeta">' + __("无级别", "no priority") + "</div></div>";
|
||||
html += "</div></div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// 开源许可卡:协议标识 + 协议全文 + 源码仓库。
|
||||
// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」——
|
||||
// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。
|
||||
function renderLegalCard() {
|
||||
var b = ((state.kernel || {}).build) || {};
|
||||
var src = b.source_url || "";
|
||||
var lic = b.license || "";
|
||||
var licURL = b.license_url || "";
|
||||
if (!lic && !src) return "";
|
||||
function row(key, val) {
|
||||
return (
|
||||
'<div class="kv-row"><span class="key">' + escHtml(key) +
|
||||
'</span><span class="val">' + val + "</span></div>"
|
||||
);
|
||||
}
|
||||
function a(href, text) {
|
||||
return (
|
||||
'<a href="' + escHtml(href) +
|
||||
'" target="_blank" rel="noopener noreferrer">' + escHtml(text) + "</a>"
|
||||
);
|
||||
}
|
||||
var rows = "";
|
||||
if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic));
|
||||
if (src) rows += row(__("源码仓库", "Source"), a(src, src));
|
||||
// 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。
|
||||
var note =
|
||||
lic && lic.toUpperCase().indexOf("AGPL") >= 0
|
||||
? '<p class="rt-empty">' +
|
||||
__(
|
||||
"网络服务条款(§13):把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径。",
|
||||
"Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.",
|
||||
) +
|
||||
"</p>"
|
||||
: "";
|
||||
return '<div class="card"><h2>' + __("开源许可", "License") + "</h2>" + rows + note + "</div>";
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
var s = state.status || {};
|
||||
var k = state.kernel;
|
||||
@ -1269,25 +1018,7 @@ function renderOverview() {
|
||||
"uptime",
|
||||
) +
|
||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||
statCard(
|
||||
__("版本", "Version"),
|
||||
(function () {
|
||||
// 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。
|
||||
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
|
||||
// 拿不到数据时会向用户展示一个不存在的版本号。
|
||||
var b = (k && k.build) || {};
|
||||
var v = b.version || s.version || "";
|
||||
if (!v) return "-";
|
||||
var sha =
|
||||
b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : "";
|
||||
return (
|
||||
"v" + escHtml(v) +
|
||||
'<div class="stat-sub">' + escHtml(b.kernel_name || "HomeAgent") +
|
||||
(sha ? " · " + escHtml(sha) : "") + "</div>"
|
||||
);
|
||||
})(),
|
||||
"version",
|
||||
) +
|
||||
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||
"</div>";
|
||||
if (k) {
|
||||
html +=
|
||||
@ -1351,7 +1082,6 @@ function renderOverview() {
|
||||
"</span></div>" +
|
||||
"</div></div>";
|
||||
}
|
||||
html += renderRuntimePanel();
|
||||
html +=
|
||||
'<div class="card"><h2>' +
|
||||
__("运行时", "Runtime") +
|
||||
@ -1364,7 +1094,6 @@ function renderOverview() {
|
||||
) +
|
||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||
"</div></div>";
|
||||
html += renderLegalCard();
|
||||
document.getElementById("view-overview").innerHTML = html;
|
||||
}
|
||||
|
||||
@ -5482,48 +5211,17 @@ async function connectFetchSSE(url) {
|
||||
var phase = p.phase || "";
|
||||
var tool = p.tool || "";
|
||||
if (p.channel !== "_consolidation_") {
|
||||
// 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
|
||||
// 与 WebUI 同一套 g(阶段组)编号,见 rtPhaseGroup。
|
||||
if (phase === "on_input") {
|
||||
state.stageTrail = [];
|
||||
rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input"));
|
||||
} else if (phase === "pre_action") {
|
||||
rtTrailPush(
|
||||
1,
|
||||
"stage",
|
||||
__("组装上下文并思考", "assemble context and think"),
|
||||
__("思考", "think"),
|
||||
);
|
||||
if (phase === "pre_action")
|
||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||
} else if (phase === "before_toolcall") {
|
||||
if (tool)
|
||||
rtTrailPush(
|
||||
2,
|
||||
tool.indexOf("output_") === 0 ? "output" : "tool",
|
||||
tool,
|
||||
rtShortTool(tool),
|
||||
);
|
||||
if (tool) state.toolFlash = true;
|
||||
else if (phase === "before_toolcall") {
|
||||
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||
if (!state.pendingTools) state.pendingTools = [];
|
||||
state.pendingTools.push(tool);
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
} else if (phase === "before_output") {
|
||||
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
|
||||
} else if (phase === "before_output")
|
||||
state.chatStage = __("生成回复中...", "Generating response...");
|
||||
} else if (phase === "after_output") {
|
||||
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
|
||||
}
|
||||
state.pipelinePhase = phase;
|
||||
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
|
||||
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
|
||||
state.pipelineTimer = setTimeout(function () {
|
||||
state.pipelinePhase = "";
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}, 2500);
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}
|
||||
var badge = document.getElementById("chat-stage");
|
||||
if (badge) {
|
||||
|
||||
@ -2191,284 +2191,3 @@ td .switch {
|
||||
.kv-row .val .switch {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)=====
|
||||
两块都统一成**等大表框**,与 KPI 卡同一种骨架。
|
||||
此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」,
|
||||
也看不到四级中断队列的积压。 */
|
||||
.stat-card .stat-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
margin: 14px 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rt-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
/* 行内小图标:一律 SVG,不使用 emoji/符号字符。 */
|
||||
.rt-ico {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vertical-align: -1.5px;
|
||||
}
|
||||
.rt-pipe-row {
|
||||
display: grid;
|
||||
/* 100px 下限:容器再窄也要保证 5 个框一行,不留「4 个 + 1 个」的孤行 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 2px 0 8px;
|
||||
transition: opacity 0.3s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rt-pipe-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out),
|
||||
background 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-cell.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.rt-pipe-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-pipe-head > b {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-pipe-head .rt-ico {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-pipe-cell.active .rt-pipe-head > b,
|
||||
.rt-pipe-cell.active .rt-pipe-head .rt-ico {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-pipe-events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 23px;
|
||||
}
|
||||
/* 「工具」格的滚动视口:固定一行高,只露最新一条(与 WebUI 同一口径)。 */
|
||||
.rt-pipe-scroll {
|
||||
flex-wrap: nowrap;
|
||||
height: 23px;
|
||||
min-height: 23px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
.rt-pipe-scroll .rt-chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.rt-chip-enter {
|
||||
animation: rt-chip-scroll-in 0.3s var(--ease-out);
|
||||
}
|
||||
@keyframes rt-chip-scroll-in {
|
||||
from {
|
||||
transform: translateY(115%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rt-scroll-count {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-loop {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-chip-t {
|
||||
font-weight: inherit;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rt-chip-tool {
|
||||
border-color: rgba(136, 192, 208, 0.42);
|
||||
color: var(--frost-300);
|
||||
}
|
||||
.rt-chip-output {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-chip-stage {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.rt-chip-none {
|
||||
opacity: 0.35;
|
||||
border-style: dashed;
|
||||
}
|
||||
.rt-chip-n {
|
||||
font-style: normal;
|
||||
opacity: 0.75;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 队列:5 个等大框(L4/L3/L2/L1 + 排队),一行排开 */
|
||||
.rt-queues {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rt-qcell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-qhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-qhead > b {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rt-qhead > span {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-qnum {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qmeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; }
|
||||
.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; }
|
||||
.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; }
|
||||
/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */
|
||||
.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); }
|
||||
.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); }
|
||||
.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; }
|
||||
/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */
|
||||
.rt-qcell.rt-qcell-queued {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
|
||||
用进度条时队列为 0 宽度就是 0,整行只剩文字,看上去就是「这块空着」。 */
|
||||
.rt-slots {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-slots > i {
|
||||
flex: 1 1 0;
|
||||
min-width: 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition:
|
||||
background 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
|
||||
.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
|
||||
.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); }
|
||||
.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
|
||||
.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); }
|
||||
.rt-slots-more {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@ -1,869 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// defaultSystemPrompt 是内置默认人格模板:不含版本号字面量,
|
||||
// 被问版本时以运行时快照为准(历史上写死版本号导致实例自称旧版本)。
|
||||
const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。
|
||||
- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
|
||||
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
|
||||
- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write,
|
||||
不要用 echo 拼接长文本。
|
||||
- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
|
||||
|
||||
// setupLogging 初始化日志:行号前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func setupLogging(dataDir string) string {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// 文件日志:同时输出到控制台和 data/log/ 目录
|
||||
logDir := filepath.Join(dataDir, "log")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[homed] warning: cannot create log dir: %v", err)
|
||||
} else {
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: cannot open log file: %v", err)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
log.Printf("[homed] logging to %s", logPath)
|
||||
}
|
||||
}
|
||||
|
||||
return logDir
|
||||
}
|
||||
|
||||
// ensureDataDirs 建好启动期需要的全部目录,返回 agent 的 overlayfs 工作目录。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func ensureDataDirs(dataDir string) string {
|
||||
agentWorkDir := filepath.Join(dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
filepath.Join(dataDir, "snapshots"),
|
||||
filepath.Join(dataDir, "plugins"),
|
||||
filepath.Join(dataDir, "changesets"),
|
||||
filepath.Join(dataDir, "memory"),
|
||||
filepath.Join(dataDir, "memory", "raw"),
|
||||
filepath.Join(dataDir, "adapters"),
|
||||
agentWorkDir,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
log.Fatalf("create dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
return agentWorkDir
|
||||
}
|
||||
|
||||
// memoryStack 聚合记忆侧组件:图库、索引器、社交图、蒸馏器。
|
||||
type memoryStack struct {
|
||||
db *memory.GraphDB
|
||||
indexer *memory.Indexer
|
||||
social *social.SocialStore
|
||||
distiller *pipeline.Distiller
|
||||
}
|
||||
|
||||
// initMemoryStack 初始化图记忆 / 索引 / 社交图 / 蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMemoryStack(dataDir string) (*memoryStack, func()) {
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
}
|
||||
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
distiller := pipeline.NewDistiller(memDB, dataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
|
||||
func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停蒸馏器,再关图库。
|
||||
if memDB != nil {
|
||||
distiller.Stop()
|
||||
}
|
||||
if memDB != nil {
|
||||
memDB.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startMemoryCandidateConsumer 起一个常驻 goroutine:把 eventbus 上的 memory_candidate 事件写进文本记忆并喂给蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startMemoryCandidateConsumer(ctx context.Context, iom *agentIO.IOManager, textMem *text.Memory, memDB *memory.GraphDB, distiller *pipeline.Distiller) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt, ok := <-iom.OutputChan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if evt.Target == "memory" && evt.Type == "memory_candidate" {
|
||||
source, _ := evt.Payload["source"].(string)
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
|
||||
if input != "" && textMem != nil {
|
||||
te := text.Event{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Source: source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if err := textMem.Append(te); err != nil {
|
||||
log.Printf("[homed] text memory append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// initLLMProviders 按配置注册全部 LLM 源(每个源经 Lua 适配器协议转换),并把各适配器的并发额度汇总回 Lua VM。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initLLMProviders(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *agentAPI.ProviderManager {
|
||||
providerMgr := agentAPI.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
|
||||
return providerMgr
|
||||
}
|
||||
|
||||
// initDocStore 启动文档记忆。flush 的唯一入口是 Stop(),所以关停时必须调用它。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initDocStore(cfg *types.Config) (*document.Store, func()) {
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
|
||||
return docStore, func() { docStore.Stop() }
|
||||
}
|
||||
|
||||
// initMediaStore 按开关启动内容寻址的媒体存储;开不起来只告警(媒体记忆非对话必需品)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMediaStore(cfgReg *internalConfig.ConfigRegistry, cfg *types.Config) (*media.Store, func()) {
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
ms, err := media.New(mediaDir)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
return mediaStore, func() {
|
||||
if mediaStore != nil {
|
||||
mediaStore.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initMultimodalSpace 从公共注册表打开多模态向量 provider。返回 (空间, provider 名, 失败原因, cleanup):后两个值只用于状态报告。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMultimodalSpace(cfgReg *internalConfig.ConfigRegistry) (vector.MultimodalEmbedder, string, string, func()) {
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
var closeAdapted func()
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
|
||||
if closeAdapted == nil {
|
||||
closeAdapted = func() {}
|
||||
}
|
||||
return multimodalSpace, mmProviderName, mmErr, closeAdapted
|
||||
}
|
||||
|
||||
// initKnowledgeStore 启动知识库。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initKnowledgeStore(cfg *types.Config) *knowledge.Store {
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||
}
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPersonality(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) *agentPkg.Personality {
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
return personality
|
||||
}
|
||||
|
||||
// newStageAndRegistry 建阶段管道与插件注册表,把内核依赖接到注册表上。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newStageAndRegistry(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, iom *agentIO.IOManager,
|
||||
evBus *events.Bus, memDB *memory.GraphDB, textMem *text.Memory, docStore *document.Store,
|
||||
mediaStore *media.Store, ks *knowledge.Store, providerMgr *agentAPI.ProviderManager,
|
||||
dataDir string) (*agentCore.StageHost, *plugin.Registry) {
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetEventBus(evBus)
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetProviderManager(providerMgr)
|
||||
pluginReg.SetConfigRegistry(cfgReg)
|
||||
pluginReg.SetPluginDir(cfg.Plugin.Dir)
|
||||
pluginReg.SetDataDir(dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||
|
||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
|
||||
return stageHost, pluginReg
|
||||
}
|
||||
|
||||
// newMainAgent 组装主 Agent:把内核各面(IO/记忆/文档/知识/媒体/社交/文本/插件/状态)接进 AgentConfig。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, provider agentAPI.Provider,
|
||||
providerMgr *agentAPI.ProviderManager, iom *agentIO.IOManager, memDB *memory.GraphDB,
|
||||
memIdx *memory.Indexer, trk *tracker.Tracker, docStore *document.Store, ks *knowledge.Store,
|
||||
socialStore *social.SocialStore, textMem *text.Memory, mediaStore *media.Store,
|
||||
personality *agentPkg.Personality, pluginReg *plugin.Registry, embedder *memory.StaticEmbedder,
|
||||
multimodalSpace vector.MultimodalEmbedder, mmProviderName, mmErr string,
|
||||
stageHost *agentCore.StageHost, evBus *events.Bus) *agentCore.Agent {
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultSystemPrompt
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// initONNXParser 初始化依存句法分析器(内嵌 ONNX 模型,失败则退回规则引擎)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initONNXParser(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) {
|
||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
} else {
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlugins 建插件目录、按启动模式决定 allowlist,然后加载全部插件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
stageHost *agentCore.StageHost, bootMode, dataDir string) {
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if bootMode == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
|
||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
}
|
||||
|
||||
// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
|
||||
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||
if sp := pluginReg.Get("skillmgr"); sp != nil {
|
||||
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
||||
agent.SetSkillIndexProvider(prov)
|
||||
log.Printf("[homed] skill index wired from skillmgr plugin")
|
||||
}
|
||||
}
|
||||
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
go logManager.Start(ctx)
|
||||
|
||||
agent.Start()
|
||||
|
||||
return func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停 agent,再停日志管理。
|
||||
agent.Stop()
|
||||
logManager.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
|
||||
started := false
|
||||
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
|
||||
st := agent.GetKernelStatus()
|
||||
llmOK := st != nil && st.LLM.Available
|
||||
tools := 0
|
||||
if st != nil {
|
||||
tools = len(st.Tools)
|
||||
}
|
||||
uptime := int64(0)
|
||||
if st != nil {
|
||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
||||
uptime = int64(d.Seconds())
|
||||
}
|
||||
}
|
||||
return &ipc.Status{
|
||||
PID: os.Getpid(),
|
||||
Boot: bootMode,
|
||||
UptimeSec: uptime,
|
||||
LLMOK: &llmOK,
|
||||
Tools: tools,
|
||||
LastDiag: lastDiagSummary(dataDir),
|
||||
}
|
||||
})
|
||||
if err := ipcServer.Start(); err != nil {
|
||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
||||
} else {
|
||||
}
|
||||
|
||||
return ipcServer, func() {
|
||||
if started {
|
||||
ipcServer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
|
||||
sup.SetTracker(trk)
|
||||
sup.RegisterAgent("main")
|
||||
|
||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
||||
restartCh := make(chan struct{}, 1)
|
||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
||||
st := agent.GetKernelStatus()
|
||||
if st == nil {
|
||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
||||
}
|
||||
h := types.HealthHealthy
|
||||
if !st.LLM.Available {
|
||||
h = types.HealthDegraded
|
||||
}
|
||||
return time.Now(), h, nil
|
||||
})
|
||||
sup.SetRestartHandler(func(id types.AgentID) {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return restartCh
|
||||
}
|
||||
|
||||
// startHeartbeat 每 5s 触碰 <data>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startHeartbeat(dataDir string, ctx context.Context) func() {
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { close(hbStop) }
|
||||
}
|
||||
|
||||
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
|
||||
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
stopHeartbeat()
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
}
|
||||
|
||||
// initLuaVM 起 Lua VM(LLM 协议适配);启动失败只告警,cleanup 为 no-op。
|
||||
func initLuaVM(cfg *types.Config) (*luapkg.VM, func()) {
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
return luaVM, func() {}
|
||||
}
|
||||
return luaVM, luaVM.Stop
|
||||
}
|
||||
|
||||
// initSupervisor 起守护管理(代理生命周期管理);起不来是致命错误。
|
||||
func initSupervisor(cfg *types.Config) *supervisor.Daemon {
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
return sup
|
||||
}
|
||||
|
||||
// initTracker 起 overlayfs 变更追踪;无 overlayfs 支持时降级为非致命告警。
|
||||
func initTracker(cfg *types.Config, agentWorkDir string) *tracker.Tracker {
|
||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else {
|
||||
if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
}
|
||||
return trk
|
||||
}
|
||||
|
||||
// initKernelAPI 建内核与插件之间的两个通道:IOManager(IO 抽象层)+ EventBus(事件总线)。
|
||||
func initKernelAPI() (*agentIO.IOManager, *events.Bus) {
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
return iom, evBus
|
||||
}
|
||||
|
||||
// initTextMemory 起文本记忆;启动失败只告警,cleanup 为 no-op。
|
||||
func initTextMemory(cfg *types.Config) (*text.Memory, func()) {
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
return textMem, func() {}
|
||||
}
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
return textMem, textMem.Stop
|
||||
}
|
||||
|
||||
// resolveBaseAPIKey 解析兜底 API key:配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
|
||||
func resolveBaseAPIKey(cfg *types.Config) string {
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// wirePluginSDK 把内核各面注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)。
|
||||
func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey string, sup *supervisor.Daemon,
|
||||
trk *tracker.Tracker, cfg *types.Config, stageHost *agentCore.StageHost, memIdx *memory.Indexer,
|
||||
agent *agentCore.Agent) {
|
||||
pluginReg.SetLuaVM(luaVM)
|
||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
||||
pluginReg.SetTracker(trk)
|
||||
pluginReg.SetConfig(cfg)
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
}
|
||||
|
||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
||||
//
|
||||
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。
|
||||
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
|
||||
//
|
||||
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
|
||||
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
|
||||
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
|
||||
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**,
|
||||
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。
|
||||
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
|
||||
if strings.TrimSpace(httpAddr) != "" {
|
||||
return strings.TrimSpace(httpAddr)
|
||||
}
|
||||
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
|
||||
// 否则会把用户在设置页里改过的插件 addr 顶掉。
|
||||
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// options 是 worker 的命令行参数。
|
||||
type options struct {
|
||||
dataDir string
|
||||
httpAddr string
|
||||
cliSocket string
|
||||
role string
|
||||
boot string
|
||||
}
|
||||
|
||||
// parseFlags 解析命令行参数。
|
||||
func parseFlags() options {
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||
flag.Parse()
|
||||
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
||||
}
|
||||
|
||||
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
|
||||
//
|
||||
// 触发条件(见 internal/config.MaybeCompact):空闲页 >= 1MB 且占页数 >= 25%。
|
||||
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
|
||||
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
|
||||
before := int64(-1)
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
before = st.Size()
|
||||
}
|
||||
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
|
||||
return
|
||||
}
|
||||
if !done {
|
||||
return
|
||||
}
|
||||
after := before
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
after = st.Size()
|
||||
}
|
||||
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
|
||||
}
|
||||
@ -2,184 +2,672 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
|
||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// main 是 worker 进程的启动序列。
|
||||
//
|
||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
||||
// 因此释放顺序与拆分前完全一致。
|
||||
func main() {
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
opt := parseFlags()
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||
flag.Parse()
|
||||
|
||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||
if opt.role == "guard" {
|
||||
runGuard(resolveDataDir(opt.dataDir))
|
||||
if *role == "guard" {
|
||||
runGuard(resolveDataDir(*dataDir))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
||||
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||
|
||||
if opt.dataDir == "" {
|
||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
||||
if *dataDir == "" {
|
||||
*dataDir = resolveDataDir(*dataDir)
|
||||
}
|
||||
|
||||
if opt.cliSocket == "" {
|
||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
||||
if *cliSocket == "" {
|
||||
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||
}
|
||||
|
||||
logDir := setupLogging(opt.dataDir)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// 文件日志:同时输出到控制台和 data/log/ 目录
|
||||
logDir := filepath.Join(*dataDir, "log")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[homed] warning: cannot create log dir: %v", err)
|
||||
} else {
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: cannot open log file: %v", err)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
log.Printf("[homed] logging to %s", logPath)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||
|
||||
agentWorkDir := ensureDataDirs(opt.dataDir)
|
||||
agentWorkDir := filepath.Join(*dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
*dataDir,
|
||||
filepath.Join(*dataDir, "snapshots"),
|
||||
filepath.Join(*dataDir, "plugins"),
|
||||
filepath.Join(*dataDir, "changesets"),
|
||||
filepath.Join(*dataDir, "memory"),
|
||||
filepath.Join(*dataDir, "memory", "raw"),
|
||||
filepath.Join(*dataDir, "adapters"),
|
||||
agentWorkDir,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
log.Fatalf("create dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 基础设施层:记忆、技能 ----
|
||||
// ========================================================================
|
||||
// 基础设施层:记忆、技能
|
||||
// ========================================================================
|
||||
|
||||
mem, closeMem := initMemoryStack(opt.dataDir)
|
||||
defer closeMem()
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
defer memDB.Close()
|
||||
}
|
||||
|
||||
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
|
||||
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 配置中心(SQLite 持久化,唯一配置源)
|
||||
// ========================================================================
|
||||
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db"))
|
||||
defer cfgReg.Close()
|
||||
cfgReg.SeedDefaults(opt.dataDir)
|
||||
cfgReg.SeedDefaults(*dataDir)
|
||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json"))
|
||||
cfg := cfgReg.ToConfig()
|
||||
|
||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||
mem.distiller.SetEmbedder(embedder)
|
||||
distiller.SetEmbedder(embedder)
|
||||
|
||||
// ---- Lua VM(LLM 协议适配) ----
|
||||
// ========================================================================
|
||||
// Lua VM(LLM 协议适配)
|
||||
// ========================================================================
|
||||
|
||||
luaVM, closeLuaVM := initLuaVM(cfg)
|
||||
defer closeLuaVM()
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
} else {
|
||||
defer luaVM.Stop()
|
||||
}
|
||||
|
||||
// ---- 守护管理(代理生命周期管理) ----
|
||||
// ========================================================================
|
||||
// 守护管理(代理生命周期管理)
|
||||
// ========================================================================
|
||||
|
||||
sup := initSupervisor(cfg)
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
|
||||
// ---- 变更追踪(overlayfs) ----
|
||||
// ========================================================================
|
||||
// 变更追踪(overlayfs)
|
||||
// ========================================================================
|
||||
|
||||
trk := initTracker(cfg, agentWorkDir)
|
||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else {
|
||||
if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
||||
// 所有插件通过这两个通道与核心交互
|
||||
// ========================================================================
|
||||
|
||||
iom, evBus := initKernelAPI()
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
|
||||
// ---- 文本记忆 + 记忆蒸馏管线 ----
|
||||
// ========================================================================
|
||||
// 文本记忆 + 记忆蒸馏管线
|
||||
// ========================================================================
|
||||
|
||||
textMem, closeTextMem := initTextMemory(cfg)
|
||||
defer closeTextMem()
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
} else {
|
||||
defer textMem.Stop()
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
}
|
||||
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
|
||||
startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.distiller)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt, ok := <-iom.OutputChan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if evt.Target == "memory" && evt.Type == "memory_candidate" {
|
||||
source, _ := evt.Payload["source"].(string)
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
|
||||
// ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
|
||||
if input != "" && textMem != nil {
|
||||
te := text.Event{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Source: source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if err := textMem.Append(te); err != nil {
|
||||
log.Printf("[homed] text memory append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
baseAPIKey := resolveBaseAPIKey(cfg)
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
|
||||
providerMgr := initLLMProviders(cfg, luaVM, baseAPIKey)
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// ========================================================================
|
||||
// LLM Provider 管理(多源,通过 Lua 适配器协议转换)
|
||||
// ========================================================================
|
||||
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
baseAPIKey := apiKey
|
||||
|
||||
providerMgr := agentAPI.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
provider := providerMgr.Default()
|
||||
|
||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||
if opt.boot == "failback" {
|
||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
if *boot == "failback" {
|
||||
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
}
|
||||
|
||||
// ---- 文档记忆 + 知识库 ----
|
||||
// ========================================================================
|
||||
// 文档记忆 + 知识库
|
||||
// ========================================================================
|
||||
|
||||
docStore, closeDocStore := initDocStore(cfg)
|
||||
defer closeDocStore()
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
|
||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
||||
defer closeMediaStore()
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过,
|
||||
// 对话行为与本特性上线前完全一致。
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
|
||||
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)",
|
||||
st["count"], st["total_bytes"], maxMB)
|
||||
}
|
||||
}
|
||||
|
||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
||||
defer closeMultimodal()
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||
}
|
||||
|
||||
ks := initKnowledgeStore(cfg)
|
||||
// ========================================================================
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
|
||||
// ---- 人格设定 ----
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||
}
|
||||
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
// ========================================================================
|
||||
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||
// ========================================================================
|
||||
|
||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetEventBus(evBus)
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetProviderManager(providerMgr)
|
||||
pluginReg.SetConfigRegistry(cfgReg)
|
||||
pluginReg.SetPluginDir(cfg.Plugin.Dir)
|
||||
pluginReg.SetDataDir(*dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||
|
||||
// ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
|
||||
agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
||||
// ========================================================================
|
||||
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||
// ========================================================================
|
||||
|
||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
||||
defaultPrompt := `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。
|
||||
- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
|
||||
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
|
||||
- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write,
|
||||
不要用 echo 拼接长文本。
|
||||
- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultPrompt
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour),
|
||||
MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour),
|
||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
||||
Personality: personality,
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
// 通过 Registry 将内核依赖注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)
|
||||
pluginReg.SetLuaVM(luaVM)
|
||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
||||
pluginReg.SetTracker(trk)
|
||||
pluginReg.SetConfig(cfg)
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
|
||||
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||
cli.DefaultSocket = opt.cliSocket
|
||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
||||
cli.DefaultSocket = *cliSocket
|
||||
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||
// 保留 CLI --webui 与 webui.listen_addr 配置对监听地址的覆盖。
|
||||
webuiListenAddr := *httpAddr
|
||||
if webuiListenAddr == "" {
|
||||
webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080")
|
||||
}
|
||||
if ps := cfgReg.PluginConfig("webui"); ps != nil {
|
||||
if v, _ := ps.Get("addr"); v == nil {
|
||||
_ = ps.Set("addr", webuiListenAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ----
|
||||
// ========================================================================
|
||||
// 依存句法分析器(内嵌 ONNX 模型 / 规则引擎)
|
||||
// ========================================================================
|
||||
|
||||
initONNXParser(cfg, cfgReg)
|
||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
} else {
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
}
|
||||
|
||||
loadPlugins(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
|
||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
||||
compactConfigDB(cfgReg)
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if *boot == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(*dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
|
||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
||||
defer stopRuntime()
|
||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
|
||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
||||
defer closeIPC()
|
||||
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||
if sp := pluginReg.Get("skillmgr"); sp != nil {
|
||||
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
||||
agent.SetSkillIndexProvider(prov)
|
||||
log.Printf("[homed] skill index wired from skillmgr plugin")
|
||||
}
|
||||
}
|
||||
|
||||
restartCh := startSupervisorRuntime(sup, trk, agent)
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
go logManager.Start(ctx)
|
||||
defer logManager.Stop()
|
||||
|
||||
agent.Start()
|
||||
defer agent.Stop()
|
||||
|
||||
// PING/ACK 心跳服务:worker 监听 unix socket,guard 发 PING、worker 回 ACK
|
||||
// (含自诊断 kernel 状态快照),替换纯文件心跳。文件心跳保留作回退。
|
||||
ipcServer := ipc.NewServer(*dataDir, func() *ipc.Status {
|
||||
st := agent.GetKernelStatus()
|
||||
llmOK := st != nil && st.LLM.Available
|
||||
tools := 0
|
||||
if st != nil {
|
||||
tools = len(st.Tools)
|
||||
}
|
||||
uptime := int64(0)
|
||||
if st != nil {
|
||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
||||
uptime = int64(d.Seconds())
|
||||
}
|
||||
}
|
||||
return &ipc.Status{
|
||||
PID: os.Getpid(),
|
||||
Boot: *boot,
|
||||
UptimeSec: uptime,
|
||||
LLMOK: &llmOK,
|
||||
Tools: tools,
|
||||
LastDiag: lastDiagSummary(*dataDir),
|
||||
}
|
||||
})
|
||||
if err := ipcServer.Start(); err != nil {
|
||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
||||
} else {
|
||||
defer ipcServer.Stop()
|
||||
}
|
||||
|
||||
sup.SetTracker(trk)
|
||||
sup.RegisterAgent("main")
|
||||
|
||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
||||
restartCh := make(chan struct{}, 1)
|
||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
||||
st := agent.GetKernelStatus()
|
||||
if st == nil {
|
||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
||||
}
|
||||
h := types.HealthHealthy
|
||||
if !st.LLM.Available {
|
||||
h = types.HealthDegraded
|
||||
}
|
||||
return time.Now(), h, nil
|
||||
})
|
||||
sup.SetRestartHandler(func(id types.AgentID) {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
|
||||
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||
|
||||
// ---- 等待退出信号 ----
|
||||
// ========================================================================
|
||||
// 等待退出信号
|
||||
// ========================================================================
|
||||
|
||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(*dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
close(hbStop)
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// requireSupportedPlatform 在受支持的平台上不做任何事。
|
||||
//
|
||||
// 平台策略见 platform_windows.go:只有 homed 放弃 Windows 原生支持
|
||||
// (插件体系依赖 fd 继承与共享内存段内偏移),Windows 用户走 WSL2。
|
||||
func requireSupportedPlatform() {}
|
||||
@ -1,44 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
|
||||
//
|
||||
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
|
||||
//
|
||||
// homed 的插件体系建立在两个原语上——**继承的 fd**(Single memfd: 统一共享
|
||||
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
|
||||
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
|
||||
//
|
||||
// Windows 的等价物是命名内核对象(CreateFileMappingW / OpenEventW)+句柄表,
|
||||
// 没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
|
||||
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
|
||||
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
|
||||
// 导致改写型插件在某个平台上静默失效」的教训(§9.2)。
|
||||
//
|
||||
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
|
||||
// WSL2 里就是普通 linux/amd64,走与我们测试矩阵完全相同的那条路径。
|
||||
//
|
||||
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
|
||||
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
|
||||
func requireSupportedPlatform() {
|
||||
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
|
||||
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
|
||||
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "请改用 WSL2:")
|
||||
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
|
||||
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap)")
|
||||
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed:与 Linux 主机完全相同,无需额外配置")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
|
||||
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
|
||||
os.Exit(2)
|
||||
}
|
||||
@ -17,36 +17,6 @@ func randomSecret(n int) string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// must 让失败真正停下来。
|
||||
//
|
||||
// 这里曾经把所有 db.Exec 的返回值丢掉,配合 CGO_ENABLED=0 构建(go-sqlite3
|
||||
// 退化成静态桩),得到的是一个**完全静默的空操作**:打印凭据、退出码 0、
|
||||
// config.db 里一个字节都没写。调用方(安装脚本)无法区分成败,用户装完
|
||||
// 照着 credentials.txt 登录必然失败。
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// verify 回读刚写入的值。
|
||||
//
|
||||
// 只看 Exec 有没有报错不够:驱动被换掉(如上面的桩)、路径不对、写入被丢弃,
|
||||
// 都可能返回 nil 而什么都没落下。这里把真实落盘的值读回来,与预期逐一比对,
|
||||
// 不一致就非零退出——"初始化脚本说自己成功了"必须由数据库内容佐证。
|
||||
func verify(db *sql.DB, table, key, want string) {
|
||||
var got string
|
||||
if err := db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, table), key).Scan(&got); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 回读 %s.%s 失败: %v\n", table, key, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if got != want {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %s.%s 与写入值不一致(读回 %q)\n", table, key, got)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "data directory")
|
||||
webuiUsername := flag.String("username", "admin", "webui username")
|
||||
@ -63,24 +33,16 @@ func main() {
|
||||
|
||||
dbPath := *dataDir + "/config.db"
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
must(err)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 尽早验证数据库真的可用:sql.Open 是惰性的,不碰一次不会暴露驱动问题。
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
const listenAddr = ":8080"
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", listenAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 webui.listen_addr 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080")
|
||||
|
||||
pw := *webuiPassword
|
||||
if pw == "" {
|
||||
@ -91,28 +53,13 @@ func main() {
|
||||
apiKey = randomSecret(16)
|
||||
}
|
||||
|
||||
const pt = "config_webui"
|
||||
if _, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pt := "config_webui"
|
||||
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt))
|
||||
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
|
||||
for _, kv := range [][2]string{
|
||||
{"api_key", apiKey},
|
||||
{"username", *webuiUsername},
|
||||
{"password", pw},
|
||||
{"session_ttl_hours", "24"},
|
||||
} {
|
||||
if _, err := db.Exec(ws, kv[0], kv[1]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 %s.%s 失败: %v\n", pt, kv[0], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
verify(db, pt, "api_key", apiKey)
|
||||
verify(db, pt, "username", *webuiUsername)
|
||||
verify(db, pt, "password", pw)
|
||||
verify(db, "config", "webui.listen_addr", listenAddr)
|
||||
db.Exec(ws, "api_key", apiKey)
|
||||
db.Exec(ws, "username", *webuiUsername)
|
||||
db.Exec(ws, "password", pw)
|
||||
db.Exec(ws, "session_ttl_hours", "24")
|
||||
|
||||
fmt.Printf("API_KEY=%s\n", apiKey)
|
||||
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1001001,
|
||||
"versionName": "1.1.1",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
|
||||
@ -32,10 +32,6 @@ export class ApiClient {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
clearConnection(): void {
|
||||
this.conn = null;
|
||||
}
|
||||
|
||||
getConnection(): ConnectionConfig | null {
|
||||
return this.conn;
|
||||
}
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 附件的字节获取与解码(网络 / 沙箱 I/O)。
|
||||
*
|
||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||
*
|
||||
* 从 components/Attachment.ets 抽出:缩略图与详情大图都走同一条解码路径。
|
||||
*/
|
||||
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { apiClient } from './ApiClient';
|
||||
|
||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
||||
export async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
|
||||
try {
|
||||
// 本地待上传的图片:直接读沙箱文件,不走网络
|
||||
if (url.startsWith('file://')) {
|
||||
const path: string = url.substring(7);
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const localSrc: image.ImageSource = image.createImageSource(f.fd);
|
||||
const localPm: image.PixelMap = await localSrc.createPixelMap();
|
||||
await localSrc.release();
|
||||
fileIo.closeSync(f);
|
||||
return localPm;
|
||||
}
|
||||
const abs: string = apiClient.absoluteUrl(url);
|
||||
const resp = await apiClient.getBinary(abs, 15000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const pm: image.PixelMap = await src.createPixelMap();
|
||||
await src.release();
|
||||
return pm;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 附件的解析与格式化(纯函数,无 UI、无平台 I/O)。
|
||||
*
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 从 components/Attachment.ets 抽出:附件卡与附件详情都要用这几个函数,
|
||||
* 放在 common 里两边共用,也不必让 UI 文件承担这段纯逻辑。
|
||||
*/
|
||||
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
|
||||
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
|
||||
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||
const url: string = o['url'] as string ?? '';
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const t: string = o['type'] as string ?? 'file';
|
||||
const a: ChatAttachment = {
|
||||
type: t === 'image' ? 'image' : 'file',
|
||||
url: url,
|
||||
size: o['size'] as number ?? 0,
|
||||
name: o['name'] as string ?? fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
|
||||
export function attachmentFromChannelOutput(
|
||||
outputType: string, url: string, size: number): ChatAttachment | undefined {
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (outputType !== 'image' && outputType !== 'file') {
|
||||
return undefined;
|
||||
}
|
||||
const a: ChatAttachment = {
|
||||
type: outputType,
|
||||
url: url,
|
||||
size: size,
|
||||
name: fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
|
||||
export function fileNameOf(url: string): string {
|
||||
let s: string = url;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const i: number = s.lastIndexOf('/');
|
||||
const name: string = i >= 0 ? s.substring(i + 1) : s;
|
||||
return name.length > 0 ? name : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (n < 1024) {
|
||||
return n.toString() + ' B';
|
||||
}
|
||||
const kb: number = n / 1024;
|
||||
if (kb < 1024) {
|
||||
return oneDecimal(kb) + ' KB';
|
||||
}
|
||||
const mb: number = kb / 1024;
|
||||
if (mb < 1024) {
|
||||
return oneDecimal(mb) + ' MB';
|
||||
}
|
||||
return oneDecimal(mb / 1024) + ' GB';
|
||||
}
|
||||
|
||||
function oneDecimal(v: number): string {
|
||||
return (Math.round(v * 10) / 10).toString();
|
||||
}
|
||||
|
||||
/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */
|
||||
export function extLabel(name: string): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
if (i < 0 || i === name.length - 1) {
|
||||
return '未知类型';
|
||||
}
|
||||
return name.substring(i + 1).toUpperCase();
|
||||
}
|
||||
|
||||
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
|
||||
export function sanitize(name: string): string {
|
||||
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
|
||||
if (s.length === 0) {
|
||||
s = 'attachment';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@ -4,63 +4,16 @@ import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
|
||||
export const LOCAL_DEVICE_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DeviceStatusPayload {
|
||||
device_id: string;
|
||||
status: string;
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
interface DeviceDetails {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
brand: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
series: string;
|
||||
sdk_api_version: number;
|
||||
security_patch: string;
|
||||
abi_list: string;
|
||||
device_type: string;
|
||||
}
|
||||
|
||||
interface DeviceInfoPayload {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
caps: string[];
|
||||
info: DeviceDetails;
|
||||
}
|
||||
|
||||
const APP_STARTED_AT: number = Date.now();
|
||||
|
||||
function okResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
@ -91,79 +44,53 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||
*/
|
||||
export async function capScreensee(): Promise<CapResult> {
|
||||
let full: image.PixelMap | null = null;
|
||||
let packer: image.ImagePacker | null = null;
|
||||
try {
|
||||
full = await captureScreenPixelMap();
|
||||
const full: image.PixelMap = await captureScreenPixelMap();
|
||||
const info: image.ImageInfo = await full.getImageInfo();
|
||||
const maxW: number = 420;
|
||||
const maxH: number = 640;
|
||||
let scale: number = 1;
|
||||
if (info.size.width > maxW) {
|
||||
scale = maxW / info.size.width;
|
||||
const maxW: number = 720;
|
||||
let targetW: number = info.size.width;
|
||||
let targetH: number = info.size.height;
|
||||
if (targetW > maxW) {
|
||||
targetH = Math.floor(targetH * maxW / targetW);
|
||||
targetW = maxW;
|
||||
}
|
||||
if (info.size.height * scale > maxH) {
|
||||
scale = maxH / info.size.height;
|
||||
let packed: ArrayBuffer;
|
||||
if (targetW !== info.size.width) {
|
||||
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
||||
}
|
||||
if (scale < 1) {
|
||||
await full.scale(scale, scale);
|
||||
}
|
||||
packer = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
|
||||
const packed: ArrayBuffer = await packer.packing(full, opt);
|
||||
const packer: image.ImagePacker = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
||||
packed = await packer.packing(full, opt);
|
||||
packer.release();
|
||||
full.release();
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||
if (b64.length > 950000) {
|
||||
return errResult('当前画面数据过大,请稍后重试');
|
||||
}
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
|
||||
} finally {
|
||||
if (packer !== null) {
|
||||
packer.release();
|
||||
}
|
||||
if (full !== null) {
|
||||
full.release();
|
||||
}
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('screensee failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||
|
||||
/**
|
||||
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
|
||||
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
|
||||
*/
|
||||
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const result: PermissionRequestResult =
|
||||
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
|
||||
return result.authResults.length > 0 && result.authResults[0] === 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||
const granted: boolean = await ensureClipboardPermission(context);
|
||||
if (!granted) {
|
||||
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
|
||||
}
|
||||
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
||||
// 这里直接尝试读取;系统拒绝时回错误信息。
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const has: boolean = await clip.hasData();
|
||||
if (!has) {
|
||||
return okResult('');
|
||||
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
||||
return empty;
|
||||
}
|
||||
const data: pasteboard.PasteData = await clip.getData();
|
||||
const txt: string = data.getPrimaryText();
|
||||
return okResult(txt ?? '');
|
||||
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
||||
return out;
|
||||
} catch (e) {
|
||||
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,9 +99,10 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||
await clip.setPasteData(data);
|
||||
return okResult('clipboard written');
|
||||
return okResult('written ' + text.length + ' chars');
|
||||
} catch (e) {
|
||||
return errResult('剪贴板写入失败,请保持应用在前台后重试');
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsue failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,11 +112,13 @@ class TtsSession {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||
|
||||
async speak(text: string): Promise<CapResult> {
|
||||
if (text.length > 4000) {
|
||||
return errResult('朗读内容过长,请缩短到 4000 字以内');
|
||||
}
|
||||
try {
|
||||
if (this.engine === null) {
|
||||
const extra: Record<string, Object> = {
|
||||
'style': 'interaction-broadcast',
|
||||
'locate': 'CN',
|
||||
'name': 'EngineName',
|
||||
};
|
||||
const params: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
@ -203,7 +133,8 @@ class TtsSession {
|
||||
this.engine.speak(text, sp);
|
||||
return okResult('speaking');
|
||||
} catch (e) {
|
||||
return errResult('语音服务暂时不可用,请稍后重试');
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('speakeruse failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,49 +156,20 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
|
||||
return ttsSession.speak(text);
|
||||
}
|
||||
|
||||
export function shutdownSpeakerUse(): void {
|
||||
ttsSession.shutdown();
|
||||
}
|
||||
// ===== deviceinfo =====
|
||||
|
||||
// ===== status / deviceinfo =====
|
||||
|
||||
export function capStatus(deviceId: string): CapResult {
|
||||
const payload: DeviceStatusPayload = {
|
||||
device_id: deviceId,
|
||||
status: 'online',
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
const details: DeviceDetails = {
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
os_release: deviceInfo.osFullName,
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
brand: deviceInfo.brand,
|
||||
manufacturer: deviceInfo.manufacture,
|
||||
model: deviceInfo.productModel,
|
||||
series: deviceInfo.productSeries,
|
||||
sdk_api_version: deviceInfo.sdkApiVersion,
|
||||
security_patch: deviceInfo.securityPatchTag,
|
||||
abi_list: deviceInfo.abiList,
|
||||
device_type: deviceInfo.deviceType,
|
||||
};
|
||||
const payload: DeviceInfoPayload = {
|
||||
device_id: deviceId,
|
||||
name: deviceName,
|
||||
kind: 'ohos-phone',
|
||||
caps: LOCAL_DEVICE_CAPS,
|
||||
info: details,
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
export function capDeviceInfo(): CapResult {
|
||||
const lines: string[] = [];
|
||||
lines.push('brand=' + deviceInfo.brand);
|
||||
lines.push('manufacturer=' + deviceInfo.manufacture);
|
||||
lines.push('model=' + deviceInfo.productModel);
|
||||
lines.push('series=' + deviceInfo.productSeries);
|
||||
lines.push('osFullName=' + deviceInfo.osFullName);
|
||||
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
|
||||
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
|
||||
lines.push('abiList=' + deviceInfo.abiList);
|
||||
lines.push('deviceType=' + deviceInfo.deviceType);
|
||||
return okResult(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
@ -280,18 +182,14 @@ export interface ScreensuePayload {
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
let rest: string = rawArgs.trim();
|
||||
const tokens: string[] = rest.split(/\s+/);
|
||||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||||
p.duration = parseInt(tokens[0], 10);
|
||||
rest = tokens.slice(1).join(' ');
|
||||
} else {
|
||||
rest = tokens.join(' ');
|
||||
}
|
||||
p.content = rest;
|
||||
p.content = rest.trim();
|
||||
return p;
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
||||
*
|
||||
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
|
||||
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
|
||||
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
|
||||
*
|
||||
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
|
||||
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
|
||||
*/
|
||||
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
export interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
export interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
export interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
export interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
export interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
// ===== 帧构造 =====
|
||||
|
||||
export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
|
||||
caps: string[], hostname: string,
|
||||
authorized: boolean): string {
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: deviceId,
|
||||
name: name,
|
||||
kind: kind,
|
||||
authorized: authorized,
|
||||
caps: caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
return JSON.stringify(hello);
|
||||
}
|
||||
|
||||
export function bridgeBindFrame(deviceId: string, token: string): string {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: deviceId,
|
||||
token: token,
|
||||
};
|
||||
return JSON.stringify(bind);
|
||||
}
|
||||
|
||||
export function bridgeResultFrame(reqId: string, status: string,
|
||||
output: string, errMsg: string): string {
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
export function bridgeDataStartFrame(reqId: string, kind: string, mime: string,
|
||||
total: number): string {
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: total,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
return JSON.stringify(startMsg);
|
||||
}
|
||||
|
||||
export function bridgeDataEndFrame(reqId: string): string {
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
return JSON.stringify(endMsg);
|
||||
}
|
||||
|
||||
export function bridgeEventFrame(deviceId: string, eventType: string, detail: string): string {
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function bridgeStatusFrame(deviceId: string, status: string): string {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': deviceId,
|
||||
'status': status,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
/** 按 CHUNK_SIZE 切二进制;切片顺序即发送顺序。 */
|
||||
export function bridgeChunkSlices(bytes: Uint8Array): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
out.push(bytes.slice(off, end));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { deviceBridge, CmdReply } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
capScreensee,
|
||||
@ -6,14 +6,12 @@ import {
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
capStatus,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
|
||||
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||
|
||||
let screensueHandler: ScreensueHandler | null = null;
|
||||
@ -27,23 +25,10 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
||||
appContext = ctx;
|
||||
}
|
||||
|
||||
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
|
||||
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
|
||||
function splitCapability(command: string): string[] {
|
||||
let start: number = 0;
|
||||
while (start < command.length && isCommandSpace(command.charAt(start))) {
|
||||
start = start + 1;
|
||||
}
|
||||
let cmd: string = command.substring(start);
|
||||
if (cmd.startsWith('homeagent-')) {
|
||||
cmd = cmd.substring('homeagent-'.length);
|
||||
}
|
||||
let idx: number = -1;
|
||||
for (let i: number = 0; i < cmd.length; i++) {
|
||||
if (isCommandSpace(cmd.charAt(i))) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const cmd: string = command.trim();
|
||||
const idx: number = cmd.indexOf(' ');
|
||||
if (idx < 0) {
|
||||
return [cmd];
|
||||
}
|
||||
@ -51,73 +36,51 @@ function splitCapability(command: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCommandSpace(ch: string): boolean {
|
||||
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
}
|
||||
|
||||
function hasArgs(args: string): boolean {
|
||||
return args.trim().length > 0;
|
||||
}
|
||||
|
||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||
const parts: string[] = splitCapability(command);
|
||||
const name: string = parts[0];
|
||||
const args: string = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
||||
if (name === 'screensee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('screensee 不接受额外参数');
|
||||
}
|
||||
return capScreensee();
|
||||
}
|
||||
if (name === 'screensue') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
const payload: ScreensuePayload = parseScreensue(args);
|
||||
if (payload.content.length === 0) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
if (screensueHandler !== null) {
|
||||
screensueHandler(payload);
|
||||
return okRes('内容已显示');
|
||||
return okRes('shown');
|
||||
}
|
||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||
return errRes('screensue: display layer not ready');
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('clipboardsee 不接受额外参数');
|
||||
}
|
||||
if (appContext === null) {
|
||||
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
|
||||
return errRes('clipboardsee: app context missing');
|
||||
}
|
||||
return capClipboardSee(appContext);
|
||||
}
|
||||
if (name === 'clipboardsue') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('clipboardsue 需要写入文字');
|
||||
if (args.length === 0) {
|
||||
return errRes('clipboardsue: empty text');
|
||||
}
|
||||
return capClipboardsue(args);
|
||||
}
|
||||
if (name === 'speakeruse') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('speakeruse 需要朗读文字');
|
||||
if (args.length === 0) {
|
||||
return errRes('speakeruse: empty text');
|
||||
}
|
||||
return capSpeakerUse(args);
|
||||
}
|
||||
if (name === 'status') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('status 不接受额外参数');
|
||||
}
|
||||
return capStatus(connStore.getDeviceId());
|
||||
if (name === 'deviceinfo' || name === 'status') {
|
||||
return capDeviceInfo();
|
||||
}
|
||||
if (name === 'deviceinfo') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('deviceinfo 不接受额外参数');
|
||||
}
|
||||
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
|
||||
if (name === 'camerasue') {
|
||||
return errRes('camerasue: camera capture not supported on this build');
|
||||
}
|
||||
return errRes('不支持的本机能力:' + name);
|
||||
if (name === 'computeruse') {
|
||||
return errRes('computeruse: not applicable to touch-only device');
|
||||
}
|
||||
return errRes('unsupported homeagent capability: ' + name);
|
||||
}
|
||||
|
||||
function okRes(output: string): CapResult {
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 聊天页的纯格式化/判定逻辑(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:这些函数只吃数据吐字符串/布尔,
|
||||
* 抽出来后气泡、工具卡、渠道头像三个组件可以共用同一份口径。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo } from '../model/Model';
|
||||
|
||||
/**
|
||||
* 由文件名后缀推断 Content-Type。
|
||||
* 后端按 multipart 部件的 Content-Type 判定 image/file,
|
||||
* 给错会让图片被当成普通文件(缩略图就没了)。
|
||||
*/
|
||||
export function mimeOf(name: string, isImage: boolean): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : '';
|
||||
if (ext === 'png') {
|
||||
return 'image/png';
|
||||
}
|
||||
if (ext === 'jpg' || ext === 'jpeg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (ext === 'webp') {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (ext === 'gif') {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (ext === 'bmp') {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if (ext === 'heic' || ext === 'heif') {
|
||||
return 'image/heic';
|
||||
}
|
||||
if (isImage) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (ext === 'txt' || ext === 'log' || ext === 'md') {
|
||||
return 'text/plain';
|
||||
}
|
||||
if (ext === 'json') {
|
||||
return 'application/json';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */
|
||||
export function stringifyField(raw: Object | undefined): string {
|
||||
if (raw === undefined || raw === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw as string;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch (e) {
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的
|
||||
* if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键
|
||||
* 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以
|
||||
* 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后
|
||||
* 永远不会再次求值——气泡里的这些面板就永远不出现。
|
||||
*
|
||||
* 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView
|
||||
* 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。
|
||||
* 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段
|
||||
* 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。
|
||||
*/
|
||||
export function structSig(msg: ChatMessage): string {
|
||||
let s: string = msg.id.toString();
|
||||
const rc: string | undefined = msg.reasoningContent;
|
||||
s += '_r' + (rc !== undefined ? rc.length.toString() : '0');
|
||||
s += '_ro' + (msg.reasoningOpen === true ? '1' : '0');
|
||||
const tcs: ToolCallInfo[] | undefined = msg.toolCalls;
|
||||
if (tcs !== undefined) {
|
||||
s += '_t' + tcs.length.toString();
|
||||
for (let i = 0; i < tcs.length; i++) {
|
||||
const tc: ToolCallInfo = tcs[i];
|
||||
s += '_' + (tc.status ?? '');
|
||||
s += '_' + (tc.open === true ? 'o' : 'c');
|
||||
s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0');
|
||||
s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0');
|
||||
s += '_' + (tc.plugin ?? '');
|
||||
}
|
||||
} else {
|
||||
s += '_t0';
|
||||
}
|
||||
s += '_a' + (msg.attachment !== undefined ? '1' : '0');
|
||||
s += '_src' + (msg.source ?? '');
|
||||
s += '_f' + (msg.isFinal === true ? '1' : '0');
|
||||
s += '_s' + (msg.isStreaming === true ? '1' : '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 折叠时也要能看出思考在增长:显示字数 */
|
||||
export function reasoningLenLabel(msg: ChatMessage): string {
|
||||
const rc: string | undefined = msg.reasoningContent;
|
||||
if (rc === undefined || rc.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return rc.length.toString() + ' 字';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否仍在执行。
|
||||
* 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串,
|
||||
* 用 result 判断会让这类调用永远显示"调用中"。
|
||||
*/
|
||||
export function tcRunning(tc: ToolCallInfo): boolean {
|
||||
const s: string | undefined = tc.status;
|
||||
return s === undefined || s.length === 0 || s === 'running';
|
||||
}
|
||||
|
||||
export function tcError(tc: ToolCallInfo): boolean {
|
||||
return tc.status === 'denied' || tc.status === 'error';
|
||||
}
|
||||
|
||||
/** 工具卡左侧色条(accent 由调用方从 palette 取) */
|
||||
export function tcLeftColor(tc: ToolCallInfo, accent: string): string {
|
||||
if (tcError(tc)) {
|
||||
return '#DB3694';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return accent;
|
||||
}
|
||||
return 'rgba(23, 169, 100, 0.8)';
|
||||
}
|
||||
|
||||
/** 工具卡状态图标颜色 */
|
||||
export function tcIcoColor(tc: ToolCallInfo, accent: string): string {
|
||||
if (tcError(tc)) {
|
||||
return '#DB3694';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return accent;
|
||||
}
|
||||
return 'rgba(23, 169, 100, 0.9)';
|
||||
}
|
||||
|
||||
export function tcStateLabel(tc: ToolCallInfo): string {
|
||||
if (tc.status === 'denied') {
|
||||
return '已拒绝';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return '调用中';
|
||||
}
|
||||
return '完成';
|
||||
}
|
||||
|
||||
export function tcStateColor(tc: ToolCallInfo): string {
|
||||
if (tc.status === 'denied') {
|
||||
return '#FF9EC6';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return '#A3B8FF';
|
||||
}
|
||||
return '#6EE7A8';
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。
|
||||
* 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和
|
||||
* 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。
|
||||
*/
|
||||
export function bubbleMaxWidth(msg: ChatMessage): string {
|
||||
const hasPanels: boolean =
|
||||
(msg.toolCalls !== undefined && msg.toolCalls.length > 0) ||
|
||||
(msg.reasoningContent !== undefined && msg.reasoningContent.length > 0);
|
||||
return hasPanels ? '92%' : '78%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项:
|
||||
* 本机自己发的消息在后端会被写成 webui/<device_id>,那仍然是"我发的",
|
||||
* 不能当成渠道消息挂上别人的头像。
|
||||
*/
|
||||
export function isChannelMsg(source: string, deviceId: string): boolean {
|
||||
if (source.length === 0 || source === 'webui') {
|
||||
return false;
|
||||
}
|
||||
return source !== 'webui/' + deviceId;
|
||||
}
|
||||
|
||||
/** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */
|
||||
export function isSelfMsg(role: string, channel: boolean): boolean {
|
||||
return role === 'user' && !channel;
|
||||
}
|
||||
|
||||
/** 渠道名展示:webui/<id> 只显示 <id>,其余原样。 */
|
||||
export function chanLabel(src: string): string {
|
||||
if (src.startsWith('webui/')) {
|
||||
return src.substring(6);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
/** 渠道首字母(大写),用作头像文字。 */
|
||||
export function chanLetter(src: string): string {
|
||||
const label: string = chanLabel(src);
|
||||
if (label.length === 0) {
|
||||
return '?';
|
||||
}
|
||||
return label.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/** 由渠道名散列出稳定色,避免每次渲染换色。 */
|
||||
export function chanColor(src: string): string {
|
||||
const label: string = chanLabel(src);
|
||||
let h: number = 0;
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
h = (h * 31 + label.charCodeAt(i)) % 360;
|
||||
}
|
||||
return 'hsl(' + h.toString() + ', 52%, 46%)';
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/**
|
||||
* /chat/history 响应解析(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径,
|
||||
* 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { stringifyField } from './ChatFormat';
|
||||
|
||||
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
|
||||
export interface ParsedHistory {
|
||||
msgs: ChatMessage[];
|
||||
/** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
|
||||
offset: number;
|
||||
/** 服务端是否还有更早的历史 */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
|
||||
export function parseHistoryPayload(
|
||||
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
|
||||
const rawList: Object | undefined = obj['messages'] as Object | undefined;
|
||||
if (rawList === undefined || rawList === null) {
|
||||
return { msgs: [], offset: 0, hasMore: false };
|
||||
}
|
||||
const arr: Object[] = rawList as Object[];
|
||||
const msgs: ChatMessage[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const role: string = item['role'] as string ?? '';
|
||||
const content: string = item['content'] as string ?? '';
|
||||
const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
|
||||
// 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
|
||||
if (role.length === 0 || (content.length === 0 && att === undefined)) {
|
||||
continue;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: alloc(),
|
||||
role: role,
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
if (att !== undefined) {
|
||||
msg.attachment = att;
|
||||
}
|
||||
// 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls,
|
||||
// 这里必须还原,否则刷新后思考与工具卡就凭空消失。
|
||||
const rc: string = item['reasoning_content'] as string ?? '';
|
||||
if (rc.length > 0) {
|
||||
msg.reasoningContent = rc;
|
||||
}
|
||||
const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']);
|
||||
if (tcs !== undefined) {
|
||||
msg.toolCalls = tcs;
|
||||
}
|
||||
// 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息
|
||||
const src: string = item['source'] as string ?? '';
|
||||
if (src.length > 0) {
|
||||
msg.source = src;
|
||||
}
|
||||
msgs.push(msg);
|
||||
}
|
||||
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
|
||||
const hasMore: boolean = obj['has_more'] === true;
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore };
|
||||
}
|
||||
|
||||
/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
|
||||
export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const tcs: ToolCallInfo[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
|
||||
if (name.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const tc: ToolCallInfo = {
|
||||
name: name,
|
||||
args: stringifyField(item['args']),
|
||||
result: stringifyField(item['result']),
|
||||
status: item['status'] as string ?? undefined,
|
||||
plugin: item['plugin'] as string ?? undefined,
|
||||
};
|
||||
tcs.push(tc);
|
||||
}
|
||||
return tcs.length > 0 ? tcs : undefined;
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 发送 / 中断(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:POST /chat 与 POST /chat/file 的请求体、
|
||||
* 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ChatAttachment } from '../model/Model';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { userMessage, isTimeout } from './UserError';
|
||||
import { chatStore } from './ChatStore';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
|
||||
interface SendChatBody {
|
||||
message: string;
|
||||
client_msg_id: string;
|
||||
/** 非空时后端编码 source = "webui/<device_id>",agent 可见来源设备 */
|
||||
device_id?: string;
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本发送:POST /chat。
|
||||
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
|
||||
*/
|
||||
export async function sendChatText(text: string): Promise<void> {
|
||||
const trimmed: string = text.trim();
|
||||
if (chatStore.isLoading()) {
|
||||
return;
|
||||
}
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 重置 SSE 标记
|
||||
chatStore.setSseActive(false);
|
||||
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed };
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const bodyObj: SendChatBody = {
|
||||
message: trimmed,
|
||||
client_msg_id: Date.now().toString(36),
|
||||
// 必须带设备身份:后端没有 device_id 就把来源编码成 webui,
|
||||
// agent 会以为消息来自网页端。device_id 非空时后端编码
|
||||
// source = "webui/<device_id>" 并注入设备上下文。
|
||||
device_id: connStore.ensureDeviceId(),
|
||||
device_name: connStore.getDeviceName(),
|
||||
};
|
||||
|
||||
// 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染);
|
||||
// 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
|
||||
try {
|
||||
const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
|
||||
|
||||
// SSE 已经处理了响应,跳过 POST 消息创建
|
||||
if (chatStore.sseActive()) {
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed: Record<string, string> = JSON.parse(resp.body) as Record<string, string>;
|
||||
const respText: string = parsed['response'] ?? '(无响应)';
|
||||
const reasoning: string = parsed['reasoning_content'] ?? '';
|
||||
const last: ChatMessage | null = chatStore.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && !last.isFinal) {
|
||||
last.content = respText;
|
||||
last.isFinal = true;
|
||||
last.isStreaming = false;
|
||||
if (reasoning.length > 0 && last.reasoningContent === undefined) {
|
||||
last.reasoningContent = reasoning;
|
||||
}
|
||||
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
|
||||
// 已有最终消息,合并(不应发生,但防御性处理)
|
||||
if (respText.length > last.content.length) {
|
||||
last.content = respText;
|
||||
}
|
||||
} else {
|
||||
const msg: ChatMessage = {
|
||||
id: chatStore.allocId(),
|
||||
role: 'assistant',
|
||||
content: respText,
|
||||
isFinal: true,
|
||||
};
|
||||
if (reasoning.length > 0) {
|
||||
msg.reasoningContent = reasoning;
|
||||
}
|
||||
chatStore.pushNew(msg);
|
||||
}
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
} catch (e) {
|
||||
// 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
|
||||
// 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把
|
||||
// "Failed to connect to the server."、内网地址直接摆到聊天流里)。
|
||||
if (isTimeout(e)) {
|
||||
chatStore.setStage('请求已发送,等待回复...');
|
||||
} else {
|
||||
chatStore.setStage(userMessage('chat.send', e));
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。
|
||||
* 后端收下后自身会把用户消息与附件写进会话并触发 agent,
|
||||
* 回复照常从 SSE 过来,所以这里不再走 /chat。
|
||||
*/
|
||||
export async function sendChatFile(text: string, path: string, name: string,
|
||||
size: number, isImage: boolean, mime: string): Promise<void> {
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
const att: ChatAttachment = {
|
||||
type: isImage ? 'image' : 'file',
|
||||
// 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
|
||||
url: 'file://' + path,
|
||||
size: size,
|
||||
name: name,
|
||||
};
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text };
|
||||
userMsg.attachment = att;
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('正在上传附件...');
|
||||
chatStore.setSseActive(false);
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const parts: http.MultiFormData[] = [
|
||||
{ name: 'file', contentType: mime, remoteFileName: name, filePath: path },
|
||||
{ name: 'message', contentType: 'text/plain', data: text },
|
||||
{ name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
|
||||
{ name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
|
||||
{ name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
|
||||
];
|
||||
|
||||
try {
|
||||
const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
|
||||
if (uploaded !== undefined) {
|
||||
userMsg.attachment = uploaded;
|
||||
}
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.upload', e));
|
||||
chatStore.setLoading(false);
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
|
||||
export async function interruptChat(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/chat/interrupt', null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
/**
|
||||
* SSE 事件 → 聊天流状态的翻译层(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出。这里只认「事件帧」与「往状态里写什么」,
|
||||
* 具体状态由 ChatStreamSink 提供 —— 这样本文件不必 import ChatStore,
|
||||
* 两边不会形成 ArkTS 里被拒绝的模块循环依赖。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { attachmentFromChannelOutput } from './AttachmentMeta';
|
||||
import { SseEvent } from './SseClient';
|
||||
import { stringifyField } from './ChatFormat';
|
||||
import { stageTrail } from './StageTrail';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */
|
||||
export interface ChatStreamSink {
|
||||
allocId(): number;
|
||||
lastMessage(): ChatMessage | null;
|
||||
/** 取/建"未定稿的助手消息" */
|
||||
ensureAssistant(): ChatMessage;
|
||||
/** 取/建同名未完成工具卡 */
|
||||
ensureToolCall(name: string): ToolCallInfo;
|
||||
/** 追加一条新消息并播入场动画 */
|
||||
pushNew(msg: ChatMessage): void;
|
||||
setLoading(v: boolean): void;
|
||||
setStage(s: string): void;
|
||||
setSseActive(v: boolean): void;
|
||||
/** 防抖刷新(合并高频 delta) */
|
||||
refresh(): void;
|
||||
/** 请求滚到底 */
|
||||
requestScroll(): void;
|
||||
/** sync_required:补拉历史 */
|
||||
reloadHistory(): void;
|
||||
}
|
||||
|
||||
export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void {
|
||||
try {
|
||||
// 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp}
|
||||
// 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。
|
||||
const frame: Record<string, Object> = JSON.parse(ev.data) as Record<string, Object>;
|
||||
const inner: Object | undefined = frame['payload'];
|
||||
const payload: Record<string, Object> =
|
||||
inner !== undefined && inner !== null ? inner as Record<string, Object> : frame;
|
||||
const frameType: string = frame['type'] as string ?? '';
|
||||
const type: string = ev.event.length > 0 ? ev.event : frameType;
|
||||
// 诊断只记事件类型(内容可能含隐私,不落盘)
|
||||
hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type);
|
||||
|
||||
if (type === 'agent_output') {
|
||||
const content: string = payload['content'] as string ?? '';
|
||||
// channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层,
|
||||
// 它是一条独立的附件消息,不能合并进上一条文本气泡。
|
||||
const kind: string = payload['kind'] as string ?? '';
|
||||
if (kind === 'channel_output') {
|
||||
const att: ChatAttachment | undefined = attachmentFromChannelOutput(
|
||||
payload['output_type'] as string ?? '',
|
||||
payload['url'] as string ?? '',
|
||||
payload['size'] as number ?? 0);
|
||||
if (att !== undefined) {
|
||||
const amsg: ChatMessage = {
|
||||
id: sink.allocId(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
isFinal: true,
|
||||
source: 'channel',
|
||||
attachment: att,
|
||||
};
|
||||
sink.pushNew(amsg);
|
||||
sink.setLoading(false);
|
||||
sink.setStage('');
|
||||
sink.setSseActive(false);
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const last: ChatMessage | null = sink.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && !last.isFinal) {
|
||||
last.content = content;
|
||||
last.isFinal = true;
|
||||
last.isStreaming = false;
|
||||
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
|
||||
// POST 已经创建了最终消息,仅合并内容(如果有增量)
|
||||
if (content.length > last.content.length) {
|
||||
last.content = content;
|
||||
}
|
||||
} else {
|
||||
const msg: ChatMessage = {
|
||||
id: sink.allocId(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
sink.pushNew(msg);
|
||||
}
|
||||
sink.setLoading(false);
|
||||
sink.setStage('');
|
||||
sink.setSseActive(false);
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
} else if (type === 'reasoning') {
|
||||
const rc: string = payload['content'] as string ?? '';
|
||||
if (rc.length > 0) {
|
||||
sink.setStage('AI 思考中...');
|
||||
// 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
|
||||
// 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
last.reasoningContent = rc;
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'sync_required') {
|
||||
// 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件)
|
||||
sink.reloadHistory();
|
||||
} else if (type === 'agent_error') {
|
||||
// 后端错误一律转人话,技术细节不上 UI
|
||||
sink.setLoading(false);
|
||||
sink.setStage('本轮处理失败,请重试');
|
||||
sink.refresh();
|
||||
} else if (type === 'content_delta') {
|
||||
const delta: string = payload['content'] as string ?? '';
|
||||
if (delta.length > 0) {
|
||||
sink.setSseActive(true);
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
last.content += delta;
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'reasoning_delta') {
|
||||
const delta: string = payload['content'] as string ?? '';
|
||||
if (delta.length > 0) {
|
||||
sink.setSseActive(true);
|
||||
sink.setStage('AI 思考中...');
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
if (last.reasoningContent === undefined) {
|
||||
last.reasoningContent = '';
|
||||
}
|
||||
last.reasoningContent += delta;
|
||||
sink.refresh();
|
||||
}
|
||||
} else if (type === 'tool_call') {
|
||||
const toolName: string = payload['tool'] as string ?? '';
|
||||
const toolStatus: string = payload['status'] as string ?? '';
|
||||
const toolPlugin: string = payload['plugin'] as string ?? '';
|
||||
if (toolName.length > 0) {
|
||||
sink.setStage('工具调用: ' + toolName);
|
||||
const target: ToolCallInfo = sink.ensureToolCall(toolName);
|
||||
if (toolPlugin.length > 0) {
|
||||
target.plugin = toolPlugin;
|
||||
}
|
||||
const argsText: string = stringifyField(payload['args']);
|
||||
if (argsText.length > 0) {
|
||||
target.args = argsText;
|
||||
}
|
||||
if (toolStatus.length > 0) {
|
||||
// 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted),
|
||||
// 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。
|
||||
target.status = toolStatus;
|
||||
target.result = stringifyField(payload['result']);
|
||||
} else {
|
||||
target.status = 'running';
|
||||
}
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'stage') {
|
||||
const phase: string = payload['phase'] as string ?? '';
|
||||
const channel: string = payload['channel'] as string ?? '';
|
||||
const stageTool: string = payload['tool'] as string ?? '';
|
||||
if (channel !== '_consolidation_') {
|
||||
// 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。
|
||||
// 两者是独立消费者,轨迹不依赖任何聊天状态。
|
||||
stageTrail.onStage(phase, stageTool);
|
||||
if (phase === 'pre_action') {
|
||||
sink.setStage('AI 思考中...');
|
||||
} else if (phase === 'before_toolcall') {
|
||||
sink.setStage('工具调用: ' + stageTool);
|
||||
// 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态
|
||||
// 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。
|
||||
if (stageTool.length > 0) {
|
||||
const tc: ToolCallInfo = sink.ensureToolCall(stageTool);
|
||||
if (tc.status === undefined) {
|
||||
tc.status = 'running';
|
||||
}
|
||||
}
|
||||
} else if (phase === 'before_output') {
|
||||
sink.setStage('生成回复中...');
|
||||
}
|
||||
sink.refresh();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
@ -1,389 +0,0 @@
|
||||
/**
|
||||
* 聊天流状态源(单例)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
|
||||
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
|
||||
*
|
||||
* 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。
|
||||
* 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播,
|
||||
* 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。
|
||||
*
|
||||
* 订阅组件的接法(见 components/ChatStream.ets):
|
||||
* @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0;
|
||||
* onRev(): void { this.messages = chatStore.messages(); }
|
||||
* ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前
|
||||
* (this.messages = this.messages.slice())的渲染语义完全一致。
|
||||
*
|
||||
* SSE 事件的翻译在 common/ChatSse.ets:本类实现它的 ChatStreamSink 接口,
|
||||
* 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { SseClient, SseEvent } from './SseClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { CHAT_PAGE_SIZE } from './Constants';
|
||||
import { ParsedHistory, parseHistoryPayload } from './ChatHistory';
|
||||
import { applyChatSse, ChatStreamSink } from './ChatSse';
|
||||
|
||||
// ===== AppStorage 键:页面/聊天流/输入区共用 =====
|
||||
export const K_CHAT_REV: string = 'chatRev';
|
||||
export const K_CHAT_SCROLL_REV: string = 'chatScrollRev';
|
||||
export const K_CHAT_LOADING: string = 'chatBusy';
|
||||
export const K_CHAT_STAGE: string = 'chatStageText';
|
||||
export const K_CHAT_CONNECTED: string = 'chatSseUp';
|
||||
|
||||
const SSE_RECONNECT_MS: number = 5000;
|
||||
|
||||
class ChatStore implements ChatStreamSink {
|
||||
private msgs: ChatMessage[] = [];
|
||||
private nextId: number = 1;
|
||||
private refreshTimer: number = -1;
|
||||
private reconnectTimer: number = -1;
|
||||
/** 分页历史:当前已加载消息在服务端全量中的起始下标 */
|
||||
private offset: number = 0;
|
||||
/** 是否还有更早历史可向上加载 */
|
||||
private hasEarlier: boolean = false;
|
||||
private loadingOlder: boolean = false;
|
||||
/** 正在为新消息播入场动画的 id */
|
||||
private newIds: number[] = [];
|
||||
// SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
|
||||
private sseActiveForTurn: boolean = false;
|
||||
private sse: SseClient = new SseClient();
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_LOADING, false);
|
||||
AppStorage.setOrCreate<string>(K_CHAT_STAGE, '');
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_CONNECTED, false);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_REV, 0);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_SCROLL_REV, 0);
|
||||
}
|
||||
|
||||
// ===================== 读取 =====================
|
||||
|
||||
messages(): ChatMessage[] {
|
||||
return this.msgs;
|
||||
}
|
||||
|
||||
findMessage(id: number): ChatMessage | undefined {
|
||||
for (let i = 0; i < this.msgs.length; i++) {
|
||||
if (this.msgs[i].id === id) {
|
||||
return this.msgs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
lastMessage(): ChatMessage | null {
|
||||
if (this.msgs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.msgs[this.msgs.length - 1];
|
||||
}
|
||||
|
||||
isFresh(id: number): boolean {
|
||||
return this.newIds.indexOf(id) >= 0;
|
||||
}
|
||||
|
||||
hasMore(): boolean {
|
||||
return this.hasEarlier;
|
||||
}
|
||||
|
||||
isFetchingOlder(): boolean {
|
||||
return this.loadingOlder;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return AppStorage.get<boolean>(K_CHAT_LOADING) ?? false;
|
||||
}
|
||||
|
||||
setLoading(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_LOADING, v);
|
||||
}
|
||||
|
||||
setStage(s: string): void {
|
||||
AppStorage.set<string>(K_CHAT_STAGE, s);
|
||||
}
|
||||
|
||||
setConnected(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_CONNECTED, v);
|
||||
}
|
||||
|
||||
sseActive(): boolean {
|
||||
return this.sseActiveForTurn;
|
||||
}
|
||||
|
||||
setSseActive(v: boolean): void {
|
||||
this.sseActiveForTurn = v;
|
||||
}
|
||||
|
||||
/** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
|
||||
latestAttachment(): ChatAttachment | undefined {
|
||||
for (let i = this.msgs.length - 1; i >= 0; i--) {
|
||||
const a: ChatAttachment | undefined = this.msgs[i].attachment;
|
||||
if (a !== undefined) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ===================== 列表变更 =====================
|
||||
|
||||
allocId(): number {
|
||||
return this.nextId++;
|
||||
}
|
||||
|
||||
/** 追加一条消息并播入场动画 */
|
||||
pushNew(msg: ChatMessage): void {
|
||||
this.msgs.push(msg);
|
||||
this.markNew(msg.id);
|
||||
}
|
||||
|
||||
/** 标记新消息,触发入场动画 */
|
||||
markNew(msgId: number): void {
|
||||
const arr: number[] = this.newIds.slice();
|
||||
arr.push(msgId);
|
||||
this.newIds = arr;
|
||||
// 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的
|
||||
// refresh()(50ms 防抖后换新数组引用)那一次一起触发。
|
||||
setTimeout(() => {
|
||||
const idx: number = this.newIds.indexOf(msgId);
|
||||
if (idx >= 0) {
|
||||
const updated: number[] = this.newIds.slice();
|
||||
updated.splice(idx, 1);
|
||||
this.newIds = updated;
|
||||
this.forceRefresh();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
|
||||
* 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
|
||||
*/
|
||||
ensureToolCall(name: string): ToolCallInfo {
|
||||
const last: ChatMessage = this.ensureAssistant();
|
||||
if (last.toolCalls === undefined) {
|
||||
last.toolCalls = [];
|
||||
}
|
||||
for (let i = 0; i < last.toolCalls.length; i++) {
|
||||
const t: ToolCallInfo = last.toolCalls[i];
|
||||
// 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
|
||||
// 已完成的那张不能被后来的调用覆盖。
|
||||
const st: string | undefined = t.status;
|
||||
if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
const created: ToolCallInfo = { name: name, args: '' };
|
||||
last.toolCalls.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证存在一条"未定稿的助手消息",返回它。
|
||||
* 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
|
||||
* 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。
|
||||
*/
|
||||
ensureAssistant(): ChatMessage {
|
||||
const last: ChatMessage | null = this.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && last.isFinal !== true) {
|
||||
return last;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: this.allocId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
isFinal: false,
|
||||
};
|
||||
this.pushNew(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 流式/高频变更的通知信号:让 ChatStream 滚到底 */
|
||||
requestScroll(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_SCROLL_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_SCROLL_REV, cur + 1);
|
||||
}
|
||||
|
||||
/** 防抖刷新:合并高频 SSE delta,最多 ~20fps */
|
||||
refresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = -1;
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/** 强制立即刷新(用于状态切换等需要即时响应的场景) */
|
||||
forceRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_REV, cur + 1);
|
||||
}
|
||||
|
||||
cancelRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 折叠开关 =====================
|
||||
|
||||
reasoningOpen(msgId: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
return m !== undefined && m.reasoningOpen === true;
|
||||
}
|
||||
|
||||
setReasoningOpen(msgId: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined) {
|
||||
m.reasoningOpen = open;
|
||||
}
|
||||
}
|
||||
|
||||
toolOpen(msgId: number, index: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) {
|
||||
return false;
|
||||
}
|
||||
return m.toolCalls[index].open === true;
|
||||
}
|
||||
|
||||
setToolOpen(msgId: number, index: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) {
|
||||
m.toolCalls[index].open = open;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 历史 =====================
|
||||
|
||||
private parseHistory(obj: Record<string, Object>): ParsedHistory {
|
||||
return parseHistoryPayload(obj, () => this.allocId());
|
||||
}
|
||||
|
||||
async loadHistory(): Promise<void> {
|
||||
try {
|
||||
// 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
|
||||
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.msgs = parsed.msgs;
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.forceRefresh();
|
||||
this.requestScroll();
|
||||
} catch (e) {
|
||||
// ignore history load failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
|
||||
* 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
|
||||
*/
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.loadingOlder || !this.hasEarlier) {
|
||||
return;
|
||||
}
|
||||
this.loadingOlder = true;
|
||||
try {
|
||||
const before: number = this.offset;
|
||||
if (before <= 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
const resp = await apiClient.getWithTimeout(
|
||||
'/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
// 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号
|
||||
this.msgs = parsed.msgs.concat(this.msgs);
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
// 失败保留 hasEarlier,允许下次滚动重试
|
||||
} finally {
|
||||
this.loadingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** sync_required:断线重连后服务端要求补拉历史 */
|
||||
reloadHistory(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
// ===================== SSE 连接 =====================
|
||||
|
||||
connect(): void {
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
this.sse.close();
|
||||
this.sse.connect(cur, '/chat/events',
|
||||
(ev: SseEvent) => {
|
||||
applyChatSse(ev, this);
|
||||
},
|
||||
() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
() => {
|
||||
this.setConnected(true);
|
||||
}).catch(() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = -1;
|
||||
this.connect();
|
||||
}, SSE_RECONNECT_MS);
|
||||
}
|
||||
|
||||
cancelReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面消失:断线、停表,避免后台空转 */
|
||||
disconnect(): void {
|
||||
this.cancelReconnect();
|
||||
this.sse.close();
|
||||
this.cancelRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
export const chatStore: ChatStore = new ChatStore();
|
||||
@ -1,8 +1,75 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
||||
bridgeChunkSlices } from './BridgeProtocol';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CmdMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
command: string;
|
||||
cmd_type: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
export class DeviceBridgeClient {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
@ -14,15 +81,14 @@ export class DeviceBridgeClient {
|
||||
private caps: string[] = [];
|
||||
private hostname: string = 'ohos';
|
||||
private connected: boolean = false;
|
||||
private bound: boolean = false;
|
||||
private everConnected: boolean = false;
|
||||
private manualClose: boolean = false;
|
||||
private reconnectTimer: number = -1;
|
||||
private connectionGeneration: number = 0;
|
||||
private cmdHandler: BridgeCmdHandler | null = null;
|
||||
private onStateChange: ((open: boolean) => void) | null = null;
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.bound;
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
@ -51,40 +117,37 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
this.ws.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore stale socket cleanup failure
|
||||
// ignore
|
||||
}
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
const generation: number = this.connectionGeneration;
|
||||
const socket: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
this.ws = socket;
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.bindWsEvents(socket, authorized, generation);
|
||||
this.ws = webSocket.createWebSocket();
|
||||
this.bindWsEvents(authorized);
|
||||
// 鉴权必须走请求头,不能拼 ?token= :
|
||||
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
||||
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
||||
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
||||
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
|
||||
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token;
|
||||
// 如果我们再带 ?token=<webui apiKey>,remotedevice 的 ServeWS
|
||||
// 会拿它和 ws_token 比对并 401。留空反而放行。
|
||||
const opts: webSocket.WebSocketRequestOptions = {
|
||||
header: this.authHeader(),
|
||||
};
|
||||
try {
|
||||
await socket.connect(this.url, opts);
|
||||
await this.ws.connect(this.url, opts);
|
||||
} catch (e) {
|
||||
if (generation === this.connectionGeneration && !this.manualClose) {
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
|
||||
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
||||
private authHeader(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
if (this.token.length > 0) {
|
||||
@ -94,48 +157,38 @@ export class DeviceBridgeClient {
|
||||
return h;
|
||||
}
|
||||
|
||||
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
|
||||
socket.on('open', (err: Error, value: Object) => {
|
||||
if (generation !== this.connectionGeneration || this.manualClose) {
|
||||
socket.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
return;
|
||||
}
|
||||
private bindWsEvents(authorized: boolean): void {
|
||||
this.ws.on('open', (err: Error, value: Object) => {
|
||||
this.connected = true;
|
||||
this.bound = false;
|
||||
this.everConnected = true;
|
||||
this.cancelReconnect();
|
||||
this.sendHello(authorized);
|
||||
this.sendBind();
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(true);
|
||||
}
|
||||
});
|
||||
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (generation === this.connectionGeneration && typeof value === 'string') {
|
||||
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (typeof value === 'string') {
|
||||
this.handleTextFrame(value);
|
||||
}
|
||||
});
|
||||
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.handleSocketEnd(generation);
|
||||
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
socket.on('error', (err: Error) => {
|
||||
this.handleSocketEnd(generation);
|
||||
this.ws.on('error', (err: Error) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private handleSocketEnd(generation: number): void {
|
||||
if (generation !== this.connectionGeneration) {
|
||||
return;
|
||||
}
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private notifyState(open: boolean): void {
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(open);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||
return;
|
||||
@ -158,20 +211,18 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
|
||||
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
||||
updateAuthorized(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
if (this.connected && this.bound) {
|
||||
if (this.connected) {
|
||||
this.sendHello(authorized);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualClose = true;
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
this.cancelReconnect();
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
@ -183,17 +234,40 @@ export class DeviceBridgeClient {
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.notifyState(false);
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
||||
this.hostname, authorized));
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: this.hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.0',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: this.deviceId,
|
||||
name: this.name,
|
||||
kind: this.kind,
|
||||
authorized: authorized,
|
||||
caps: this.caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
this.send(JSON.stringify(hello));
|
||||
}
|
||||
|
||||
private sendBind(): void {
|
||||
this.send(bridgeBindFrame(this.deviceId, this.token));
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: this.deviceId,
|
||||
token: this.token,
|
||||
};
|
||||
this.send(JSON.stringify(bind));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
@ -206,64 +280,67 @@ export class DeviceBridgeClient {
|
||||
return;
|
||||
}
|
||||
const op: string = obj['op'] as string ?? '';
|
||||
if (op === 'bind_ack') {
|
||||
const accepted: boolean = obj['ok'] === true;
|
||||
if (accepted && this.connected && !this.manualClose) {
|
||||
this.bound = true;
|
||||
this.cancelReconnect();
|
||||
this.notifyState(true);
|
||||
} else {
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
try {
|
||||
this.ws.close().catch(() => {
|
||||
// ignore bind rejection close failure
|
||||
});
|
||||
} catch (e) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
if (op === 'cmd') {
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
||||
if (this.onAck !== null) {
|
||||
this.onAck(op);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (op !== 'cmd' || !this.bound) {
|
||||
return;
|
||||
}
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
}
|
||||
|
||||
onAck: ((op: string) => void) | null = null;
|
||||
|
||||
private dispatchCommand(reqId: string, command: string): void {
|
||||
if (this.cmdHandler === null) {
|
||||
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
|
||||
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
||||
return;
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
this.sendResult(reqId, 'error', '', msg);
|
||||
});
|
||||
}
|
||||
|
||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||
this.send(bridgeResultFrame(reqId, status, output, errMsg));
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
this.send(JSON.stringify(result));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||
|
||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: bytes.byteLength,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
this.send(JSON.stringify(startMsg));
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
const view: Uint8Array = bytes.slice(off, end);
|
||||
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
|
||||
try {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
@ -272,15 +349,32 @@ export class DeviceBridgeClient {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.send(bridgeDataEndFrame(reqId));
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
this.send(JSON.stringify(endMsg));
|
||||
}
|
||||
|
||||
sendEvent(eventType: string, detail: string): void {
|
||||
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': this.deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
sendStatus(status: string): void {
|
||||
this.send(bridgeStatusFrame(this.deviceId, status));
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': this.deviceId,
|
||||
'status': status,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
@ -294,3 +388,7 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||
|
||||
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
|
||||
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
|
||||
|
||||
let bridgeStarting: boolean = false;
|
||||
let foregroundActive: boolean = false;
|
||||
let rootUIReady: boolean = false;
|
||||
let bridgeGeneration: number = 0;
|
||||
let stateTrackingReady: boolean = false;
|
||||
|
||||
function ensureBridgeStateTracking(): void {
|
||||
if (stateTrackingReady) {
|
||||
return;
|
||||
}
|
||||
stateTrackingReady = true;
|
||||
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
AppStorage.set<boolean>('deviceBridgeConnected', open);
|
||||
});
|
||||
}
|
||||
|
||||
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||
export function deviceGatewayUrl(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||
*/
|
||||
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
foregroundActive = true;
|
||||
setBridgeAppContext(context);
|
||||
installCmdRouter();
|
||||
ensureBridgeStateTracking();
|
||||
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
|
||||
return;
|
||||
}
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
|
||||
return;
|
||||
}
|
||||
bridgeStarting = true;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
const generation: number = bridgeGeneration;
|
||||
const deviceId: string = connStore.ensureDeviceId();
|
||||
try {
|
||||
await deviceBridge.connect(
|
||||
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
|
||||
}
|
||||
if (generation === bridgeGeneration) {
|
||||
bridgeStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
|
||||
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
|
||||
rootUIReady = true;
|
||||
startForegroundBridge(context);
|
||||
}
|
||||
|
||||
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
|
||||
export function stopForegroundBridge(): void {
|
||||
foregroundActive = false;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
shutdownSpeakerUse();
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
|
||||
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
|
||||
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
deviceBridge.disconnect();
|
||||
await startForegroundBridge(context);
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 设备页的纯逻辑:本机 device_id 兜底与在线设备列表解析。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(非 UI,可被其它页面/桥复用)。
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { connStore } from './ConnStore';
|
||||
|
||||
// ===== 二级页面路由 id(页面与一级入口列表共用)=====
|
||||
export const SUB_NONE: string = '';
|
||||
export const SUB_LOCAL: string = 'local';
|
||||
export const SUB_CAPS: string = 'caps';
|
||||
export const SUB_GATEWAY: string = 'gateway';
|
||||
export const SUB_LIST: string = 'list';
|
||||
|
||||
/**
|
||||
* 本机 device_id:桥里已有就用桥的,其次读持久化,都没有则生成一个并落盘。
|
||||
* 生成后必须持久化,否则每次冷启动换 id,网关侧会累积成一堆幽灵设备。
|
||||
*
|
||||
* 判定顺序与原 DevicePage.aboutToAppear 一致:桥的 id 优先于持久化的 id。
|
||||
*/
|
||||
export function resolveDeviceId(bridgeId: string): string {
|
||||
let id: string = bridgeId;
|
||||
if (id.length === 0) {
|
||||
id = connStore.getDeviceId();
|
||||
}
|
||||
if (id.length === 0) {
|
||||
id = 'ohos-' + Date.now().toString(36);
|
||||
try {
|
||||
connStore.saveDeviceId(id);
|
||||
} catch (e) {
|
||||
// ignore persist failure
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 /device/online 响应体。
|
||||
*
|
||||
* apiClient 已自动前置 /api/v1,调用方只写其后的部分
|
||||
* (否则会拼成 /api/v1/api/v1/device/online 并 404)。
|
||||
*/
|
||||
export function parseOnlineDevices(parsed: Record<string, Object>): DeviceInfo[] {
|
||||
const devs: Object = parsed['devices'];
|
||||
const list: DeviceInfo[] = [];
|
||||
if (devs === undefined || devs === null) {
|
||||
return list;
|
||||
}
|
||||
const arr: Object[] = devs as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const capsArr: Object = d['caps'];
|
||||
const caps: string[] = [];
|
||||
if (capsArr !== undefined && capsArr !== null) {
|
||||
const cArr: Object[] = capsArr as Object[];
|
||||
for (let j = 0; j < cArr.length; j++) {
|
||||
caps.push(cArr[j] as string);
|
||||
}
|
||||
}
|
||||
const info: DeviceInfo = {
|
||||
deviceId: d['device_id'] as string ?? '',
|
||||
name: d['name'] as string ?? '',
|
||||
kind: d['kind'] as string ?? '',
|
||||
online: true,
|
||||
authorized: d['authorized'] as boolean ?? false,
|
||||
caps: caps,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Markdown 解析器(无 UI 依赖)。
|
||||
*
|
||||
* 从 components/StaticMarkdown.ets 抽出:解析与渲染分家后,
|
||||
* 解析规则可以单独被复用/测试,渲染组件也回到可读长度。
|
||||
*
|
||||
* 覆盖:标题、段落、代码围栏、无序/有序列表、引用块、分隔线、表格,
|
||||
* 以及行内的粗体/斜体/行内代码/链接。
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MdBlock {
|
||||
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
|
||||
level?: number;
|
||||
items?: string[];
|
||||
text?: string;
|
||||
lang?: string;
|
||||
codeLines?: string[];
|
||||
headers?: string[];
|
||||
rows?: string[][];
|
||||
}
|
||||
|
||||
export interface MdSpan {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
link?: boolean;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
// ── Inline parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseInline(text: string): MdSpan[] {
|
||||
const spans: MdSpan[] = [];
|
||||
let i: number = 0;
|
||||
while (i < text.length) {
|
||||
// Inline code (backtick)
|
||||
if (text[i] === '`') {
|
||||
const end: number = text.indexOf('`', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), code: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **text**
|
||||
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
|
||||
const end: number = text.indexOf('**', i + 2);
|
||||
if (end > i + 1) {
|
||||
spans.push({ text: text.substring(i + 2, end), bold: true });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *text* (single asterisk)
|
||||
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
|
||||
const end: number = text.indexOf('*', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), italic: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Link: [text](url)
|
||||
if (text[i] === '[') {
|
||||
const cb: number = text.indexOf(']', i + 1);
|
||||
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
|
||||
const cp: number = text.indexOf(')', cb + 2);
|
||||
if (cp > cb + 1) {
|
||||
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
|
||||
i = cp + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plain run
|
||||
let j: number = i + 1;
|
||||
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
|
||||
j++;
|
||||
}
|
||||
spans.push({ text: text.substring(i, j) });
|
||||
i = j;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ── Block parser helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHr(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const ch: string = line[0];
|
||||
if (ch !== '-' && ch !== '*' && ch !== '_') {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
if (line[k] !== ch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOlStart(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let k: number = 0;
|
||||
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
|
||||
k++;
|
||||
}
|
||||
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
|
||||
}
|
||||
|
||||
function isTableSep(line: string): boolean {
|
||||
if (!line.includes('-')) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
const c: string = line[k];
|
||||
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 表格行 → 单元格数组(去掉首尾空串产生的空单元格)。 */
|
||||
function parseTableRow(row: string): string[] {
|
||||
const cells: string[] = [];
|
||||
const parts: string[] = row.split('|');
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
const c: string = parts[p].trim();
|
||||
if (c.length > 0) {
|
||||
cells.push(c);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// ── Block parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseBlocks(content: string): MdBlock[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = content.split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let i: number = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line: string = lines[i];
|
||||
|
||||
// Empty line
|
||||
if (line.trim().length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code fence
|
||||
if (line.startsWith('```')) {
|
||||
const langEnd: number = line.indexOf('`', 3);
|
||||
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) {
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (line.startsWith('#')) {
|
||||
let level: number = 0;
|
||||
while (level < line.length && line[level] === '#') {
|
||||
level++;
|
||||
}
|
||||
if (level <= 6 && level < line.length && line[level] === ' ') {
|
||||
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (isHr(line.trim())) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
|
||||
items.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'list', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (isOlStart(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && isOlStart(lines[i])) {
|
||||
const dotIdx: number = lines[i].indexOf('. ');
|
||||
items.push(lines[i].substring(dotIdx + 2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'ol', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const qLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
qLines.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
|
||||
const tLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||
tLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (tLines.length >= 2) {
|
||||
const headers: string[] = parseTableRow(tLines[0]);
|
||||
const rows: string[][] = [];
|
||||
for (let k = 1; k < tLines.length; k++) {
|
||||
if (!isTableSep(tLines[k].trim())) {
|
||||
rows.push(parseTableRow(tLines[k]));
|
||||
}
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
blocks.push({ type: 'table', headers: headers, rows: rows });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect consecutive non-special lines
|
||||
{
|
||||
const paraLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const ln: string = lines[i];
|
||||
if (ln.trim().length === 0) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
|
||||
break;
|
||||
}
|
||||
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(ln);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'para', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 插件数据获取(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:三处接口的取数、合并口径与字段解析
|
||||
* 都放在这里,页面只负责把结果落到 @State。
|
||||
*
|
||||
* 数据源对齐 WebGUI renderPlugins:
|
||||
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
|
||||
import { apiClient } from './ApiClient';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
|
||||
/** 拉取并合并插件列表;失败时抛错,由调用方转成人话提示。 */
|
||||
export async function fetchPluginRows(): Promise<PluginRow[]> {
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
const kpRaw: Object | undefined = kernelObj['plugins'];
|
||||
if (kpRaw !== undefined && kpRaw !== null) {
|
||||
const kpArr: Object[] = kpRaw as Object[];
|
||||
for (let i = 0; i < kpArr.length; i++) {
|
||||
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length === 0) {
|
||||
continue;
|
||||
}
|
||||
loadedMap.set(n, item['loaded'] as boolean ?? true);
|
||||
}
|
||||
}
|
||||
const tRaw: Object | undefined = kernelObj['tools'];
|
||||
if (tRaw !== undefined && tRaw !== null) {
|
||||
const tArr: Object[] = tRaw as Object[];
|
||||
for (let i = 0; i < tArr.length; i++) {
|
||||
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
|
||||
const tn: string = item['name'] as string ?? '';
|
||||
const owner: string = item['plugin'] as string ?? '';
|
||||
if (tn.length === 0 || owner.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let list: string[] | undefined = toolsByPlugin.get(owner);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
toolsByPlugin.set(owner, list);
|
||||
}
|
||||
// 每插件最多展示 8 个工具名,避免卡片过长
|
||||
if (list.length < 8) {
|
||||
list.push(tn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- installed external plugins metadata ----
|
||||
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
|
||||
try {
|
||||
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
|
||||
const bodyTrim = pResp.body.trim();
|
||||
let arr: Object[] = [];
|
||||
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
|
||||
arr = JSON.parse(pResp.body) as Object[];
|
||||
} else {
|
||||
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
|
||||
const rawList: Object = obj['plugins'] ?? obj['data'];
|
||||
if (rawList !== undefined && rawList !== null) {
|
||||
arr = rawList as Object[];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length > 0) {
|
||||
externalMeta.set(n, item);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 外部列表失败不阻塞内置展示
|
||||
}
|
||||
|
||||
// ---- disabled list ----
|
||||
const disabledNames: Set<string> = new Set<string>();
|
||||
try {
|
||||
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
|
||||
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
|
||||
const dArr: Object | undefined = dObj['disabled'];
|
||||
if (dArr !== undefined && dArr !== null) {
|
||||
const items: Object[] = dArr as Object[];
|
||||
for (let di = 0; di < items.length; di++) {
|
||||
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
|
||||
const dn: string = dItem['name'] as string ?? '';
|
||||
if (dn.length > 0) {
|
||||
disabledNames.add(dn);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// disabled endpoint may not exist; ignore
|
||||
}
|
||||
|
||||
// ---- merge: allNames sorted(与 GUI 一致)----
|
||||
const allNames: Set<string> = new Set<string>();
|
||||
loadedMap.forEach((v: boolean, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
externalMeta.forEach((v: Record<string, Object>, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
disabledNames.forEach((n: string) => {
|
||||
allNames.add(n);
|
||||
});
|
||||
const names: string[] = Array.from(allNames);
|
||||
names.sort();
|
||||
|
||||
const rows: PluginRow[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name: string = names[i];
|
||||
const meta: Record<string, Object> | undefined = externalMeta.get(name);
|
||||
const tools: string[] | undefined = toolsByPlugin.get(name);
|
||||
const row: PluginRow = {
|
||||
name: name,
|
||||
loaded: loadedMap.get(name) ?? false,
|
||||
disabled: disabledNames.has(name),
|
||||
external: externalMeta.has(name),
|
||||
version: meta !== undefined ? meta['version'] as string ?? '' : '',
|
||||
description: meta !== undefined ? meta['description'] as string ?? '' : '',
|
||||
tools: tools,
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function strArray(raw: Object | undefined): string[] {
|
||||
const out: string[] = [];
|
||||
if (raw === undefined || raw === null) {
|
||||
return out;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const s: string = arr[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时由调用方退回列表已有信息。
|
||||
*/
|
||||
export async function fetchPluginDetail(name: string): Promise<PluginDetail> {
|
||||
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
|
||||
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = o['name'] as string ?? name;
|
||||
d.version = o['version'] as string ?? '';
|
||||
d.description = o['description'] as string ?? '';
|
||||
d.author = o['author'] as string ?? '';
|
||||
d.license = o['license'] as string ?? '';
|
||||
d.homepage = o['homepage'] as string ?? '';
|
||||
d.repository = o['repository'] as string ?? '';
|
||||
d.entry = o['entry'] as string ?? '';
|
||||
d.minVersion = o['min_version'] as string ?? '';
|
||||
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||
d.tags = strArray(o['tags']);
|
||||
d.files = strArray(o['files']);
|
||||
return d;
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 插件状态展示口径(纯函数,无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:列表行与详情页共用同一套状态判定/文案/配色,
|
||||
* 抽成模块后两处不会再各写一份。
|
||||
*
|
||||
* 状态口径与 WebGUI 一致:已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。
|
||||
*/
|
||||
|
||||
import { PluginRow } from '../model/Model';
|
||||
|
||||
/** 'loaded' | 'pending' | 'disabled' | 'notloaded' */
|
||||
export function pluginStatusOf(plugin: PluginRow): string {
|
||||
if (plugin.loaded && !plugin.disabled) {
|
||||
return 'loaded'; // 已加载
|
||||
}
|
||||
if (plugin.loaded && plugin.disabled) {
|
||||
return 'pending'; // 运行中(禁用待生效)
|
||||
}
|
||||
if (plugin.disabled) {
|
||||
return 'disabled'; // 已禁用
|
||||
}
|
||||
return 'notloaded'; // 未加载
|
||||
}
|
||||
|
||||
export function pluginStatusText(plugin: PluginRow): string {
|
||||
const s: string = pluginStatusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '待生效';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '已禁用';
|
||||
}
|
||||
return '未加载';
|
||||
}
|
||||
|
||||
/** 状态点颜色;未加载态用主题里的弱化色(mutedColor 由调用方从 palette 取)。 */
|
||||
export function pluginStatusColor(plugin: PluginRow, mutedColor: string): string {
|
||||
const s: string = pluginStatusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '#17A964';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '#D99A2B';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '#E84026';
|
||||
}
|
||||
return mutedColor;
|
||||
}
|
||||
|
||||
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
|
||||
export function pluginRowSubtitle(plugin: PluginRow): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(pluginStatusText(plugin));
|
||||
parts.push(plugin.external ? '外部' : '内置');
|
||||
if (plugin.tools !== undefined && plugin.tools.length > 0) {
|
||||
parts.push(plugin.tools.length.toString() + ' 工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** 详情页状态行:状态 + 内置/外部 + 已废弃 + 工具数(顺序与原实现一致) */
|
||||
export function pluginDetailStatusLine(row: PluginRow, deprecated: boolean): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(pluginStatusText(row));
|
||||
parts.push(row.external ? '外部' : '内置');
|
||||
if (deprecated) {
|
||||
parts.push('已废弃');
|
||||
}
|
||||
if (row.tools !== undefined && row.tools.length > 0) {
|
||||
parts.push(row.tools.length.toString() + ' 个工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@ -1,313 +0,0 @@
|
||||
/**
|
||||
* 设置数据模型与纯解析逻辑(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:/settings 的响应解析、key 分区归类、
|
||||
* 分页取项都是纯函数,页面只负责把结果落到 @State。
|
||||
*/
|
||||
|
||||
/** One settings key card rendered in the editor list. */
|
||||
export interface SettingEntry {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
type: string; // bool | int | duration | select | password | text | string
|
||||
options: string[];
|
||||
value: string; // raw string value as stored by backend
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SettingMetaRaw {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export const SETTINGS_PAGE_SIZE: number = 40;
|
||||
|
||||
/**
|
||||
* 二级页面标识。
|
||||
* 一级入口列表(components/SettingsRootEntries.ets)与页面路由表分开成文件后,
|
||||
* 这些 id 必须只有一个来源 —— 否则改一处就会"点了没反应"。
|
||||
*/
|
||||
export const SUB_NONE: string = '';
|
||||
export const SUB_STATUS: string = 'status';
|
||||
export const SUB_CONNECTIONS: string = 'connections';
|
||||
export const SUB_APPEARANCE: string = 'appearance';
|
||||
export const SUB_BACKEND: string = 'backend';
|
||||
export const SUB_SECTION: string = 'section';
|
||||
|
||||
/** /settings 响应解析结果 */
|
||||
export interface ParsedSettings {
|
||||
meta: Record<string, SettingMetaRaw>;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 分区统计结果 */
|
||||
export interface SectionStats {
|
||||
sections: SettingsSection[];
|
||||
/** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */
|
||||
pluginKeyCount: number;
|
||||
/** 涉及的插件个数 */
|
||||
pluginConfigCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 GET /settings 响应:meta 定义 + 当前值。
|
||||
* 值统一转成字符串(后端可能给 bool/number/嵌套对象)。
|
||||
*/
|
||||
export function parseSettingsPayload(body: string): ParsedSettings {
|
||||
const obj: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
const metaStore: Record<string, SettingMetaRaw> = {};
|
||||
const valuesStore: Record<string, string> = {};
|
||||
|
||||
const rawMeta: Object | undefined = obj['meta'];
|
||||
if (rawMeta !== undefined && rawMeta !== null) {
|
||||
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
|
||||
for (const mk of Object.keys(mObj)) {
|
||||
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
|
||||
const optsArr: Object | undefined = item['options'];
|
||||
const opts: string[] = [];
|
||||
if (optsArr !== undefined && optsArr !== null) {
|
||||
const oa: Object[] = optsArr as Object[];
|
||||
for (let i = 0; i < oa.length; i++) {
|
||||
opts.push(oa[i] as string);
|
||||
}
|
||||
}
|
||||
const meta: SettingMetaRaw = {
|
||||
key: item['key'] as string ?? mk,
|
||||
type: item['type'] as string ?? 'string',
|
||||
displayName: item['display_name'] as string ?? '',
|
||||
description: item['description'] as string ?? '',
|
||||
category: item['category'] as string ?? '',
|
||||
options: opts,
|
||||
};
|
||||
metaStore[mk] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
const rawVals: Object | undefined = obj['settings'];
|
||||
if (rawVals !== undefined && rawVals !== null) {
|
||||
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
|
||||
for (const vk of Object.keys(vObj)) {
|
||||
if (vk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const val: Object = vObj[vk];
|
||||
let strVal: string;
|
||||
if (typeof val === 'string') {
|
||||
strVal = val as string;
|
||||
} else if (typeof val === 'boolean' || typeof val === 'number') {
|
||||
strVal = String(val);
|
||||
} else {
|
||||
strVal = JSON.stringify(val);
|
||||
}
|
||||
valuesStore[vk] = strVal;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed: ParsedSettings = { meta: metaStore, values: valuesStore };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function metaCategory(metaStore: Record<string, SettingMetaRaw>, key: string): string {
|
||||
const m: SettingMetaRaw | undefined = metaStore[key];
|
||||
return m !== undefined && m.category.length > 0 ? m.category : '';
|
||||
}
|
||||
|
||||
export function categoryTitle(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'agent': '智能体',
|
||||
'daemon': '守护进程',
|
||||
'llm': '大模型',
|
||||
'sources': '数据源',
|
||||
'input': '输入',
|
||||
'paths': '路径',
|
||||
'resources': '资源',
|
||||
'defaults': '默认值',
|
||||
'snapshot': '快照',
|
||||
'rollback': '回滚',
|
||||
};
|
||||
const t: string | undefined = map[cat];
|
||||
return t !== undefined ? t : cat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key 归到分区:
|
||||
* - core.* → 'core/<category>',展示在「核心」二级页下
|
||||
* - plugin.* → 'plugin/<name>',仅用于计数;实际编辑在插件详情页里,
|
||||
* 不在这里列出(否则同一批 key 会有两个入口)
|
||||
* - 其余 → 'other'
|
||||
*/
|
||||
export function buildSections(
|
||||
valuesStore: Record<string, string>, metaStore: Record<string, SettingMetaRaw>): SectionStats {
|
||||
const ids: string[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
const titles: Record<string, string> = {};
|
||||
let pluginKeys: number = 0;
|
||||
const plugNames: string[] = [];
|
||||
for (const key of Object.keys(valuesStore)) {
|
||||
if (key.startsWith('plugin.')) {
|
||||
// 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。
|
||||
// 这里只统计,用于提示有多少项在那边。
|
||||
pluginKeys = pluginKeys + 1;
|
||||
const rest: string = key.substring('plugin.'.length);
|
||||
const dot: number = rest.indexOf('.');
|
||||
const plugName: string = dot > 0 ? rest.substring(0, dot) : rest;
|
||||
if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) {
|
||||
plugNames.push(plugName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let secId: string;
|
||||
if (key.startsWith('core.')) {
|
||||
const cat: string = metaCategory(metaStore, key);
|
||||
secId = cat.length > 0 ? 'core/' + cat : 'core/misc';
|
||||
if (titles[secId] === undefined) {
|
||||
titles[secId] = cat.length > 0 ? categoryTitle(cat) : '未分类';
|
||||
}
|
||||
} else {
|
||||
secId = 'other';
|
||||
if (titles[secId] === undefined) {
|
||||
titles[secId] = '其他';
|
||||
}
|
||||
}
|
||||
if (counts[secId] === undefined) {
|
||||
counts[secId] = 0;
|
||||
ids.push(secId);
|
||||
}
|
||||
counts[secId] = counts[secId] + 1;
|
||||
}
|
||||
ids.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
const secs: SettingsSection[] = [];
|
||||
for (const id of ids) {
|
||||
secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 });
|
||||
}
|
||||
const stats: SectionStats = {
|
||||
sections: secs,
|
||||
pluginKeyCount: pluginKeys,
|
||||
pluginConfigCount: plugNames.length,
|
||||
};
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function pickInitialSection(sections: SettingsSection[]): string {
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === 'core/agent') {
|
||||
return 'core/agent';
|
||||
}
|
||||
}
|
||||
return sections.length > 0 ? sections[0].id : 'core';
|
||||
}
|
||||
|
||||
export function keyInSection(
|
||||
metaStore: Record<string, SettingMetaRaw>, key: string, secId: string): boolean {
|
||||
if (secId === 'other') {
|
||||
return !key.startsWith('core.') && !key.startsWith('plugin.');
|
||||
}
|
||||
if (secId.startsWith('core/')) {
|
||||
if (!key.startsWith('core.')) {
|
||||
return false;
|
||||
}
|
||||
const cat: string = secId.substring('core/'.length);
|
||||
return cat === 'misc'
|
||||
? metaCategory(metaStore, key).length === 0
|
||||
: metaCategory(metaStore, key) === cat;
|
||||
}
|
||||
if (secId.startsWith('plugin/')) {
|
||||
const p: string = secId.substring('plugin/'.length);
|
||||
return key.startsWith('plugin.' + p + '.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function activeSectionTitle(sections: SettingsSection[], secId: string): string {
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === secId) {
|
||||
return sections[i].title;
|
||||
}
|
||||
}
|
||||
return '配置项';
|
||||
}
|
||||
|
||||
/** 某个分区的配置项(按 key 字典序,最多 PAGE_SIZE*4 项) */
|
||||
export function buildEntries(
|
||||
valuesStore: Record<string, string>,
|
||||
metaStore: Record<string, SettingMetaRaw>,
|
||||
secId: string): SettingEntry[] {
|
||||
const entries: SettingEntry[] = [];
|
||||
const keys: string[] = Object.keys(valuesStore).filter((k: string): boolean => {
|
||||
return keyInSection(metaStore, k, secId);
|
||||
});
|
||||
keys.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
const limit: number = Math.min(keys.length, SETTINGS_PAGE_SIZE * 4);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const key: string = keys[i];
|
||||
const meta: SettingMetaRaw | undefined = metaStore[key];
|
||||
const entry: SettingEntry = {
|
||||
key: key,
|
||||
displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key,
|
||||
description: meta !== undefined ? meta.description : '',
|
||||
type: meta !== undefined ? meta.type : 'string',
|
||||
options: meta !== undefined ? meta.options : [],
|
||||
value: valuesStore[key] ?? '',
|
||||
dirty: false,
|
||||
};
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 覆盖某个 entry 的 value/dirty,返回新数组(保持 @State 数组替换语义) */
|
||||
export function withEntryMarked(
|
||||
entries: SettingEntry[], key: string, value: string, dirty: boolean): SettingEntry[] {
|
||||
const next: SettingEntry[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e: SettingEntry = entries[i];
|
||||
if (e.key === key) {
|
||||
const copy: SettingEntry = {
|
||||
key: e.key,
|
||||
displayName: e.displayName,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
options: e.options,
|
||||
value: value,
|
||||
dirty: dirty,
|
||||
};
|
||||
next.push(copy);
|
||||
} else {
|
||||
next.push(e);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 picker 返回的 URI 里取图片后缀(带点)。
|
||||
* Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败,
|
||||
* 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。
|
||||
*/
|
||||
export function imageExt(uri: string): string {
|
||||
let s: string = uri;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const dot: number = s.lastIndexOf('.');
|
||||
const slash: number = s.lastIndexOf('/');
|
||||
if (dot > slash && dot < s.length - 1) {
|
||||
const ext: string = s.substring(dot).toLowerCase();
|
||||
if (ext.length <= 5) {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
|
||||
*
|
||||
* 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。
|
||||
* 为什么不放进 StatusStore:轨迹来自 SSE 流、与 /status、/kernel 的轮询无关,
|
||||
* 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累
|
||||
* (SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。
|
||||
*
|
||||
* 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
|
||||
* 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
|
||||
* 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
|
||||
*/
|
||||
|
||||
/** 一个阶段组 */
|
||||
export interface StageGroup {
|
||||
/** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */
|
||||
group: number;
|
||||
label: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
/** 轨迹里的一条事件 */
|
||||
export interface StageEvent {
|
||||
group: number;
|
||||
/** 'stage' | 'tool' | 'output' */
|
||||
kind: string;
|
||||
label: string;
|
||||
short: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 阶段组定义,索引即 group */
|
||||
export const STAGE_GROUPS: StageGroup[] = [
|
||||
{ group: 0, label: '输入', en: 'in' },
|
||||
{ group: 1, label: '行动', en: 'act' },
|
||||
{ group: 2, label: '工具', en: 'tool' },
|
||||
{ group: 3, label: '输出', en: 'out' },
|
||||
{ group: 4, label: '结束', en: 'done' },
|
||||
];
|
||||
|
||||
/** 当前阶段(SSE 驱动)。空串 = 空闲。 */
|
||||
export const K_STAGE_PHASE: string = 'stagePhase';
|
||||
/** 轨迹版本号:数组不进 AppStorage,靠它触发订阅组件重取快照 */
|
||||
export const K_STAGE_REV: string = 'stageRev';
|
||||
|
||||
/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */
|
||||
const IDLE_AFTER_MS: number = 2500;
|
||||
const MAX_ITEMS: number = 24;
|
||||
|
||||
/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string {
|
||||
let n: string = name;
|
||||
const i: number = n.lastIndexOf('__');
|
||||
if (i >= 0) {
|
||||
n = n.substring(i + 2);
|
||||
}
|
||||
return n.length > 14 ? n.substring(0, 13) + '…' : n;
|
||||
}
|
||||
|
||||
class StageTrail {
|
||||
private items: StageEvent[] = [];
|
||||
private timerId: number = -1;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<string>(K_STAGE_PHASE, '');
|
||||
AppStorage.setOrCreate<number>(K_STAGE_REV, 0);
|
||||
}
|
||||
|
||||
/** 只给订阅组件读;调用方不要持有它 */
|
||||
snapshot(): StageEvent[] {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
currentPhase(): string {
|
||||
return AppStorage.get<string>(K_STAGE_PHASE) ?? '';
|
||||
}
|
||||
|
||||
/** 按阶段分组取条目 */
|
||||
eventsOf(group: number): StageEvent[] {
|
||||
const out: StageEvent[] = [];
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const e: StageEvent = this.items[i];
|
||||
if (e.group === group) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到一条 stage 事件。phase 取值与内核一致:
|
||||
* on_input / pre_action / post_action / before_toolcall / after_toolcall /
|
||||
* before_output / after_output。
|
||||
*/
|
||||
onStage(phase: string, tool: string): void {
|
||||
if (phase.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (phase === 'on_input') {
|
||||
// 新的一轮:清空上一轮的轨迹
|
||||
this.items = [];
|
||||
this.push(0, 'stage', '输入', '输入');
|
||||
} else if (phase === 'pre_action') {
|
||||
this.push(1, 'stage', '组装上下文并思考', '思考');
|
||||
} else if (phase === 'before_toolcall' && tool.length > 0) {
|
||||
// output_* 是输出通道工具,与普通工具用不同配色区分
|
||||
const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool';
|
||||
this.push(2, kind, tool, shortTool(tool));
|
||||
} else if (phase === 'before_output') {
|
||||
this.push(3, 'stage', '生成回复', '生成');
|
||||
} else if (phase === 'after_output') {
|
||||
this.push(4, 'stage', '本轮完成', '完成');
|
||||
} else {
|
||||
// post_action / after_toolcall 不单独记:它们与相邻格重复,
|
||||
// 逐条记会把轨迹刷成噪音。
|
||||
AppStorage.set<string>(K_STAGE_PHASE, phase);
|
||||
this.armIdleTimer();
|
||||
return;
|
||||
}
|
||||
AppStorage.set<string>(K_STAGE_PHASE, phase);
|
||||
this.armIdleTimer();
|
||||
this.bump();
|
||||
}
|
||||
|
||||
private push(group: number, kind: string, label: string, short: string): void {
|
||||
// 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏
|
||||
const n: number = this.items.length;
|
||||
if (n > 0) {
|
||||
const last: StageEvent = this.items[n - 1];
|
||||
if (last.group === group && last.kind === kind && last.short === short) {
|
||||
last.count = last.count + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 });
|
||||
if (this.items.length > MAX_ITEMS) {
|
||||
this.items.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private armIdleTimer(): void {
|
||||
if (this.timerId !== -1) {
|
||||
clearTimeout(this.timerId);
|
||||
}
|
||||
this.timerId = setTimeout(() => {
|
||||
this.timerId = -1;
|
||||
AppStorage.set<string>(K_STAGE_PHASE, '');
|
||||
this.bump();
|
||||
}, IDLE_AFTER_MS);
|
||||
}
|
||||
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_STAGE_REV) ?? 0;
|
||||
AppStorage.set<number>(K_STAGE_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const stageTrail: StageTrail = new StageTrail();
|
||||
|
||||
/**
|
||||
* 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致,
|
||||
* 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。
|
||||
*/
|
||||
export function phaseGroup(phase: string): number {
|
||||
if (phase === 'on_input') {
|
||||
return 0;
|
||||
}
|
||||
if (phase === 'pre_action' || phase === 'post_action') {
|
||||
return 1;
|
||||
}
|
||||
if (phase === 'before_toolcall' || phase === 'after_toolcall') {
|
||||
return 2;
|
||||
}
|
||||
if (phase === 'before_output') {
|
||||
return 3;
|
||||
}
|
||||
if (phase === 'after_output') {
|
||||
return 4;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@ -27,68 +27,6 @@ export interface StatGroup {
|
||||
fields: StatField[];
|
||||
}
|
||||
|
||||
// ===== 运行态快照(/runtime)=====
|
||||
//
|
||||
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
|
||||
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
|
||||
// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。
|
||||
|
||||
/** 一条队列(四级中断之一,或排队队列) */
|
||||
export interface RuntimeQueue {
|
||||
/** 4/3/2/1;0 表示排队队列(无级别) */
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
depth: number;
|
||||
registered: number;
|
||||
preempted: number;
|
||||
}
|
||||
|
||||
/** 一次刷新的运行态快照 */
|
||||
export interface RuntimeSnapshot {
|
||||
/** 排队队列深度(无级别,纯 FIFO) */
|
||||
ready: number;
|
||||
pending: number;
|
||||
stack: number;
|
||||
maxStack: number;
|
||||
subagents: number;
|
||||
/** 格槽数(按全场最大深度缩放,至少 5) */
|
||||
slots: number;
|
||||
/** 五条队列:L4/L3/L2/L1 + 排队 */
|
||||
queues: RuntimeQueue[];
|
||||
}
|
||||
|
||||
interface LevelDef {
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 四级中断的定义(顺序即 L4→L1) */
|
||||
const LEVEL_DEFS: LevelDef[] = [
|
||||
{ lv: 4, name: 'L4', desc: '内核独占' },
|
||||
{ lv: 3, name: 'L3', desc: '交互' },
|
||||
{ lv: 2, name: 'L2', desc: '消息' },
|
||||
{ lv: 1, name: 'L1', desc: '后台' },
|
||||
];
|
||||
|
||||
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
|
||||
function numAt(arr: Object[] | undefined, i: number): number {
|
||||
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
|
||||
return 0;
|
||||
}
|
||||
const v: number = arr[i] as number;
|
||||
return isNaN(v) ? 0 : v;
|
||||
}
|
||||
|
||||
/** 格槽数:按全场最大深度缩放,至少 5 格(0 时也要有可见形状)、最多 16 格 */
|
||||
function clampSlots(maxQ: number): number {
|
||||
if (maxQ < 5) {
|
||||
return 5;
|
||||
}
|
||||
return maxQ > 16 ? 16 : maxQ;
|
||||
}
|
||||
|
||||
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||
export const K_UP: string = 'statUp';
|
||||
export const K_VERSION: string = 'statVersion';
|
||||
@ -99,14 +37,10 @@ export const K_TOOLS: string = 'statTools';
|
||||
export const K_ERR: string = 'statErr';
|
||||
export const K_LOADING: string = 'statLoading';
|
||||
export const K_REV: string = 'statRev';
|
||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
||||
export const K_BUILD: string = 'statBuildSub';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
@ -118,17 +52,12 @@ class StatusStore {
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
getRuntime(): RuntimeSnapshot | undefined {
|
||||
return this.runtimeSnapshot;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
@ -138,11 +67,8 @@ class StatusStore {
|
||||
this.fail(noConnectionMessage());
|
||||
return;
|
||||
}
|
||||
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
|
||||
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
|
||||
// 版本 "-"、插件 0 的初始状态。
|
||||
AppStorage.set<boolean>(K_LOADING, true);
|
||||
AppStorage.set<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
@ -153,10 +79,10 @@ class StatusStore {
|
||||
const agents: number = obj['agents'] as number ?? 0;
|
||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||
|
||||
AppStorage.set<boolean>(K_UP, true);
|
||||
AppStorage.set<string>(K_VERSION, version);
|
||||
AppStorage.set<number>(K_AGENTS, agents);
|
||||
AppStorage.set<string>(K_STARTED, startedAt);
|
||||
AppStorage.setOrCreate<boolean>(K_UP, true);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, version);
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
||||
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
||||
|
||||
const systemFields: StatField[] = [
|
||||
{ label: '版本', value: version },
|
||||
@ -172,13 +98,12 @@ class StatusStore {
|
||||
];
|
||||
|
||||
await this.collectKernel(groups);
|
||||
await this.collectRuntime();
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
this.fail(userMessage('status.refresh', e));
|
||||
}
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
}
|
||||
|
||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||
@ -194,8 +119,8 @@ class StatusStore {
|
||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||
|
||||
AppStorage.set<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.set<number>(K_TOOLS, toolCount);
|
||||
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
|
||||
|
||||
const kernelFields: StatField[] = [
|
||||
{ label: 'Agent ID', value: agentId },
|
||||
@ -205,66 +130,8 @@ class StatusStore {
|
||||
if (startTime.length > 0) {
|
||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
||||
}
|
||||
|
||||
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。
|
||||
// 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、
|
||||
// 哪次构建;许可标识更是完全没有。
|
||||
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
|
||||
let lic: string = '';
|
||||
let licURL: string = '';
|
||||
let srcURL: string = '';
|
||||
if (build !== undefined && build !== null) {
|
||||
const bVer: string = build['version'] as string ?? '';
|
||||
const bCommit: string = build['commit'] as string ?? '';
|
||||
const bName: string = build['kernel_name'] as string ?? 'HomeAgent';
|
||||
const bSdk: string = build['sdk_compatible'] as string ?? '';
|
||||
const bTime: string = build['build_time'] as string ?? '';
|
||||
lic = build['license'] as string ?? '';
|
||||
licURL = build['license_url'] as string ?? '';
|
||||
srcURL = build['source_url'] as string ?? '';
|
||||
if (bVer.length > 0) {
|
||||
AppStorage.set<string>(K_VERSION, 'v' + bVer);
|
||||
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
|
||||
}
|
||||
let sub: string = bName;
|
||||
if (bCommit.length > 0 && bCommit !== 'unknown') {
|
||||
sub = sub + ' · ' + bCommit.substring(0, 7);
|
||||
}
|
||||
AppStorage.set<string>(K_BUILD, sub);
|
||||
if (bCommit.length > 0) {
|
||||
kernelFields.push({ label: 'Commit', value: bCommit });
|
||||
}
|
||||
if (bSdk.length > 0) {
|
||||
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
|
||||
}
|
||||
if (bTime.length > 0 && bTime !== 'unknown') {
|
||||
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
|
||||
}
|
||||
}
|
||||
groups.push({ title: '内核', fields: kernelFields });
|
||||
|
||||
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
|
||||
// 也看不出网络服务场景下的 §13 义务。
|
||||
const legalFields: StatField[] = [];
|
||||
if (lic.length > 0) {
|
||||
legalFields.push({ label: '许可协议', value: lic });
|
||||
}
|
||||
if (licURL.length > 0) {
|
||||
legalFields.push({ label: '协议全文', value: licURL });
|
||||
}
|
||||
if (srcURL.length > 0) {
|
||||
legalFields.push({ label: '源码仓库', value: srcURL });
|
||||
}
|
||||
if (legalFields.length > 0) {
|
||||
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
|
||||
legalFields.push({
|
||||
label: '网络条款',
|
||||
value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)',
|
||||
});
|
||||
}
|
||||
groups.push({ title: '开源许可', fields: legalFields });
|
||||
}
|
||||
|
||||
// LLM:provider / 可用源 / 是否可用
|
||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||
if (llm !== undefined && llm !== null) {
|
||||
@ -351,98 +218,18 @@ class StatusStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取运行态快照(/runtime)。失败不影响已经取到的明细分组:
|
||||
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
|
||||
*/
|
||||
private async collectRuntime(): Promise<void> {
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/runtime', 8000);
|
||||
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
|
||||
const residents: Object[] | undefined = r['residents'] as Object[];
|
||||
|
||||
let ready: number = 0;
|
||||
let pending: number = 0;
|
||||
let stack: number = 0;
|
||||
let maxStack: number = 4;
|
||||
let qArr: Object[] | undefined = undefined;
|
||||
let regArr: Object[] | undefined = undefined;
|
||||
let preArr: Object[] | undefined = undefined;
|
||||
if (sc !== undefined && sc !== null) {
|
||||
ready = sc['ready_queue_depth'] as number ?? 0;
|
||||
pending = sc['pending_interrupts'] as number ?? 0;
|
||||
stack = sc['suspend_stack'] as number ?? 0;
|
||||
maxStack = sc['max_suspend_depth'] as number ?? 4;
|
||||
qArr = sc['interrupt_queues'] as Object[];
|
||||
regArr = sc['interrupts_by_level'] as Object[];
|
||||
preArr = sc['preempts_by_level'] as Object[];
|
||||
}
|
||||
|
||||
// 四级语义引用内核的定义(internal/agent/core/scheduler.go),
|
||||
// 前端只负责把它们画出来,不自己起名字。
|
||||
const queues: RuntimeQueue[] = [];
|
||||
let maxQ: number = 1;
|
||||
for (let i = 0; i < LEVEL_DEFS.length; i++) {
|
||||
const d: LevelDef = LEVEL_DEFS[i];
|
||||
const depth: number = numAt(qArr, d.lv);
|
||||
if (depth > maxQ) {
|
||||
maxQ = depth;
|
||||
}
|
||||
queues.push({
|
||||
lv: d.lv,
|
||||
name: d.name,
|
||||
desc: d.desc,
|
||||
depth: depth,
|
||||
registered: numAt(regArr, d.lv),
|
||||
preempted: numAt(preArr, d.lv),
|
||||
});
|
||||
}
|
||||
if (ready > maxQ) {
|
||||
maxQ = ready;
|
||||
}
|
||||
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
|
||||
// 所以 lv 用 0 标记「无级别」。
|
||||
queues.push({
|
||||
lv: 0,
|
||||
name: '排队',
|
||||
desc: 'FIFO',
|
||||
depth: ready,
|
||||
registered: 0,
|
||||
preempted: 0,
|
||||
});
|
||||
|
||||
const subagents: number = residents !== undefined && residents !== null
|
||||
? residents.length : 0;
|
||||
|
||||
this.runtimeSnapshot = {
|
||||
ready: ready,
|
||||
pending: pending,
|
||||
stack: stack,
|
||||
maxStack: maxStack,
|
||||
subagents: subagents,
|
||||
slots: clampSlots(maxQ),
|
||||
queues: queues,
|
||||
};
|
||||
} catch (e) {
|
||||
// /runtime 不可用(旧后端):保留上一次快照,不清空
|
||||
}
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.set<string>(K_ERR, msg);
|
||||
AppStorage.set<boolean>(K_UP, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<string>(K_ERR, msg);
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.runtimeSnapshot = undefined;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||
AppStorage.set<number>(K_REV, cur + 1);
|
||||
AppStorage.setOrCreate<number>(K_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 「外观」二级页面:主题三选一 + 自定义背景图。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出。
|
||||
* themeMode / bgImage / bgOpacity 用 @Link 与一级页面共享(一级页的
|
||||
* 外观行要显示当前主题名,两边必须是同一份数据)。
|
||||
*/
|
||||
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { AppSettings, emptySettings } from '../model/Model';
|
||||
import { applyThemeMode } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { imageExt } from '../common/SettingsModel';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@Component
|
||||
export struct AppearancePane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Link themeMode: string;
|
||||
@Link bgImage: string;
|
||||
@Link bgOpacity: number;
|
||||
onBack?: () => void;
|
||||
onToast?: (msg: string, isError: boolean) => void;
|
||||
@State pickingBg: boolean = false;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private toast(msg: string, isError: boolean): void {
|
||||
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
|
||||
if (cb !== undefined) {
|
||||
cb(msg, isError);
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme(mode: string): void {
|
||||
this.themeMode = mode;
|
||||
this.persistSettings();
|
||||
// 立即翻转全局主题标志,整个 UI 随之切换
|
||||
applyThemeMode(mode);
|
||||
}
|
||||
|
||||
private async pickBackgroundImage(): Promise<void> {
|
||||
if (this.pickingBg) {
|
||||
return;
|
||||
}
|
||||
this.pickingBg = true;
|
||||
try {
|
||||
const options = new picker.PhotoSelectOptions();
|
||||
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = 1;
|
||||
const photoPicker = new picker.PhotoViewPicker();
|
||||
const result = await photoPicker.select(options);
|
||||
if (result.photoUris.length === 0) {
|
||||
return;
|
||||
}
|
||||
const srcUri: string = result.photoUris[0];
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
// 文件名带时间戳:Image 组件按 src 字符串做内存缓存,
|
||||
// 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。
|
||||
// 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败,
|
||||
// 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。
|
||||
const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri);
|
||||
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
|
||||
const destFile = fileIo.openSync(destPath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.copyFileSync(srcFile.fd, destFile.fd);
|
||||
fileIo.closeSync(srcFile);
|
||||
fileIo.closeSync(destFile);
|
||||
this.removeOldBgFile();
|
||||
// Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败
|
||||
this.bgImage = 'file://' + destPath;
|
||||
this.persistSettings();
|
||||
this.toast('背景图已设置', false);
|
||||
} catch (e) {
|
||||
this.toast(userMessage('settings.pickBg', e), true);
|
||||
}
|
||||
this.pickingBg = false;
|
||||
}
|
||||
|
||||
/** 删除上一张背景图文件,避免沙箱里越攒越多 */
|
||||
private removeOldBgFile(): void {
|
||||
const old: string = this.bgImage;
|
||||
if (old.length === 0) {
|
||||
return;
|
||||
}
|
||||
const path: string = old.startsWith('file://') ? old.substring(7) : old;
|
||||
try {
|
||||
fileIo.unlinkSync(path);
|
||||
} catch (e) {
|
||||
// 文件可能已不存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
private clearBackgroundImage(): void {
|
||||
this.removeOldBgFile();
|
||||
this.bgImage = '';
|
||||
this.persistSettings();
|
||||
this.toast('已清除背景图', false);
|
||||
}
|
||||
|
||||
private onBgOpacityChange(value: number): void {
|
||||
this.bgOpacity = value / 100;
|
||||
this.persistSettings();
|
||||
}
|
||||
|
||||
private persistSettings(): void {
|
||||
const s: AppSettings = emptySettings();
|
||||
const old: AppSettings = connStore.getSettings();
|
||||
s.lang = old.lang;
|
||||
s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system');
|
||||
s.currentConnId = old.currentConnId;
|
||||
s.bgImage = this.bgImage;
|
||||
s.bgOpacity = this.bgOpacity;
|
||||
connStore.saveSettings(s);
|
||||
AppStorage.set<string>('bgImage', this.bgImage);
|
||||
AppStorage.set<number>('bgOpacity', this.bgOpacity);
|
||||
}
|
||||
|
||||
@Builder
|
||||
ThemeOption(label: string, mode: string) {
|
||||
// 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。
|
||||
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
|
||||
Column() {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
|
||||
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.width('100%')
|
||||
.height(34)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
|
||||
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
|
||||
// 覆盖它上面的所有状态驱动属性。
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
|
||||
})
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.applyTheme(mode);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '外观',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
PlainCard({ caption: '主题' }) {
|
||||
Row({ space: 8 }) {
|
||||
this.ThemeOption('跟随系统', 'system')
|
||||
this.ThemeOption('浅色', 'light')
|
||||
this.ThemeOption('深色', 'dark')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.themeMode === 'system'
|
||||
? '当前跟随系统,系统切换深浅色时自动跟随'
|
||||
: (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题'))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '背景图' }) {
|
||||
Row() {
|
||||
Column() {
|
||||
Text('自定义背景图')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
if (this.bgImage.length > 0) {
|
||||
Button('更换')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.pickBackgroundImage();
|
||||
})
|
||||
Button('清除')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor('#E84026')
|
||||
.onClick(() => {
|
||||
this.clearBackgroundImage();
|
||||
})
|
||||
} else {
|
||||
Button(this.pickingBg ? '选择中...' : '选择图片')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.pickBackgroundImage();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.bgImage.length > 0) {
|
||||
Row() {
|
||||
Text('透明度')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Slider({
|
||||
value: Math.round(this.bgOpacity * 100),
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 1,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.selectedColor(this.palette().accent)
|
||||
.trackColor(this.palette().bgHover)
|
||||
.margin({ left: 8, right: 8 })
|
||||
.onChange((v: number, mode: SliderChangeMode) => {
|
||||
if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) {
|
||||
this.onBgOpacityChange(v);
|
||||
}
|
||||
})
|
||||
Text(Math.round(this.bgOpacity * 100).toString() + '%')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width(32)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ top: 12 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,20 +4,107 @@ import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta';
|
||||
import { loadPixelMap } from '../common/AttachmentImage';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard } from './SubPage';
|
||||
|
||||
/**
|
||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
||||
* 附件解析与展示。
|
||||
*
|
||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||
*/
|
||||
|
||||
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
|
||||
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||
const url: string = o['url'] as string ?? '';
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const t: string = o['type'] as string ?? 'file';
|
||||
const a: ChatAttachment = {
|
||||
type: t === 'image' ? 'image' : 'file',
|
||||
url: url,
|
||||
size: o['size'] as number ?? 0,
|
||||
name: o['name'] as string ?? fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
|
||||
export function attachmentFromChannelOutput(
|
||||
outputType: string, url: string, size: number): ChatAttachment | undefined {
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (outputType !== 'image' && outputType !== 'file') {
|
||||
return undefined;
|
||||
}
|
||||
const a: ChatAttachment = {
|
||||
type: outputType,
|
||||
url: url,
|
||||
size: size,
|
||||
name: fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
|
||||
export function fileNameOf(url: string): string {
|
||||
let s: string = url;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const i: number = s.lastIndexOf('/');
|
||||
const name: string = i >= 0 ? s.substring(i + 1) : s;
|
||||
return name.length > 0 ? name : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (n < 1024) {
|
||||
return n.toString() + ' B';
|
||||
}
|
||||
const kb: number = n / 1024;
|
||||
if (kb < 1024) {
|
||||
return oneDecimal(kb) + ' KB';
|
||||
}
|
||||
const mb: number = kb / 1024;
|
||||
if (mb < 1024) {
|
||||
return oneDecimal(mb) + ' MB';
|
||||
}
|
||||
return oneDecimal(mb / 1024) + ' GB';
|
||||
}
|
||||
|
||||
function oneDecimal(v: number): string {
|
||||
return (Math.round(v * 10) / 10).toString();
|
||||
}
|
||||
|
||||
/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */
|
||||
export function extLabel(name: string): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
if (i < 0 || i === name.length - 1) {
|
||||
return '未知类型';
|
||||
}
|
||||
return name.substring(i + 1).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||
*/
|
||||
@Component
|
||||
@ -341,3 +428,36 @@ export struct AttachmentDetailContent {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
||||
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
|
||||
try {
|
||||
// 本地待上传的图片:直接读沙箱文件,不走网络
|
||||
if (url.startsWith('file://')) {
|
||||
const path: string = url.substring(7);
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const localSrc: image.ImageSource = image.createImageSource(f.fd);
|
||||
const localPm: image.PixelMap = await localSrc.createPixelMap();
|
||||
await localSrc.release();
|
||||
fileIo.closeSync(f);
|
||||
return localPm;
|
||||
}
|
||||
const abs: string = apiClient.absoluteUrl(url);
|
||||
const resp = await apiClient.getBinary(abs, 15000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const pm: image.PixelMap = await src.createPixelMap();
|
||||
await src.release();
|
||||
return pm;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
|
||||
function sanitize(name: string): string {
|
||||
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
|
||||
if (s.length === 0) {
|
||||
s = 'attachment';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出。
|
||||
* 取数/分区/落库都留在页面(它同时要显示"几个分类 · 几项"和错误态),
|
||||
* 这里只负责渲染与把用户动作转成回调。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { SettingsSection, SettingEntry, activeSectionTitle } from '../common/SettingsModel';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard } from './SubPage';
|
||||
import { SettingsEntryCard } from './SettingsEntryCard';
|
||||
|
||||
@Component
|
||||
export struct BackendSectionsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop sections: SettingsSection[] = [];
|
||||
@Prop pluginKeyCount: number = 0;
|
||||
@Prop pluginConfigCount: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
@Prop activeSection: string = '';
|
||||
/** 宽屏右栏正显示分区明细时高亮左侧对应行 */
|
||||
@Prop highlightRows: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onOpenSection?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '核心配置',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
if (this.errorText.length > 0) {
|
||||
PlainCard({ caption: '状态' }) {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
}
|
||||
}
|
||||
|
||||
NavGroup({ caption: '核心' }) {
|
||||
ForEach(this.sections, (sec: SettingsSection, idx: number) => {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_tune'),
|
||||
title: sec.title,
|
||||
subtitle: sec.count.toString() + ' 项配置',
|
||||
showDivider: idx < this.sections.length - 1,
|
||||
selected: this.highlightRows && this.activeSection === sec.id,
|
||||
onTap: () => {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpenSection;
|
||||
if (cb !== undefined) {
|
||||
cb(sec.id);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (sec: SettingsSection) => sec.id + sec.count.toString())
|
||||
}
|
||||
|
||||
// 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口
|
||||
if (this.pluginKeyCount > 0) {
|
||||
Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' +
|
||||
this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
|
||||
if (this.sections.length === 0 && !this.busy) {
|
||||
Text('未获取到配置分类。检查后端连接后点击刷新。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct SectionEntriesPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop sections: SettingsSection[] = [];
|
||||
@Prop activeSection: string = '';
|
||||
@Prop entries: SettingEntry[] = [];
|
||||
@Prop busy: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onSaveValue?: (key: string, value: string) => void;
|
||||
onSaveCurrent?: (key: string) => void;
|
||||
onEdit?: (key: string, value: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: activeSectionTitle(this.sections, this.activeSection),
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
ForEach(this.entries, (entry: SettingEntry) => {
|
||||
SettingsEntryCard({
|
||||
entry: entry,
|
||||
onSaveValue: (key: string, value: string) => {
|
||||
const cb: ((k: string, v: string) => void) | undefined = this.onSaveValue;
|
||||
if (cb !== undefined) {
|
||||
cb(key, value);
|
||||
}
|
||||
},
|
||||
onSaveCurrent: (key: string) => {
|
||||
const cb: ((k: string) => void) | undefined = this.onSaveCurrent;
|
||||
if (cb !== undefined) {
|
||||
cb(key);
|
||||
}
|
||||
},
|
||||
onEdit: (key: string, value: string) => {
|
||||
const cb: ((k: string, v: string) => void) | undefined = this.onEdit;
|
||||
if (cb !== undefined) {
|
||||
cb(key, value);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
|
||||
|
||||
if (!this.busy && this.entries.length === 0) {
|
||||
Text('该分区暂无配置项')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
/**
|
||||
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
|
||||
*
|
||||
* 从 components/ChatComposer.ets 拆出 —— 两者都是"输入区上方的独立浮层",
|
||||
* 数据与动作全部由输入区传入。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { formatBytes } from '../common/AttachmentMeta';
|
||||
|
||||
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
|
||||
@Component
|
||||
export struct ChatAttachMenu {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onPickImage?: () => void;
|
||||
onPickFile?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
@Builder
|
||||
AttachOption(icon: Resource, label: string, tap: () => void) {
|
||||
// 按压反馈交给 MotionBase:每个实例自带独立按压态,
|
||||
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Row({ space: 6 }) {
|
||||
Image(icon)
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
}
|
||||
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.onClick(tap)
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐
|
||||
Row() {
|
||||
Row({ space: 8 }) {
|
||||
this.AttachOption($r('app.media.ic_image'), '图片', () => {
|
||||
const cb: (() => void) | undefined = this.onPickImage;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
this.AttachOption($r('app.media.ic_file'), '文件', () => {
|
||||
const cb: (() => void) | undefined = this.onPickFile;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.margin({ bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
// 加号菜单由 if 控制,进出场只能靠 transition;配合 toggle 处的
|
||||
// animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */
|
||||
@Component
|
||||
export struct ChatPendingChip {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop name: string = '';
|
||||
@Prop byteSize: number = 0;
|
||||
@Prop isImage: boolean = false;
|
||||
@Prop uploading: boolean = false;
|
||||
onRemove?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 按压反馈交给 MotionBase(全宽预览条)
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.isImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(this.name)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.uploading ? '上传中...' : formatBytes(this.byteSize))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
if (this.uploading) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
} else {
|
||||
Image($r('app.media.ic_close'))
|
||||
.width(13)
|
||||
.height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onRemove;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.margin({ bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 Avatar / ChanAvatar / BubbleSlot /
|
||||
* MessageBubble / BubbleBody 五个 @Builder)。
|
||||
*
|
||||
* 传参约定:一切都在构造时快照进来。ForEach 的键(structSig)只在
|
||||
* 消息"结构"变化时改变(新增思考/工具/附件/来源、定稿),结构一变
|
||||
* 气泡就整条重建,因此结构类字段不需要二次更新;唯一会在键不变时
|
||||
* 持续变化的是正文 content,所以它单独用基本类型 @Prop 传(与
|
||||
* MarkdownView 的 @Prop content 走同一条响应式链路,流式渲染不变)。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { bubbleMaxWidth, chanColor, chanLabel, chanLetter } from '../common/ChatFormat';
|
||||
import { AttachmentCard } from './Attachment';
|
||||
import { MarkdownView } from './MarkdownView';
|
||||
import { ChatReasoningCard, ChatToolCard } from './ChatToolCard';
|
||||
|
||||
@Component
|
||||
export struct ChatBubble {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop msgId: number = 0;
|
||||
@Prop role: string = '';
|
||||
/** 正文:唯一会在 ForEach 键不变时持续变化的字段 */
|
||||
@Prop content: string = '';
|
||||
@Prop isStreaming: boolean = false;
|
||||
@Prop isFinal: boolean = false;
|
||||
@Prop reasoningContent: string = '';
|
||||
@Prop reasoningOpen: boolean = false;
|
||||
@Prop toolCalls: ToolCallInfo[] = [];
|
||||
@Prop attachment: ChatAttachment | undefined = undefined;
|
||||
@Prop source: string = '';
|
||||
/** 是否"别处来的"消息(渠道/设备) */
|
||||
@Prop channel: boolean = false;
|
||||
/** 是否自己发的(右对齐、"我"头像) */
|
||||
@Prop mine: boolean = false;
|
||||
/** 入场动画阶段 */
|
||||
@Prop fresh: boolean = false;
|
||||
onOpenAttachment?: (att: ChatAttachment) => void;
|
||||
onToggleReasoning?: (msgId: number) => void;
|
||||
onToggleTool?: (msgId: number, index: number) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 思考卡/工具卡是否是"面板":气泡宽度要放宽,见 common/ChatFormat */
|
||||
private maxWidth(): string {
|
||||
const msg: ChatMessage = {
|
||||
id: this.msgId,
|
||||
role: this.role,
|
||||
content: this.content,
|
||||
toolCalls: this.toolCalls,
|
||||
};
|
||||
if (this.reasoningContent.length > 0) {
|
||||
msg.reasoningContent = this.reasoningContent;
|
||||
}
|
||||
return bubbleMaxWidth(msg);
|
||||
}
|
||||
|
||||
@Builder
|
||||
Avatar() {
|
||||
Text(this.role === 'user' ? '我' : 'AI')
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.role === 'user' ? this.palette().msgUserText : this.palette().accent)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.backgroundColor(this.role === 'user' ? this.palette().msgUserBg : this.palette().accentBg)
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ChanAvatar() {
|
||||
Text(chanLetter(this.source))
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.backgroundColor(chanColor(this.source))
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。
|
||||
*
|
||||
* 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比
|
||||
* (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带
|
||||
* 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding,
|
||||
* 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的
|
||||
* 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来,
|
||||
* 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为
|
||||
* 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp
|
||||
* 被 clip 的问题是同一个成因)。
|
||||
*
|
||||
* 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。
|
||||
* 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度,
|
||||
* 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。
|
||||
*/
|
||||
@Builder
|
||||
BubbleSlot() {
|
||||
Column() {
|
||||
this.BubbleBody()
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(this.mine ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
BubbleBody() {
|
||||
Column() {
|
||||
// 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示
|
||||
if (this.channel) {
|
||||
Text(chanLabel(this.source))
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 4 })
|
||||
}
|
||||
|
||||
// Reasoning card (assistant only)
|
||||
if (this.role === 'assistant' && this.reasoningContent.length > 0) {
|
||||
ChatReasoningCard({
|
||||
content: this.reasoningContent,
|
||||
open: this.reasoningOpen,
|
||||
sweeping: this.isFinal !== true && this.isStreaming === true,
|
||||
fresh: this.fresh,
|
||||
onToggle: () => {
|
||||
const cb: ((id: number) => void) | undefined = this.onToggleReasoning;
|
||||
if (cb !== undefined) {
|
||||
cb(this.msgId);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页
|
||||
if (this.attachment !== undefined) {
|
||||
AttachmentCard({
|
||||
att: this.attachment,
|
||||
mine: this.role === 'user',
|
||||
onTap: () => {
|
||||
const a: ChatAttachment | undefined = this.attachment;
|
||||
const cb: ((att: ChatAttachment) => void) | undefined = this.onOpenAttachment;
|
||||
if (a !== undefined && cb !== undefined) {
|
||||
cb(a);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn
|
||||
if (this.content.length > 0) {
|
||||
if (this.role === 'assistant') {
|
||||
MarkdownView({
|
||||
content: this.content,
|
||||
isStreaming: this.isStreaming === true,
|
||||
isDark: this.isDark,
|
||||
})
|
||||
} else {
|
||||
Text(this.content)
|
||||
.fontSize(15)
|
||||
.lineHeight(24)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.constraintSize({ maxWidth: '100%' })
|
||||
.margin({ top: this.attachment !== undefined ? 8 : 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Tool cards
|
||||
if (this.toolCalls.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.toolCalls, (tc: ToolCallInfo, tci: number) => {
|
||||
ChatToolCard({
|
||||
tc: tc,
|
||||
index: tci,
|
||||
onToggle: (index: number) => {
|
||||
const cb: ((id: number, i: number) => void) | undefined = this.onToggleTool;
|
||||
if (cb !== undefined) {
|
||||
cb(this.msgId, index);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name)
|
||||
}
|
||||
// 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。
|
||||
// 让它自适应,最大宽约束由气泡内容框向下传递,ChatToolCard 内部用 layoutWeight 取满。
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ top: this.content.length > 0 ? 6 : 0 })
|
||||
}
|
||||
}
|
||||
.constraintSize({ maxWidth: this.maxWidth() })
|
||||
.clip(true)
|
||||
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
|
||||
.backgroundColor(this.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg)
|
||||
.borderRadius({
|
||||
topLeft: RADIUS_MD,
|
||||
topRight: RADIUS_MD,
|
||||
bottomLeft: this.role === 'assistant' ? 4 : RADIUS_MD,
|
||||
bottomRight: this.role === 'user' ? 4 : RADIUS_MD,
|
||||
})
|
||||
.border({ width: 1, color: this.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder })
|
||||
.shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// bubbleGrow: 气泡入场缩放效果
|
||||
.scale({
|
||||
x: this.fresh ? 0.95 : 1,
|
||||
y: this.fresh ? 0.95 : 1,
|
||||
})
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
if (this.channel) {
|
||||
this.ChanAvatar()
|
||||
this.BubbleSlot()
|
||||
} else if (this.role === 'user') {
|
||||
this.BubbleSlot()
|
||||
this.Avatar()
|
||||
} else {
|
||||
this.Avatar()
|
||||
this.BubbleSlot()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
// 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配,
|
||||
// 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。
|
||||
.justifyContent(this.mine ? FlexAlign.End : FlexAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
// 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0)
|
||||
.opacity(this.fresh ? 0 : 1)
|
||||
.translate({ y: this.fresh ? 8 : 0 })
|
||||
.animation({ duration: 180, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
@ -1,361 +0,0 @@
|
||||
/**
|
||||
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 的层2 + AttachMenu /
|
||||
* AttachOption / PendingAttachmentChip 三个 @Builder,外加选图/选文件、
|
||||
* 沙箱落盘、带附件发送这一整套方法)。
|
||||
*
|
||||
* 输入区自己持有文本与附件状态;只有会影响【列表底部留白】的三项
|
||||
* (inputMultiLine / attachMenuOpen / pendingName)用 @Link 与页面共享。
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
|
||||
import { mimeOf } from '../common/ChatFormat';
|
||||
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
|
||||
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
|
||||
import { fileNameOf } from '../common/AttachmentMeta';
|
||||
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
|
||||
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
|
||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { MeasureOptions } from '@ohos.measure';
|
||||
|
||||
/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */
|
||||
const INPUT_FONT_SIZE: number = 14;
|
||||
const INPUT_INNER_PAD: number = 16;
|
||||
/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */
|
||||
const INPUT_LEFT_GAP: number = 50;
|
||||
const INPUT_RIGHT_GAP: number = 52;
|
||||
|
||||
@Component
|
||||
export struct ChatComposer {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
/** 输入框是否已进入多行态(页面用它算列表底部留白) */
|
||||
@Link inputMultiLine: boolean;
|
||||
/** 加号菜单是否展开(同上) */
|
||||
@Link attachMenuOpen: boolean;
|
||||
/** 待发送附件的展示名(同上) */
|
||||
@Link pendingName: string;
|
||||
@State inputText: string = '';
|
||||
@State pendingSize: number = 0;
|
||||
@State pendingIsImage: boolean = false;
|
||||
@State uploading: boolean = false;
|
||||
private pendingPath: string = '';
|
||||
private pendingMime: string = '';
|
||||
/** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */
|
||||
private inputRowWidth: number = 0;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */
|
||||
private inputSolidBg(): string {
|
||||
return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。
|
||||
*
|
||||
* 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。
|
||||
* 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关,
|
||||
* 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。
|
||||
*/
|
||||
private recomputeMultiLine(text: string): void {
|
||||
const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP
|
||||
- INPUT_INNER_PAD * 2;
|
||||
if (avail <= 0) {
|
||||
return;
|
||||
}
|
||||
let multi: boolean = text.indexOf('\n') >= 0;
|
||||
if (!multi && text.length > 0) {
|
||||
const opt: MeasureOptions = {
|
||||
textContent: text,
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
};
|
||||
const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt);
|
||||
// measureTextSize 返回 px,可用宽度是 vp,换算后再比
|
||||
const widthVp: number = this.getUIContext().px2vp(size.width as number);
|
||||
multi = widthVp > avail;
|
||||
}
|
||||
if (multi !== this.inputMultiLine) {
|
||||
this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => {
|
||||
this.inputMultiLine = multi;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 附件:选择与上传 =====================
|
||||
|
||||
/** 从图库挑一张图 */
|
||||
private async pickImage(): Promise<void> {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.PhotoSelectOptions();
|
||||
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = 1;
|
||||
const photoPicker = new picker.PhotoViewPicker();
|
||||
const result = await photoPicker.select(options);
|
||||
if (result.photoUris.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.stagePickedFile(result.photoUris[0], true);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.pickImage', e));
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** 从文件管理器挑一个文件 */
|
||||
private async pickFile(): Promise<void> {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.DocumentSelectOptions();
|
||||
options.maxSelectNumber = 1;
|
||||
const docPicker = new picker.DocumentViewPicker();
|
||||
const uris: string[] = await docPicker.select(options);
|
||||
if (uris.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.stagePickedFile(uris[0], false);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.pickFile', e));
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 picker 给的 URI 复制到应用沙箱。
|
||||
* http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径,
|
||||
* 直接把 picker 的 media:// URI 交过去会读不到内容。
|
||||
*/
|
||||
private stagePickedFile(srcUri: string, isImage: boolean): void {
|
||||
try {
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
const name: string = fileNameOf(srcUri);
|
||||
const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name;
|
||||
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
|
||||
const destFile = fileIo.openSync(destPath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.copyFileSync(srcFile.fd, destFile.fd);
|
||||
fileIo.closeSync(srcFile);
|
||||
fileIo.closeSync(destFile);
|
||||
const stat = fileIo.statSync(destPath);
|
||||
this.clearPendingFile();
|
||||
this.pendingPath = destPath;
|
||||
this.pendingName = name;
|
||||
this.pendingSize = stat.size;
|
||||
this.pendingIsImage = isImage;
|
||||
this.pendingMime = mimeOf(name, isImage);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.stageFile', e));
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
|
||||
/** 丢弃待发送附件,并删掉沙箱里的临时副本 */
|
||||
private clearPendingFile(): void {
|
||||
if (this.pendingPath.length > 0) {
|
||||
try {
|
||||
fileIo.unlinkSync(this.pendingPath);
|
||||
} catch (e) {
|
||||
// 已不存在,忽略
|
||||
}
|
||||
}
|
||||
this.pendingPath = '';
|
||||
this.pendingName = '';
|
||||
this.pendingSize = 0;
|
||||
this.pendingIsImage = false;
|
||||
this.pendingMime = '';
|
||||
}
|
||||
|
||||
/** 发送:有附件走 multipart(POST /chat/file),否则走 POST /chat */
|
||||
private async send(): Promise<void> {
|
||||
if (this.loading || this.uploading) {
|
||||
return;
|
||||
}
|
||||
// 没连后端时直接返回、且不清输入/不动附件:与拆分前 sendChat 的守卫顺序一致
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
const text: string = this.inputText.trim();
|
||||
if (this.pendingPath.length > 0) {
|
||||
const path: string = this.pendingPath;
|
||||
const name: string = this.pendingName;
|
||||
const size: number = this.pendingSize;
|
||||
const isImage: boolean = this.pendingIsImage;
|
||||
const mime: string = this.pendingMime;
|
||||
this.inputText = '';
|
||||
this.inputMultiLine = false;
|
||||
this.uploading = true;
|
||||
await sendChatFile(text, path, name, size, isImage, mime);
|
||||
this.uploading = false;
|
||||
this.clearPendingFile();
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
return;
|
||||
}
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.inputText = '';
|
||||
this.inputMultiLine = false;
|
||||
await sendChatText(text);
|
||||
}
|
||||
|
||||
// ===================== UI =====================
|
||||
|
||||
build() {
|
||||
NavFloatOverlay({ tab: 0 }) {
|
||||
// 加号展开的两个选项(图片 / 文件),点一次收起
|
||||
if (this.attachMenuOpen) {
|
||||
ChatAttachMenu({
|
||||
onPickImage: () => {
|
||||
this.pickImage();
|
||||
},
|
||||
onPickFile: () => {
|
||||
this.pickFile();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 待发送附件预览(选好图片/文件、还没点发送时显示)
|
||||
if (this.pendingName.length > 0) {
|
||||
ChatPendingChip({
|
||||
name: this.pendingName,
|
||||
byteSize: this.pendingSize,
|
||||
isImage: this.pendingIsImage,
|
||||
uploading: this.uploading,
|
||||
onRemove: () => {
|
||||
this.clearPendingFile();
|
||||
chatStore.forceRefresh();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动,
|
||||
// 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮
|
||||
Row() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_plus'),
|
||||
onTap: () => {
|
||||
// 菜单展开会同时改变列表底部留白,用 animateTo 把
|
||||
// 列表内边距和菜单进出场拉到同一个时钟上。
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = !this.attachMenuOpen;
|
||||
});
|
||||
},
|
||||
})
|
||||
Blank()
|
||||
if (this.loading) {
|
||||
Button() {
|
||||
Image($r('app.media.ic_stop'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor('#77809A')
|
||||
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
interruptChat();
|
||||
})
|
||||
} else {
|
||||
Button() {
|
||||
Image($r('app.media.ic_send'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onAreaChange((_o: Area, n: Area) => {
|
||||
// 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。
|
||||
const w: number = n.width as number;
|
||||
if (Math.abs(w - this.inputRowWidth) > 0.5) {
|
||||
this.inputRowWidth = w;
|
||||
this.recomputeMultiLine(this.inputText);
|
||||
}
|
||||
})
|
||||
|
||||
// 上层:输入框。
|
||||
// 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行;
|
||||
// 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。
|
||||
//
|
||||
// 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成
|
||||
// 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。
|
||||
// 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行,
|
||||
// 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。
|
||||
Row() {
|
||||
TextArea({
|
||||
placeholder: '输入消息...',
|
||||
text: this.inputText,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
// 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动
|
||||
.constraintSize({ minHeight: 44, maxHeight: 168 })
|
||||
.fontSize(INPUT_FONT_SIZE)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderFont({ size: 13 })
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.inputSolidBg())
|
||||
.backdropBlur(24)
|
||||
.borderRadius(22)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.padding({
|
||||
left: INPUT_INNER_PAD,
|
||||
right: INPUT_INNER_PAD,
|
||||
top: 11,
|
||||
bottom: 11,
|
||||
})
|
||||
.enterKeyType(EnterKeyType.Send)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
this.recomputeMultiLine(value);
|
||||
})
|
||||
.onSubmit(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
// 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作
|
||||
// "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。
|
||||
.padding(this.inputMultiLine
|
||||
? { left: 0, right: 0 }
|
||||
: { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP })
|
||||
.margin({ bottom: this.inputMultiLine ? 52 : 0 })
|
||||
// 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。
|
||||
// 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。
|
||||
// None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
.animation({ duration: 260, curve: Curve.Friction })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。
|
||||
* 消息数组来自 common/ChatStore.ets:用版本号 K_CHAT_REV 订阅,
|
||||
* 版本变化时重取一次快照(数组引用每次都是新的,ForEach 的渲染语义
|
||||
* 与拆分前 this.messages = this.messages.slice() 完全一致)。
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
|
||||
import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { ChatBubble } from './ChatBubble';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
|
||||
@Component
|
||||
export struct ChatStream {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_CHAT_STAGE) private stage: string = '';
|
||||
/** 数组快照的订阅信号 */
|
||||
@StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0;
|
||||
/** "滚到底"请求信号 */
|
||||
@StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0;
|
||||
@State messages: ChatMessage[] = [];
|
||||
/** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */
|
||||
@Prop bottomPad: number = 210;
|
||||
onOpenAttachment?: (att: ChatAttachment) => void;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private autoScrolling: boolean = false;
|
||||
private navHidden: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
private onChatRev(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
private onScrollReq(): void {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
|
||||
* bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
|
||||
* 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
|
||||
*/
|
||||
private opaqueBottomBg(): string {
|
||||
return '#FF' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private transparentBottomBg(): string {
|
||||
return '#00' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
this.autoScrolling = true;
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
this.autoScrolling = false;
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}, 450);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar,
|
||||
* 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
|
||||
* 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换,
|
||||
* 不会引起布局回流——这是滑动流畅的关键。
|
||||
*/
|
||||
private handleScrollDirection(yOffset: number, state: ScrollState): void {
|
||||
if (this.autoScrolling) {
|
||||
return;
|
||||
}
|
||||
// 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页
|
||||
if (yOffset < 60 && chatStore.hasMore()) {
|
||||
chatStore.loadOlder();
|
||||
}
|
||||
if (state === ScrollState.Idle) {
|
||||
if (this.navHidden) {
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}
|
||||
} else {
|
||||
// Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏
|
||||
if (!this.navHidden) {
|
||||
this.navHidden = true;
|
||||
navBar.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出)
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column() {
|
||||
ForEach(this.messages, (msg: ChatMessage, idx: number) => {
|
||||
ChatBubble({
|
||||
msgId: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
isStreaming: msg.isStreaming === true,
|
||||
isFinal: msg.isFinal === true,
|
||||
reasoningContent: msg.reasoningContent ?? '',
|
||||
reasoningOpen: msg.reasoningOpen === true,
|
||||
toolCalls: msg.toolCalls ?? [],
|
||||
attachment: msg.attachment,
|
||||
source: msg.source ?? '',
|
||||
channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()),
|
||||
mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())),
|
||||
fresh: chatStore.isFresh(msg.id),
|
||||
onOpenAttachment: (att: ChatAttachment) => {
|
||||
const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment;
|
||||
if (cb !== undefined) {
|
||||
cb(att);
|
||||
}
|
||||
},
|
||||
onToggleReasoning: (id: number) => {
|
||||
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
|
||||
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
onToggleTool: (id: number, index: number) => {
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
})
|
||||
}, (msg: ChatMessage, idx: number) => structSig(msg))
|
||||
|
||||
if (this.loading) {
|
||||
Row({ space: 8 }) {
|
||||
LoadingProgress()
|
||||
.width(16)
|
||||
.height(16)
|
||||
.color(this.palette().accent)
|
||||
Text(this.stage.length > 0 ? this.stage : '处理中...')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.padding({ left: 52, top: 6, bottom: 6 })
|
||||
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
|
||||
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({
|
||||
left: 14,
|
||||
right: 14,
|
||||
top: 76,
|
||||
bottom: this.bottomPad,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
this.handleScrollDirection(yOffset, state);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
|
||||
PageTopBar({ title: '聊天' })
|
||||
|
||||
// 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
|
||||
// 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。
|
||||
Column()
|
||||
.width('100%')
|
||||
.height(170)
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Bottom,
|
||||
colors: [
|
||||
[this.transparentBottomBg(), 0.0],
|
||||
[this.opaqueBottomBg(), 0.6],
|
||||
[this.opaqueBottomBg(), 1.0],
|
||||
],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来分别是 ReasoningCard / ToolCard 两个 @Builder)。
|
||||
* 折叠状态与开关动作都交回调用方(状态在 chatStore 里,且展开/收起要在
|
||||
* animateTo 帧内完成 —— 那需要组件上下文)。
|
||||
*/
|
||||
|
||||
import { ToolCallInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { tcRunning, tcError, tcLeftColor, tcIcoColor, tcStateLabel, tcStateColor } from '../common/ChatFormat';
|
||||
|
||||
@Component
|
||||
export struct ChatReasoningCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 思考正文(已折叠时也带着,展开不再请求) */
|
||||
@Prop content: string = '';
|
||||
@Prop open: boolean = false;
|
||||
/** 流式中:显示转圈 + 扫光条 */
|
||||
@Prop sweeping: boolean = false;
|
||||
/** 入场动画阶段:扫光条起始偏移靠它切换 */
|
||||
@Prop fresh: boolean = false;
|
||||
onToggle?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 两层结构,原因见 ChatBubble 的布局说明:
|
||||
// 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度,
|
||||
// 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满;
|
||||
// 内层 holder Column 自身无 padding,所以它的子节点写 width('100%')
|
||||
// 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。
|
||||
Row() {
|
||||
Column() {
|
||||
Row({ space: 7 }) {
|
||||
Image($r('app.media.ic_sparkle'))
|
||||
.width(12)
|
||||
.height(12)
|
||||
.fillColor(this.palette().accent)
|
||||
Text('思考过程')
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
// 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡
|
||||
if (this.sweeping) {
|
||||
LoadingProgress()
|
||||
.width(11)
|
||||
.height(11)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
Blank()
|
||||
Text(this.content.length > 0 ? this.content.length.toString() + ' 字' : '')
|
||||
.fontSize(9.5)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Image($r('app.media.ic_chevron_down'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.rotate({ angle: this.open ? 180 : 0 })
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
|
||||
if (this.open) {
|
||||
Text(this.content)
|
||||
.fontSize(11.5)
|
||||
.lineHeight(17)
|
||||
.fontColor(this.palette().textTertiary)
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, bottom: 8 })
|
||||
.maxLines(24)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
|
||||
if (this.sweeping) {
|
||||
Stack() {
|
||||
Row()
|
||||
.height(2)
|
||||
.borderRadius(2)
|
||||
.width('200%')
|
||||
.linearGradient({
|
||||
angle: 90,
|
||||
colors: [
|
||||
['rgba(255,255,255,0.01)', 0],
|
||||
[this.palette().accent, 0.35],
|
||||
['rgba(255,255,255,0.01)', 0.5],
|
||||
[this.palette().accent, 0.65],
|
||||
['rgba(255,255,255,0.01)', 1],
|
||||
],
|
||||
})
|
||||
.opacity(0.7)
|
||||
.translate({ x: this.fresh ? '0%' : '-50%' })
|
||||
.animation({ duration: 1200, curve: Curve.Linear })
|
||||
}
|
||||
.width('100%')
|
||||
.clip(true)
|
||||
.height(2)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 6 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed })
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ChatToolCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop tc: ToolCallInfo;
|
||||
/** 在所属消息 toolCalls 里的下标:开关动作要交回调用方按 (msgId, index) 定位 */
|
||||
@Prop index: number = 0;
|
||||
onToggle?: (index: number) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 同 ChatReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽
|
||||
Row() {
|
||||
Column() {
|
||||
Row({ space: 6 }) {
|
||||
if (tcError(this.tc)) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(13).height(13)
|
||||
.fillColor(tcIcoColor(this.tc, this.palette().accent))
|
||||
} else if (tcRunning(this.tc)) {
|
||||
LoadingProgress()
|
||||
.width(12).height(12)
|
||||
.color(tcIcoColor(this.tc, this.palette().accent))
|
||||
} else {
|
||||
Image($r('app.media.ic_check'))
|
||||
.width(13).height(13)
|
||||
.fillColor(tcIcoColor(this.tc, this.palette().accent))
|
||||
}
|
||||
Text(this.tc.name)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 名字可长可短,必须让它占剩余宽度并可收缩,
|
||||
// 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉)
|
||||
.layoutWeight(1)
|
||||
if (this.tc.plugin !== undefined && this.tc.plugin.length > 0) {
|
||||
Text(this.tc.plugin)
|
||||
.fontSize(9.5)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.flexShrink(0)
|
||||
}
|
||||
// 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标")
|
||||
Text(tcStateLabel(this.tc))
|
||||
.fontSize(9.5)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(tcStateColor(this.tc))
|
||||
.flexShrink(0)
|
||||
Image($r('app.media.ic_chevron_down'))
|
||||
.width(13).height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.flexShrink(0)
|
||||
.rotate({ angle: this.tc.open === true ? 180 : 0 })
|
||||
.animation({ duration: 150, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
const cb: ((index: number) => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb(this.index);
|
||||
}
|
||||
})
|
||||
|
||||
if (this.tc.open === true) {
|
||||
Column() {
|
||||
if (this.tc.args.length > 0 && this.tc.args !== '{}') {
|
||||
Text('参数')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(this.tc.args)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}
|
||||
if (this.tc.result !== undefined && this.tc.result.length > 0) {
|
||||
Text('结果')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(this.tc.result)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.maxLines(8)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
|
||||
.margin({ bottom: 4 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3, top: 1, right: 1, bottom: 1 },
|
||||
color: {
|
||||
left: tcLeftColor(this.tc, this.palette().accent),
|
||||
top: this.palette().kvBorder,
|
||||
right: this.palette().kvBorder,
|
||||
bottom: this.palette().kvBorder,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,347 +0,0 @@
|
||||
/**
|
||||
* 「后端连接」二级页面。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、
|
||||
* 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥)
|
||||
* 都属于这一个功能域,收在一个组件里。
|
||||
*
|
||||
* connections / currentId 用 @Link 与一级页面共享:一级页的入口行
|
||||
* 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。
|
||||
*/
|
||||
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@Component
|
||||
export struct ConnectionsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Link connections: ConnectionConfig[];
|
||||
@Link currentId: string;
|
||||
onBack?: () => void;
|
||||
onToast?: (msg: string, isError: boolean) => void;
|
||||
@State showAddForm: boolean = false;
|
||||
@State addFormVisible: boolean = false;
|
||||
/**
|
||||
* 表单当前在编辑哪条连接:空串表示新建。
|
||||
*
|
||||
* 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者,
|
||||
* 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单
|
||||
* 靠这个 id 区分保存走 add 还是 update。
|
||||
*/
|
||||
@State editingId: string = '';
|
||||
@State editUrl: string = '';
|
||||
@State editApiKey: string = '';
|
||||
@State editName: string = '';
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private toast(msg: string, isError: boolean): void {
|
||||
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
|
||||
if (cb !== undefined) {
|
||||
cb(msg, isError);
|
||||
}
|
||||
}
|
||||
|
||||
private currentConnName(): string {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
if (this.connections[i].id === this.currentId) {
|
||||
return this.connections[i].name;
|
||||
}
|
||||
}
|
||||
return '未配置';
|
||||
}
|
||||
|
||||
private selectConnection(id: string): void {
|
||||
connStore.setCurrent(id).then(() => {
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('已切换连接', false);
|
||||
});
|
||||
}
|
||||
|
||||
private addConnection(): void {
|
||||
const name: string = this.editName.trim();
|
||||
const url: string = this.editUrl.trim();
|
||||
const apiKey: string = this.editApiKey.trim();
|
||||
if (name.length === 0 || url.length === 0) {
|
||||
this.toast('名称和地址不能为空', true);
|
||||
return;
|
||||
}
|
||||
if (this.editingId.length > 0) {
|
||||
this.updateConnection(this.editingId, name, url, apiKey);
|
||||
return;
|
||||
}
|
||||
connStore.addConnection(name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已添加', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对已有连接的修改。
|
||||
*
|
||||
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
|
||||
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
|
||||
*/
|
||||
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
|
||||
connStore.updateConnection(id, name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已更新', false);
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */
|
||||
private openConnForm(conn: ConnectionConfig | null): void {
|
||||
this.showAddForm = true;
|
||||
this.addFormVisible = false;
|
||||
if (conn === null) {
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
} else {
|
||||
this.editingId = conn.id;
|
||||
this.editName = conn.name;
|
||||
this.editUrl = conn.url;
|
||||
this.editApiKey = conn.apiKey;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.addFormVisible = true;
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private closeConnForm(): void {
|
||||
this.showAddForm = false;
|
||||
this.addFormVisible = false;
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
}
|
||||
|
||||
private deleteConnection(id: string): void {
|
||||
connStore.deleteConnection(id).then(() => {
|
||||
this.connections = connStore.getConnections();
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
} else {
|
||||
apiClient.clearConnection();
|
||||
}
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已删除', false);
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '后端连接',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
PlainCard({ caption: '当前连接' }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Text(this.currentConnName())
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button('+ 添加')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.openConnForm(null);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
if (this.showAddForm) {
|
||||
Column() {
|
||||
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ bottom: 10 })
|
||||
TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editName = v;
|
||||
})
|
||||
TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editUrl = v;
|
||||
})
|
||||
TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
|
||||
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
|
||||
.type(InputType.Password).margin({ bottom: 12 })
|
||||
.onChange((v: string) => {
|
||||
this.editApiKey = v;
|
||||
})
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.closeConnForm();
|
||||
})
|
||||
Blank()
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.addConnection();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.margin({ top: 12 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.opacity(this.addFormVisible ? 1 : 0)
|
||||
.translate({ y: this.addFormVisible ? 0 : 12 })
|
||||
.animation({ duration: 220, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
|
||||
PlainCard({ caption: '全部连接' }) {
|
||||
if (this.connections.length === 0) {
|
||||
Text('暂无连接。点击上方“添加”配置后端地址。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
ForEach(this.connections, (conn: ConnectionConfig) => {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(conn.name)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(conn.url)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (conn.id !== this.currentId) {
|
||||
Button('切换')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.selectConnection(conn.id);
|
||||
})
|
||||
} else {
|
||||
Text('使用中')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().accentBg)
|
||||
.margin({ right: 6 })
|
||||
}
|
||||
Button('编辑')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.openConnForm(conn);
|
||||
})
|
||||
Button('删除')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor('#E84026')
|
||||
.onClick(() => {
|
||||
this.deleteConnection(conn.id);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3 },
|
||||
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
|
||||
})
|
||||
.margin({ bottom: 6 })
|
||||
// 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
|
||||
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
|
||||
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
|
||||
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
/**
|
||||
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(原来是 LocalDeviceContent / CapsContent /
|
||||
* GatewayContent / OnlineDevicesContent 四个 @Builder + KvRow)。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题、所属 Tab、返回、刷新),
|
||||
* 页面只保留路由分发 —— 与 SettingsPage 拆出的三个 Pane 同一套做法。
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL,
|
||||
ANIM_ENTER } from '../common/Constants';
|
||||
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard, SubPageLayer } from './SubPage';
|
||||
|
||||
/** 通用 KV 行(面板之间共用) */
|
||||
@Component
|
||||
export struct DeviceKvRow {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop label: string = '';
|
||||
@Prop value: string = '';
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
Text(this.label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
Text(this.value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:本机设备(基本信息 + 远程控制授权) */
|
||||
@Component
|
||||
export struct DeviceLocalPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop deviceId: string = '';
|
||||
@Prop authorized: boolean = false;
|
||||
onBack?: () => void;
|
||||
onToggleAuth?: (on: boolean) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '基本信息' }) {
|
||||
DeviceKvRow({ label: '设备 ID', value: this.deviceId.length > 0 ? this.deviceId : '未注册' })
|
||||
DeviceKvRow({ label: '名称', value: 'HomeAgent OHOS' })
|
||||
DeviceKvRow({ label: '类型', value: 'phone' })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '权限控制' }) {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text('允许 agent 控制本机')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
const cb: ((on: boolean) => void) | undefined = this.onToggleAuth;
|
||||
if (cb !== undefined) {
|
||||
cb(on);
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.authorized ? '#17A964' : '#E84026')
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.margin({ right: 8 })
|
||||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||||
.fontSize(12)
|
||||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:设备能力(本机能被 agent 调用的能力清单) */
|
||||
@Component
|
||||
export struct DeviceCapsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onBack?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '能力清单' }) {
|
||||
Text('agent 通过设备桥可调用的本机能力:')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
|
||||
Text(cap)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(999)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
}, (cap: string) => cap)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:设备通道(网关地址 / Token / 连接状态) */
|
||||
@Component
|
||||
export struct DeviceGatewayPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop bridgeUrl: string = '';
|
||||
@Prop bridgeToken: string = '';
|
||||
@Prop bridgeConnected: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
DeviceKvRow({ label: '网关地址', value: this.bridgeUrl.length > 0 ? this.bridgeUrl : '-' })
|
||||
DeviceKvRow({ label: 'Token', value: this.bridgeToken.length > 0 ? '已从连接继承' : '未配置' })
|
||||
DeviceKvRow({ label: '状态', value: this.bridgeConnected ? '已连接' : '未连接' })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Blank()
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:接入的设备(网关侧在线设备列表) */
|
||||
@Component
|
||||
export struct DeviceListPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop devices: DeviceInfo[] = [];
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
showRefresh: true,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
DeviceOnlineList({ devices: this.devices })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线设备列表本体(抽出来只是为了让 DeviceListPane 的 build 更短) */
|
||||
@Component
|
||||
struct DeviceOnlineList {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop devices: DeviceInfo[] = [];
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.devices.length === 0) {
|
||||
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||||
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
|
||||
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
|
||||
Column() {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 设备页一级入口列表(本机 / 通道两组)。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(原来是 RootEntries 一个 @Builder)。
|
||||
* 宽屏高亮由组件自己读 AppStorage 的 isWideScreen 决定,
|
||||
* 页面只负责给 activeSub 与四个打开动作。
|
||||
*/
|
||||
|
||||
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
|
||||
import { SUB_CAPS, SUB_GATEWAY, SUB_LIST, SUB_LOCAL } from '../common/DeviceModel';
|
||||
import { NavGroup, NavRow } from './SubPage';
|
||||
|
||||
@Component
|
||||
export struct DeviceRootEntries {
|
||||
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@Prop activeSub: string = '';
|
||||
@Prop deviceId: string = '';
|
||||
@Prop bridgeConnected: boolean = false;
|
||||
@Prop bridgeUrl: string = '';
|
||||
@Prop loadingDevices: boolean = false;
|
||||
@Prop deviceCount: number = 0;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_DEVICE_CAPS.length;
|
||||
}
|
||||
|
||||
private open(id: string): void {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 一级入口列表整体作为一个容器根节点:@Component 的 build() 只允许一个根,
|
||||
// 页面侧仍是 `.padding(...)` 的 Column,逐项布局与拆分前一致。
|
||||
Column() {
|
||||
NavGroup({ caption: '本机' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_phone'),
|
||||
title: '本机设备',
|
||||
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
|
||||
value: this.bridgeConnected ? '在线' : '离线',
|
||||
selected: this.isWide && this.activeSub === SUB_LOCAL,
|
||||
onTap: () => {
|
||||
this.open(SUB_LOCAL);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_bolt'),
|
||||
title: '设备能力',
|
||||
subtitle: this.capsCount().toString() + ' 项能力',
|
||||
value: '',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_CAPS,
|
||||
onTap: () => {
|
||||
this.open(SUB_CAPS);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '通道' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_gateway'),
|
||||
title: '设备通道',
|
||||
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
|
||||
value: this.bridgeConnected ? '已连接' : '未连接',
|
||||
selected: this.isWide && this.activeSub === SUB_GATEWAY,
|
||||
onTap: () => {
|
||||
this.open(SUB_GATEWAY);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_devices_multi'),
|
||||
title: '接入的设备',
|
||||
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
|
||||
value: this.deviceCount.toString() + ' 台',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_LIST,
|
||||
onTap: () => {
|
||||
this.open(SUB_LIST);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 插件详情(二级页面内容)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出。
|
||||
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
|
||||
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 配置 / 操作。
|
||||
*/
|
||||
|
||||
import { PluginRow, PluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
|
||||
ANIM_FAST, COLOR_ERROR } from '../common/Constants';
|
||||
import { pluginDetailStatusLine, pluginStatusColor } from '../common/PluginStatus';
|
||||
import { PlainCard } from './SubPage';
|
||||
import { SettingsEditor } from './SettingsEditor';
|
||||
|
||||
@Component
|
||||
export struct PluginDetailPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop detail: PluginDetail;
|
||||
/** 当前查看详情的插件名(可能还没拿到 detail.name) */
|
||||
@Prop activeName: string = '';
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
/** 一级列表里对应的那一行;取不到详情时用它兜底,也为操作按钮提供状态 */
|
||||
@Prop row: PluginRow | undefined = undefined;
|
||||
onToggle?: () => void;
|
||||
onRemove?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
// ---- 取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
|
||||
|
||||
private hasManifest(): boolean {
|
||||
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
|
||||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
|
||||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
|
||||
}
|
||||
|
||||
private isBuiltin(): boolean {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? !r.external : false;
|
||||
}
|
||||
|
||||
private isDisabled(): boolean {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? r.disabled : false;
|
||||
}
|
||||
|
||||
private tools(): string[] {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
if (r === undefined) {
|
||||
return [];
|
||||
}
|
||||
return r.tools ?? [];
|
||||
}
|
||||
|
||||
private statusLine(): string {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? pluginDetailStatusLine(r, this.detail.deprecated) : '未加载';
|
||||
}
|
||||
|
||||
private statusColor(): string {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? pluginStatusColor(r, this.palette().textMuted) : this.palette().textMuted;
|
||||
}
|
||||
|
||||
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
|
||||
@Builder
|
||||
KvRow(label: string, value: string) {
|
||||
if (value.length > 0) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
Text(value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 220 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
|
||||
@Builder
|
||||
Badges() {
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 7, height: 7 })
|
||||
.fill(this.statusColor())
|
||||
Text(this.statusLine())
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
|
||||
@Builder
|
||||
ToolsCard() {
|
||||
if (this.tools().length > 0) {
|
||||
PlainCard({ caption: '注册的工具 (' + this.tools().length.toString() + ')' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.tools(), (tool: string) => {
|
||||
Text(tool)
|
||||
.fontSize(11)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (tool: string) => tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
ActionsCard() {
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.isDisabled() ? '启用' : '禁用')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.isDisabled()
|
||||
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
|
||||
})
|
||||
.fontColor(this.isDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
if (!this.isBuiltin()) {
|
||||
Button('卸载')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor(COLOR_ERROR)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onRemove;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 单一根节点:与原来作为 @Builder 直接铺在 SubPageLayer 的 Column 里同构,
|
||||
// 保持 alignItems Start,避免文本被默认居中对齐。
|
||||
Column() {
|
||||
if (this.busy) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 30, bottom: 30 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.errorText.length > 0) {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.padding({ left: 4, bottom: 12 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 概览卡:名称、版本、状态徽标、描述
|
||||
PlainCard({ caption: '概览' }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.detail.version.length > 0) {
|
||||
Text('v' + this.detail.version)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
this.Badges()
|
||||
|
||||
if (this.detail.description.length > 0) {
|
||||
Text(this.detail.description)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
|
||||
if (this.hasManifest()) {
|
||||
PlainCard({ caption: '清单' }) {
|
||||
this.KvRow('作者', this.detail.author)
|
||||
this.KvRow('许可证', this.detail.license)
|
||||
this.KvRow('主页', this.detail.homepage)
|
||||
this.KvRow('仓库', this.detail.repository)
|
||||
this.KvRow('入口', this.detail.entry)
|
||||
this.KvRow('最低内核版本', this.detail.minVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.tags.length > 0) {
|
||||
PlainCard({ caption: '标签' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.tags, (t: string) => {
|
||||
Text(t)
|
||||
.fontSize(10)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (t: string) => t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ToolsCard()
|
||||
|
||||
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
|
||||
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
|
||||
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '插件配置' }) {
|
||||
SettingsEditor({
|
||||
prefix: 'plugin.' + this.activeName + '.',
|
||||
emptyHint: '该插件没有暴露可配置项',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.files.length > 0) {
|
||||
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
|
||||
ForEach(this.detail.files, (f: string) => {
|
||||
Text(f)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ bottom: 4 })
|
||||
}, (f: string) => f)
|
||||
}
|
||||
}
|
||||
|
||||
this.ActionsCard()
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 插件一级列表(列表卡 + 三种占位态)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:列表只负责"选谁",
|
||||
* 描述/工具/启停全部下沉到详情页。
|
||||
*/
|
||||
|
||||
import { PluginRow } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD,
|
||||
ANIM_FAST, ANIM_ENTER } from '../common/Constants';
|
||||
import { pluginRowSubtitle, pluginStatusColor } from '../common/PluginStatus';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* 单个插件行卡。
|
||||
*
|
||||
* 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
|
||||
* 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
|
||||
* 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
|
||||
*/
|
||||
@Component
|
||||
export struct PluginListCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop plugin: PluginRow;
|
||||
/** 是否当前选中(宽屏高亮左侧列表项)。单独用基本类型传,选中态变更才能触发更新 */
|
||||
@Prop active: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
if (this.plugin.version !== undefined && this.plugin.version.length > 0) {
|
||||
Text('v' + this.plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(pluginStatusColor(this.plugin, this.palette().textMuted))
|
||||
Text(pluginRowSubtitle(this.plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.active ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.active ? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.active ? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级列表整体:占位态 + 行卡 ForEach。
|
||||
* 取数留在页面里,这里只吃数据。
|
||||
*/
|
||||
@Component
|
||||
export struct PluginListView {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop plugins: PluginRow[] = [];
|
||||
@Prop busy: boolean = false;
|
||||
@Prop activeName: string = '';
|
||||
@Prop hasConn: boolean = false;
|
||||
onSelect?: (name: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// ── 加载中 / 未配置 / 空列表三种占位态 ──
|
||||
if (this.busy && this.plugins.length === 0) {
|
||||
LoadingProgress()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.color(this.palette().accent)
|
||||
.margin({ top: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.hasConn) {
|
||||
Text(noConnectionMessage())
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.busy && this.plugins.length === 0 && this.hasConn) {
|
||||
Text('暂无已加载插件')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
PluginListCard({
|
||||
plugin: plugin,
|
||||
active: this.activeName === plugin.name,
|
||||
onTap: () => {
|
||||
const cb: ((name: string) => void) | undefined = this.onSelect;
|
||||
if (cb !== undefined) {
|
||||
cb(plugin.name);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 安装表单悬浮卡。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出(浮在内容之上的独立图层,与列表/详情无关)。
|
||||
* 轻提示条与设置页共用,见 components/ToastBar.ets。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
|
||||
ANIM_ENTER } from '../common/Constants';
|
||||
|
||||
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
|
||||
@Component
|
||||
export struct PluginInstallForm {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop url: string = '';
|
||||
onUrlChange?: (v: string) => void;
|
||||
onInstall?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
TextInput({ placeholder: '.hmap 包下载 URL', text: this.url })
|
||||
.layoutWeight(1)
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.onChange((v: string) => {
|
||||
const cb: ((v: string) => void) | undefined = this.onUrlChange;
|
||||
if (cb !== undefined) {
|
||||
cb(v);
|
||||
}
|
||||
})
|
||||
Button('安装')
|
||||
.height(36)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ left: 6 })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onInstall;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.margin({ bottom: 10 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
@ -1,385 +0,0 @@
|
||||
import {
|
||||
ThemePalette, DARK_PALETTE, LIGHT_PALETTE,
|
||||
RADIUS_SM, RADIUS_MD, ANIM_FAST,
|
||||
COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300,
|
||||
} from '../common/Constants';
|
||||
import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore';
|
||||
import {
|
||||
stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup,
|
||||
K_STAGE_PHASE, K_STAGE_REV,
|
||||
} from '../common/StageTrail';
|
||||
|
||||
/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */
|
||||
const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A'];
|
||||
/** 阶段管道里的「工具」格(循环格):一轮内可能调几十次,只露最新一条 */
|
||||
const TOOL_GROUP: number = 2;
|
||||
/** 索引 = lv(0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4) */
|
||||
function lvColor(lv: number): string {
|
||||
if (lv < 0 || lv > 4) {
|
||||
return COLOR_ACCENT;
|
||||
}
|
||||
return LV_COLORS[lv];
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行态面板:阶段管道 + 中断队列。
|
||||
*
|
||||
* 与 WebUI / 桌面版同一套设计语言:**等大表框**。
|
||||
* 此前鸿蒙端完全没有运行态展示(「状态」Tab 已并入设置页 + 二级明细),
|
||||
* 这里补在二级明细页顶部。
|
||||
*
|
||||
* 两块数据来源不同:
|
||||
* - 队列 / 计数:StatusStore 轮询 /runtime,靠 K_REV 通知;
|
||||
* - 阶段 + 本轮轨迹:StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。
|
||||
*/
|
||||
@Component
|
||||
export struct RuntimePanel {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 宽屏(平板/折叠)5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0;
|
||||
@StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0;
|
||||
@StorageProp(K_STAGE_PHASE) private phase: string = '';
|
||||
@State private snap: RuntimeSnapshot | undefined = undefined;
|
||||
/** 格槽下标:ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */
|
||||
@State private slotIdx: number[] = [];
|
||||
@State private trail: StageEvent[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.pull();
|
||||
}
|
||||
|
||||
private onStatusRev(): void {
|
||||
this.pull();
|
||||
}
|
||||
|
||||
private onStageRev(): void {
|
||||
this.trail = stageTrail.snapshot();
|
||||
}
|
||||
|
||||
private pull(): void {
|
||||
const s: RuntimeSnapshot | undefined = statusStore.getRuntime();
|
||||
this.snap = s;
|
||||
if (s !== undefined) {
|
||||
const idx: number[] = [];
|
||||
for (let i = 0; i < s.slots; i++) {
|
||||
idx.push(i);
|
||||
}
|
||||
this.slotIdx = idx;
|
||||
} else {
|
||||
this.slotIdx = [];
|
||||
}
|
||||
this.trail = stageTrail.snapshot();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 该阶段本轮发生的事件 */
|
||||
private eventsOf(group: number): StageEvent[] {
|
||||
const out: StageEvent[] = [];
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该阶段本轮事件的**最后一条**(最新)。没有则 undefined。 */
|
||||
private latestEvent(group: number): StageEvent | undefined {
|
||||
let out: StageEvent | undefined = undefined;
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
out = e;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该阶段本轮事件条数合计(同一工具连调会累加到 count 上) */
|
||||
private totalCount(group: number): number {
|
||||
let n: number = 0;
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
n += e.count;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Builder
|
||||
tile(label: string, value: string, warn: boolean) {
|
||||
Column({ space: 2 }) {
|
||||
Text(value)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(warn ? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
}
|
||||
|
||||
/** 阶段管道的一格 */
|
||||
@Builder
|
||||
stageCell(g: StageGroup, active: boolean) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 5 }) {
|
||||
// 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源,
|
||||
// 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。
|
||||
// 不用 Circle().fill():那是 SDK 26 起的 API,本工程兼容版本是 6.1.1(24)。
|
||||
Row()
|
||||
.width(7)
|
||||
.height(7)
|
||||
.borderRadius(4)
|
||||
.backgroundColor(active ? this.palette().accent : this.palette().textMuted)
|
||||
Text(g.label)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(active ? this.palette().accent : this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着。
|
||||
//
|
||||
// 「工具」格是循环格:一轮里可能调几十次工具/输出通道。把每一次都追加成
|
||||
// 一行,这格会被撑成长条,反而看不出「现在在调什么」。所以它只保留**最新
|
||||
// 一条**,右侧给本轮累计次数(与 WebUI/桌面版同一口径)。
|
||||
Column({ space: 3 }) {
|
||||
if (g.group === TOOL_GROUP) {
|
||||
if (this.eventsOf(g.group).length > 0) {
|
||||
Row({ space: 3 }) {
|
||||
Text(this.latestEvent(g.group)?.short ?? '')
|
||||
.fontSize(9)
|
||||
.fontColor(this.latestEvent(g.group)?.kind === 'output' ? this.palette().accent
|
||||
: (this.latestEvent(g.group)?.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text('x' + this.totalCount(g.group).toString())
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
} else {
|
||||
Text('无')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.opacity(0.5)
|
||||
}
|
||||
} else {
|
||||
ForEach(this.eventsOf(g.group), (e: StageEvent) => {
|
||||
Row({ space: 3 }) {
|
||||
Text(e.short)
|
||||
.fontSize(9)
|
||||
.fontColor(e.kind === 'output' ? this.palette().accent
|
||||
: (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (e.count > 1) {
|
||||
Text('x' + e.count.toString())
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}, (e: StageEvent, i: number) => i.toString() + ':' + e.short)
|
||||
|
||||
if (this.eventsOf(g.group).length === 0) {
|
||||
Text('无')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(active ? this.palette().accentBg : this.palette().bgHover)
|
||||
.border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
/** 中断队列的一格 */
|
||||
@Builder
|
||||
queueCell(q: RuntimeQueue) {
|
||||
Column({ space: 5 }) {
|
||||
Row({ space: 4 }) {
|
||||
Text(q.name)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(lvColor(q.lv))
|
||||
.maxLines(1)
|
||||
Text(q.desc)
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
|
||||
Text(q.depth.toString())
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
|
||||
// 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0,
|
||||
// 整格只剩文字,看上去就是「这块空着」。
|
||||
Row({ space: 2 }) {
|
||||
ForEach(this.slotIdx, (i: number) => {
|
||||
Row()
|
||||
.layoutWeight(1)
|
||||
.height(11)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover)
|
||||
}, (i: number) => q.lv.toString() + '-' + i.toString())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (q.lv > 0) {
|
||||
Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
} else {
|
||||
Text('无级别')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: 1,
|
||||
color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder,
|
||||
style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid,
|
||||
})
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text('运行态')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ bottom: 8 })
|
||||
|
||||
if (this.snap === undefined) {
|
||||
Text('运行态数据不可用')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
} else {
|
||||
// 四个数字块
|
||||
Row({ space: 8 }) {
|
||||
this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0)
|
||||
this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0)
|
||||
this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(),
|
||||
this.snap.stack > 0)
|
||||
this.tile('子代理', this.snap.subagents.toString(), false)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 阶段管道:等大表框,事件落在所属阶段那一格
|
||||
Text('阶段管道')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ top: 14, bottom: 6 })
|
||||
if (this.isWide) {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS, (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'w' + g.group.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
} else {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'n0' + g.group.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'n1' + g.group.toString())
|
||||
// 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐
|
||||
Row().layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
|
||||
// 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)
|
||||
Text('队列')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ top: 14, bottom: 6 })
|
||||
if (this.isWide) {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues, (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'wq' + q.lv.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
} else {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'nq0' + q.lv.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'nq1' + q.lv.toString())
|
||||
Row().layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding(16)
|
||||
.margin({ bottom: 14 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop pushedText: string = '';
|
||||
@Prop countdown: number = 0;
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
Row({ space: 8 }) {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
if (this.countdown > 0) {
|
||||
Text(this.countdown.toString() + 's')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
} else {
|
||||
Text('常驻')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
|
||||
PageTopBar({ title: '推送内容' })
|
||||
|
||||
Row() {
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('关闭')
|
||||
.height(42)
|
||||
.padding({ left: 22, right: 22 })
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.borderRadius(21)
|
||||
.onClick(() => {
|
||||
this.onClose();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, bottom: 22 })
|
||||
.justifyContent(FlexAlign.End)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 18 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().bgPrimary)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -1,215 +0,0 @@
|
||||
/**
|
||||
* 单个配置项卡片。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:按 type 分发控件(bool/select/password/text/其他),
|
||||
* 值的保存与「未保存」标记交回页面(页面上持有 valuesStore 与 entries)。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
|
||||
import { SettingEntry } from '../common/SettingsModel';
|
||||
|
||||
@Component
|
||||
export struct SettingsEntryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop entry: SettingEntry;
|
||||
/** bool 开关 / select 选项:值已知,直接落库 */
|
||||
onSaveValue?: (key: string, value: string) => void;
|
||||
/** 输入类控件的保存:由页面取该 key 的最新编辑值再落库 */
|
||||
onSaveCurrent?: (key: string) => void;
|
||||
/** 输入框内容变化:只更新本地标记,不请求 */
|
||||
onEdit?: (key: string, value: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private saveValue(value: string): void {
|
||||
const cb: ((key: string, value: string) => void) | undefined = this.onSaveValue;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private saveCurrent(): void {
|
||||
const cb: ((key: string) => void) | undefined = this.onSaveCurrent;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
private editValue(value: string): void {
|
||||
const cb: ((key: string, value: string) => void) | undefined = this.onEdit;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(this.entry.displayName)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text(this.entry.dirty ? '未保存' : this.entry.type)
|
||||
.fontSize(10)
|
||||
.fontColor(this.entry.dirty ? '#D99A2B' : this.palette().textMuted)
|
||||
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.entry.key)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 1 })
|
||||
|
||||
if (this.entry.description.length > 0) {
|
||||
Text(this.entry.description)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 3 })
|
||||
}
|
||||
|
||||
// control row per type
|
||||
if (this.entry.type === 'bool') {
|
||||
Row() {
|
||||
Text(this.entry.value === 'true' ? 'true' : 'false')
|
||||
.fontSize(12)
|
||||
.fontColor(this.entry.value === 'true' ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.saveValue(on ? 'true' : 'false');
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'select' && this.entry.options.length > 0) {
|
||||
Flex({
|
||||
direction: FlexDirection.Row,
|
||||
justifyContent: FlexAlign.Start,
|
||||
alignItems: ItemAlign.Center,
|
||||
wrap: FlexWrap.Wrap,
|
||||
}) {
|
||||
ForEach(this.entry.options, (opt: string) => {
|
||||
Button(opt)
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(this.entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(this.entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.saveValue(opt);
|
||||
})
|
||||
}, (opt: string) => opt)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'password') {
|
||||
Row() {
|
||||
TextInput({ text: this.entry.value })
|
||||
.height(36)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.type(InputType.Password)
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'text') {
|
||||
TextArea({ text: this.entry.value })
|
||||
.width('100%')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.constraintSize({ minHeight: 60, maxHeight: 200 })
|
||||
.margin({ top: 8 })
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Row() {
|
||||
Blank()
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else {
|
||||
// string / int / duration
|
||||
Row() {
|
||||
TextInput({ text: this.entry.value })
|
||||
.height(36)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.entry.dirty ? '#D99A2B' : this.palette().glassBorder,
|
||||
})
|
||||
.margin({ bottom: 10 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:页面本身只剩数据流与导航表,
|
||||
* 这一层是纯布局 —— 所有计数/文案都由页面算好传进来。
|
||||
*/
|
||||
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow } from './PageTopBar';
|
||||
import { SettingsRootEntries } from './SettingsRootEntries';
|
||||
import { ToastBar } from './ToastBar';
|
||||
|
||||
@Component
|
||||
export struct SettingsHome {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 一级入口列表所需的计数/文案(见 SettingsRootEntries) */
|
||||
@Prop connCount: number = 0;
|
||||
@Prop connName: string = '';
|
||||
@Prop sectionCount: number = 0;
|
||||
@Prop coreKeys: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
@Prop themeLabel: string = '';
|
||||
@Prop activeSub: string = '';
|
||||
@Prop isWide: boolean = false;
|
||||
/** 保存进行中:悬浮区显示转圈 */
|
||||
@Prop savingCount: number = 0;
|
||||
@Prop toastMsg: string = '';
|
||||
@Prop toastIsError: boolean = false;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
SettingsRootEntries({
|
||||
connCount: this.connCount,
|
||||
connName: this.connName,
|
||||
sectionCount: this.sectionCount,
|
||||
coreKeys: this.coreKeys,
|
||||
busy: this.busy,
|
||||
errorText: this.errorText,
|
||||
themeLabel: this.themeLabel,
|
||||
activeSub: this.activeSub,
|
||||
isWide: this.isWide,
|
||||
onOpen: (id: string) => {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
PageTopBar({ title: '设置' })
|
||||
|
||||
// 一级悬浮区:仅在保存进行中显示一个转圈;
|
||||
// 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌)
|
||||
NavFloatOverlay({ tab: 3 }) {
|
||||
NavFloatRow() {
|
||||
if (this.savingCount > 0) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 设置一级页的入口列表。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:纯展示 + 跳转回调,
|
||||
* 所有计数/文案由页面算好传进来(页面才是这些状态的持有者)。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { SUB_STATUS, SUB_CONNECTIONS, SUB_BACKEND, SUB_SECTION, SUB_APPEARANCE } from '../common/SettingsModel';
|
||||
import { StatusSummaryCard } from './StatusCards';
|
||||
import { NavGroup, NavRow } from './SubPage';
|
||||
|
||||
@Component
|
||||
export struct SettingsRootEntries {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 连接配置条数 */
|
||||
@Prop connCount: number = 0;
|
||||
/** 当前生效连接的展示名 */
|
||||
@Prop connName: string = '';
|
||||
/** 后端配置:分类数 / 核心项数 / 是否加载中 / 错误文案 */
|
||||
@Prop sectionCount: number = 0;
|
||||
@Prop coreKeys: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
/** 当前主题的中文名(一级行右侧摘要值) */
|
||||
@Prop themeLabel: string = '';
|
||||
/** 宽屏分栏时用来高亮右栏对应的入口行 */
|
||||
@Prop activeSub: string = '';
|
||||
@Prop isWide: boolean = false;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private open(id: string): void {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页
|
||||
StatusSummaryCard({
|
||||
onTap: () => {
|
||||
this.open(SUB_STATUS);
|
||||
},
|
||||
})
|
||||
|
||||
NavGroup({ caption: '连接' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_link'),
|
||||
title: '后端连接',
|
||||
subtitle: this.connCount.toString() + ' 个连接配置',
|
||||
value: this.connName,
|
||||
selected: this.isWide && this.activeSub === SUB_CONNECTIONS,
|
||||
onTap: () => {
|
||||
this.open(SUB_CONNECTIONS);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_tune'),
|
||||
title: '核心配置',
|
||||
subtitle: this.sectionCount.toString() + ' 个分类 · ' + this.coreKeys.toString() + ' 项',
|
||||
value: this.busy ? '加载中' : (this.errorText.length > 0 ? '不可用' : ''),
|
||||
showDivider: false,
|
||||
selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION),
|
||||
onTap: () => {
|
||||
this.open(SUB_BACKEND);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '个性化' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_theme'),
|
||||
title: '外观',
|
||||
subtitle: '主题与背景图',
|
||||
value: this.themeLabel,
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_APPEARANCE,
|
||||
onTap: () => {
|
||||
this.open(SUB_APPEARANCE);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (this.errorText.length > 0) {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,285 @@
|
||||
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
|
||||
* Uses Span children inside Text for inline bold/italic/code/link formatting.
|
||||
*
|
||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
||||
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MdBlock {
|
||||
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
|
||||
level?: number;
|
||||
items?: string[];
|
||||
text?: string;
|
||||
lang?: string;
|
||||
codeLines?: string[];
|
||||
headers?: string[];
|
||||
rows?: string[][];
|
||||
}
|
||||
|
||||
export interface MdSpan {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
link?: boolean;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
// ── Inline parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseInline(text: string): MdSpan[] {
|
||||
const spans: MdSpan[] = [];
|
||||
let i: number = 0;
|
||||
while (i < text.length) {
|
||||
// Inline code (backtick)
|
||||
if (text[i] === '`') {
|
||||
const end: number = text.indexOf('`', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), code: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **text**
|
||||
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
|
||||
const end: number = text.indexOf('**', i + 2);
|
||||
if (end > i + 1) {
|
||||
spans.push({ text: text.substring(i + 2, end), bold: true });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *text* (single asterisk)
|
||||
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
|
||||
const end: number = text.indexOf('*', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), italic: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Link: [text](url)
|
||||
if (text[i] === '[') {
|
||||
const cb: number = text.indexOf(']', i + 1);
|
||||
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
|
||||
const cp: number = text.indexOf(')', cb + 2);
|
||||
if (cp > cb + 1) {
|
||||
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
|
||||
i = cp + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plain run
|
||||
let j: number = i + 1;
|
||||
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
|
||||
j++;
|
||||
}
|
||||
spans.push({ text: text.substring(i, j) });
|
||||
i = j;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ── Block parser helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHr(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const ch: string = line[0];
|
||||
if (ch !== '-' && ch !== '*' && ch !== '_') {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
if (line[k] !== ch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOlStart(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let k: number = 0;
|
||||
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
|
||||
k++;
|
||||
}
|
||||
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
|
||||
}
|
||||
|
||||
function isTableSep(line: string): boolean {
|
||||
if (!line.includes('-')) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
const c: string = line[k];
|
||||
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Block parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseBlocks(content: string): MdBlock[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = content.split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let i: number = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line: string = lines[i];
|
||||
|
||||
// Empty line
|
||||
if (line.trim().length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code fence
|
||||
if (line.startsWith('```')) {
|
||||
const langEnd: number = line.indexOf('`', 3);
|
||||
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) {
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (line.startsWith('#')) {
|
||||
let level: number = 0;
|
||||
while (level < line.length && line[level] === '#') {
|
||||
level++;
|
||||
}
|
||||
if (level <= 6 && level < line.length && line[level] === ' ') {
|
||||
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (isHr(line.trim())) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
|
||||
items.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'list', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (isOlStart(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && isOlStart(lines[i])) {
|
||||
const dotIdx: number = lines[i].indexOf('. ');
|
||||
items.push(lines[i].substring(dotIdx + 2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'ol', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const qLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
qLines.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
|
||||
const tLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||
tLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (tLines.length >= 2) {
|
||||
const parseRow = (row: string): string[] => {
|
||||
const cells: string[] = [];
|
||||
const parts: string[] = row.split('|');
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
const c: string = parts[p].trim();
|
||||
if (c.length > 0) {
|
||||
cells.push(c);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
};
|
||||
const headers: string[] = parseRow(tLines[0]);
|
||||
const rows: string[][] = [];
|
||||
for (let k = 1; k < tLines.length; k++) {
|
||||
if (!isTableSep(tLines[k].trim())) {
|
||||
rows.push(parseRow(tLines[k]));
|
||||
}
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
blocks.push({ type: 'table', headers: headers, rows: rows });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect consecutive non-special lines
|
||||
{
|
||||
const paraLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const ln: string = lines[i];
|
||||
if (ln.trim().length === 0) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
|
||||
break;
|
||||
}
|
||||
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(ln);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'para', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Component
|
||||
export struct StaticMarkdownView {
|
||||
|
||||
@ -2,10 +2,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { RuntimePanel } from './RuntimePanel';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
} from '../common/StatusStore';
|
||||
|
||||
/**
|
||||
@ -20,8 +19,6 @@ export struct StatusSummaryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_VERSION) private version: string = '-';
|
||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@ -133,24 +130,14 @@ export struct StatusSummaryCard {
|
||||
Text('版本')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Column() {
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
|
||||
Text(this.buildSub)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
@ -284,11 +271,6 @@ export struct StatusDetailContent {
|
||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
||||
// 后者是进这个页面最先想看的。
|
||||
RuntimePanel()
|
||||
|
||||
ForEach(this.groups, (g: StatGroup) => {
|
||||
Column() {
|
||||
Text(g.title)
|
||||
|
||||
@ -13,11 +13,7 @@ import { GradientBackground } from './GradientBackground';
|
||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||
|
||||
export function markSubPageOpen(open: boolean): void {
|
||||
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
|
||||
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
} else {
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
}
|
||||
|
||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 右下角轻提示条(设置页与插件页共用)。
|
||||
*
|
||||
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
|
||||
* 用 bordered 表达这个差异(width 0 的边框不占位、不可见)。
|
||||
*
|
||||
* 配色由调用方决定:颜色标志必须在调用方落 animateTo 之前先写好,
|
||||
* 否则第一帧会用上一条 toast 的配色。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, ANIM_NORMAL } from '../common/Constants';
|
||||
|
||||
@Component
|
||||
export struct ToastBar {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop msg: string = '';
|
||||
@Prop isError: boolean = false;
|
||||
/** 是否带一圈语义色描边(插件页用,设置页不用) */
|
||||
@Prop bordered: boolean = false;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
if (this.msg.length > 0) {
|
||||
Row() {
|
||||
Text(this.msg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.isError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.isError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
.border({
|
||||
width: this.bordered ? 1 : 0,
|
||||
color: this.isError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
||||
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||
function storedThemeMode(): string {
|
||||
@ -32,7 +31,6 @@ export default class EntryAbility extends UIAbility {
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onDestroy');
|
||||
}
|
||||
|
||||
@ -88,7 +86,6 @@ export default class EntryAbility extends UIAbility {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.reapplyStoredTheme();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch(() => {
|
||||
startUI();
|
||||
@ -110,7 +107,6 @@ export default class EntryAbility extends UIAbility {
|
||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||
this.reapplyStoredTheme();
|
||||
this.applySystemBar();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch((e: Error) => {
|
||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||
@ -133,12 +129,10 @@ export default class EntryAbility extends UIAbility {
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
startForegroundBridge(this.context);
|
||||
console.info('[HomeAgent] ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onBackground');
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,28 +1,35 @@
|
||||
import { deviceBridge } from '../common/DeviceBridge';
|
||||
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
import { DeviceRootEntries } from '../components/DeviceRootEntries';
|
||||
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
|
||||
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
|
||||
const LOCAL_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
/** 二级页面标识 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_LOCAL: string = 'local';
|
||||
const SUB_CAPS: string = 'caps';
|
||||
const SUB_GATEWAY: string = 'gateway';
|
||||
const SUB_LIST: string = 'list';
|
||||
|
||||
/**
|
||||
* 设备页:只做"页面壳"。
|
||||
*
|
||||
* 拆分后的分工(拆分前这里是 560 行的单文件):
|
||||
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
|
||||
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
|
||||
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那)
|
||||
* 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
|
||||
*/
|
||||
@Component
|
||||
export struct DevicePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -32,28 +39,56 @@ export struct DevicePage {
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@State activeSub: string = SUB_NONE;
|
||||
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
|
||||
@State bridgeConnected: boolean = false;
|
||||
@State bridgeUrl: string = '';
|
||||
@State bridgeToken: string = '';
|
||||
@State deviceId: string = '';
|
||||
@State authorized: boolean = false;
|
||||
@State devices: DeviceInfo[] = [];
|
||||
@State loadingDevices: boolean = false;
|
||||
@State lastError: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
private autoConnectTried: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
||||
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
|
||||
this.deviceId = deviceBridge.getDeviceId();
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = connStore.getDeviceId();
|
||||
}
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.authorized = connStore.getDeviceAuth();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
this.bridgeUrl = deviceGatewayUrl(cur.url);
|
||||
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
||||
this.bridgeToken = cur.apiKey;
|
||||
}
|
||||
installCmdRouter();
|
||||
try {
|
||||
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
|
||||
} catch (e) {
|
||||
// ignore context errors
|
||||
}
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.bridgeConnected = open;
|
||||
});
|
||||
if (open) {
|
||||
this.lastError = '';
|
||||
this.showToast('设备网关已连接', false);
|
||||
this.refreshDevices();
|
||||
}
|
||||
});
|
||||
this.refreshDevices();
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(2, this.navStack, () => {
|
||||
@ -69,6 +104,37 @@ export struct DevicePage {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
|
||||
*
|
||||
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
|
||||
* 直接把 http:// 传进 connect() 会在 native 层报
|
||||
* "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp),
|
||||
* 表现为设备通道永远连不上。所以这里必须做协议替换。
|
||||
*/
|
||||
private gatewayUrlOf(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开二级页面。
|
||||
*
|
||||
@ -104,9 +170,45 @@ export struct DevicePage {
|
||||
// ignore persist failure
|
||||
}
|
||||
deviceBridge.updateAuthorized(on);
|
||||
if (!this.bridgeConnected) {
|
||||
this.connectBridge();
|
||||
}
|
||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||
}
|
||||
|
||||
// ===================== gateway connection =====================
|
||||
|
||||
private async connectBridge(): Promise<void> {
|
||||
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
|
||||
this.lastError = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
this.lastError = '';
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
}
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
const name: string = 'HomeAgent OHOS';
|
||||
await deviceBridge.connect(
|
||||
this.bridgeUrl, this.bridgeToken, this.deviceId,
|
||||
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
|
||||
}
|
||||
|
||||
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
|
||||
private maybeAutoConnect(): void {
|
||||
if (this.autoConnectTried || this.bridgeConnected) {
|
||||
return;
|
||||
}
|
||||
this.autoConnectTried = true;
|
||||
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
|
||||
this.connectBridge();
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
@ -129,7 +231,31 @@ export struct DevicePage {
|
||||
const resp = await apiClient.get('/device/online');
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const list: DeviceInfo[] = parseOnlineDevices(parsed);
|
||||
const devs: Object = parsed['devices'];
|
||||
const list: DeviceInfo[] = [];
|
||||
if (devs !== undefined && devs !== null) {
|
||||
const arr: Object[] = devs as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const capsArr: Object = d['caps'];
|
||||
const caps: string[] = [];
|
||||
if (capsArr !== undefined && capsArr !== null) {
|
||||
const cArr: Object[] = capsArr as Object[];
|
||||
for (let j = 0; j < cArr.length; j++) {
|
||||
caps.push(cArr[j] as string);
|
||||
}
|
||||
}
|
||||
const info: DeviceInfo = {
|
||||
deviceId: d['device_id'] as string ?? '',
|
||||
name: d['name'] as string ?? '',
|
||||
kind: d['kind'] as string ?? '',
|
||||
online: true,
|
||||
authorized: d['authorized'] as boolean ?? false,
|
||||
caps: caps,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
}
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.devices = list;
|
||||
});
|
||||
@ -149,17 +275,7 @@ export struct DevicePage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
DeviceRootEntries({
|
||||
activeSub: this.activeSub,
|
||||
deviceId: this.deviceId,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
loadingDevices: this.loadingDevices,
|
||||
deviceCount: this.devices.length,
|
||||
onOpen: (id: string) => {
|
||||
this.openSub(id);
|
||||
},
|
||||
})
|
||||
this.RootEntries()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -171,6 +287,9 @@ export struct DevicePage {
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
.onAppear(() => {
|
||||
this.maybeAutoConnect();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@ -230,58 +349,61 @@ export struct DevicePage {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面路由表。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
||||
*/
|
||||
/** 二级页面路由表 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
DeviceLocalPane({
|
||||
deviceId: this.deviceId,
|
||||
authorized: this.authorized,
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onToggleAuth: (on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.LocalDeviceContent()
|
||||
}
|
||||
} else if (name === SUB_CAPS) {
|
||||
DeviceCapsPane({
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.CapsContent()
|
||||
}
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
DeviceGatewayPane({
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
bridgeToken: this.bridgeToken,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.GatewayContent()
|
||||
}
|
||||
} else if (name === SUB_LIST) {
|
||||
DeviceListPane({
|
||||
devices: this.devices,
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.OnlineDevicesContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
@ -302,6 +424,269 @@ export struct DevicePage {
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 一级入口列表 =====================
|
||||
|
||||
@Builder
|
||||
RootEntries() {
|
||||
NavGroup({ caption: '本机' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_phone'),
|
||||
title: '本机设备',
|
||||
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
|
||||
value: this.bridgeConnected ? '在线' : '离线',
|
||||
selected: this.isWide && this.activeSub === SUB_LOCAL,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LOCAL);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_bolt'),
|
||||
title: '设备能力',
|
||||
subtitle: this.capsCount().toString() + ' 项能力',
|
||||
value: '',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_CAPS,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_CAPS);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '通道' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_gateway'),
|
||||
title: '设备通道',
|
||||
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
|
||||
value: this.bridgeConnected ? '已连接' : '未连接',
|
||||
selected: this.isWide && this.activeSub === SUB_GATEWAY,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_GATEWAY);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_devices_multi'),
|
||||
title: '接入的设备',
|
||||
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
|
||||
value: this.devices.length.toString() + ' 台',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_LIST,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LIST);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_CAPS.length;
|
||||
}
|
||||
|
||||
// ===================== 二级:本机设备 =====================
|
||||
|
||||
@Builder
|
||||
LocalDeviceContent() {
|
||||
PlainCard({ caption: '基本信息' }) {
|
||||
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
|
||||
this.KvRow('名称', 'HomeAgent OHOS')
|
||||
this.KvRow('类型', 'phone')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '权限控制' }) {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text('允许 agent 控制本机')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.authorized ? '#17A964' : '#E84026')
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.margin({ right: 8 })
|
||||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||||
.fontSize(12)
|
||||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备能力 =====================
|
||||
|
||||
@Builder
|
||||
CapsContent() {
|
||||
PlainCard({ caption: '能力清单' }) {
|
||||
Text('agent 通过设备桥可调用的本机能力:')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_CAPS, (cap: string) => {
|
||||
Text(cap)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(999)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
}, (cap: string) => cap)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备通道 =====================
|
||||
|
||||
@Builder
|
||||
GatewayContent() {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
|
||||
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
|
||||
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
||||
}
|
||||
|
||||
if (this.lastError.length > 0) {
|
||||
Text(this.lastError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -8 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
||||
// 因为父容器的 .animation() 到不了子节点。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:接入的设备 =====================
|
||||
|
||||
@Builder
|
||||
OnlineDevicesContent() {
|
||||
if (this.devices.length === 0) {
|
||||
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||||
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
|
||||
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
|
||||
Column() {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
|
||||
}
|
||||
|
||||
// ===================== 通用 KV 行 =====================
|
||||
|
||||
@Builder
|
||||
KvRow(k: string, v: string) {
|
||||
Row() {
|
||||
Text(k)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
Text(v)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
|
||||
const RADIUS_MD: number = 10;
|
||||
|
||||
@ -11,9 +11,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR
|
||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { ScreensuePage } from '../components/ScreensuePage';
|
||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
@ -112,14 +110,14 @@ struct Index {
|
||||
this.syncSystemBar();
|
||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||
this.resolveSafeArea();
|
||||
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
|
||||
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
||||
installCmdRouter();
|
||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||
this.showScreensue(payload);
|
||||
});
|
||||
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
|
||||
}
|
||||
|
||||
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */
|
||||
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */
|
||||
private showScreensue(payload: ScreensuePayload): void {
|
||||
this.screensueText = payload.content;
|
||||
this.screensueCountdown = payload.duration;
|
||||
@ -220,7 +218,7 @@ struct Index {
|
||||
private updateWideScreen(w: number): void {
|
||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||
if (wide !== this.isWide) {
|
||||
AppStorage.set<boolean>('isWideScreen', wide);
|
||||
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,10 +230,6 @@ struct Index {
|
||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||
*/
|
||||
onBackPress(): boolean {
|
||||
if (this.screensueVisible) {
|
||||
this.closeScreensue();
|
||||
return true;
|
||||
}
|
||||
return handleBackPress(this.currentTab, this.isWide);
|
||||
}
|
||||
|
||||
@ -345,42 +339,51 @@ struct Index {
|
||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
|
||||
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
|
||||
// 左侧一级页面与主导航保持可见、可操作。
|
||||
// screensue 悬浮层:agent 推送给用户看的内容(置顶展示)
|
||||
if (this.screensueVisible) {
|
||||
if (this.isWide) {
|
||||
Column() {
|
||||
Row() {
|
||||
Column()
|
||||
.width(WIDE_NAV_BAR_WIDTH)
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ left: 8 })
|
||||
Blank()
|
||||
if (this.screensueCountdown > 0) {
|
||||
Text(this.screensueCountdown.toString() + 's')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
Text('关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
this.closeScreensue();
|
||||
},
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
} else {
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
this.closeScreensue();
|
||||
},
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.screensueText)
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
}
|
||||
.constraintSize({ maxHeight: 320 })
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
}
|
||||
.width('86%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
@ -1,26 +1,24 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
||||
import { PluginListView } from '../components/PluginListView';
|
||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
||||
import { ToastBar } from '../components/ToastBar';
|
||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { SettingsEditor } from '../components/SettingsEditor';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -60,14 +58,134 @@ export struct PluginsPage {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
|
||||
/**
|
||||
* 数据源对齐 WebGUI renderPlugins:
|
||||
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
private async loadPlugins(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const rows: PluginRow[] = await fetchPluginRows();
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||
const kpRaw: Object | undefined = kernelObj['plugins'];
|
||||
if (kpRaw !== undefined && kpRaw !== null) {
|
||||
const kpArr: Object[] = kpRaw as Object[];
|
||||
for (let i = 0; i < kpArr.length; i++) {
|
||||
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length === 0) {
|
||||
continue;
|
||||
}
|
||||
loadedMap.set(n, item['loaded'] as boolean ?? true);
|
||||
}
|
||||
}
|
||||
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||
const tRaw: Object | undefined = kernelObj['tools'];
|
||||
if (tRaw !== undefined && tRaw !== null) {
|
||||
const tArr: Object[] = tRaw as Object[];
|
||||
for (let i = 0; i < tArr.length; i++) {
|
||||
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
|
||||
const tn: string = item['name'] as string ?? '';
|
||||
const owner: string = item['plugin'] as string ?? '';
|
||||
if (tn.length === 0 || owner.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let list: string[] | undefined = toolsByPlugin.get(owner);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
toolsByPlugin.set(owner, list);
|
||||
}
|
||||
// 每插件最多展示 8 个工具名,避免卡片过长
|
||||
if (list.length < 8) {
|
||||
list.push(tn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- installed external plugins metadata ----
|
||||
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
|
||||
try {
|
||||
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
|
||||
const bodyTrim = pResp.body.trim();
|
||||
let arr: Object[] = [];
|
||||
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
|
||||
arr = JSON.parse(pResp.body) as Object[];
|
||||
} else {
|
||||
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
|
||||
const rawList: Object = obj['plugins'] ?? obj['data'];
|
||||
if (rawList !== undefined && rawList !== null) {
|
||||
arr = rawList as Object[];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length > 0) {
|
||||
externalMeta.set(n, item);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 外部列表失败不阻塞内置展示
|
||||
}
|
||||
|
||||
// ---- disabled list ----
|
||||
const disabledNames: Set<string> = new Set<string>();
|
||||
try {
|
||||
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
|
||||
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
|
||||
const dArr: Object | undefined = dObj['disabled'];
|
||||
if (dArr !== undefined && dArr !== null) {
|
||||
const items: Object[] = dArr as Object[];
|
||||
for (let di = 0; di < items.length; di++) {
|
||||
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
|
||||
const dn: string = dItem['name'] as string ?? '';
|
||||
if (dn.length > 0) {
|
||||
disabledNames.add(dn);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// disabled endpoint may not exist; ignore
|
||||
}
|
||||
|
||||
// ---- merge: allNames sorted(与 GUI 一致)----
|
||||
const allNames: Set<string> = new Set<string>();
|
||||
loadedMap.forEach((v: boolean, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
externalMeta.forEach((v: Record<string, Object>, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
disabledNames.forEach((n: string) => {
|
||||
allNames.add(n);
|
||||
});
|
||||
const names: string[] = Array.from(allNames);
|
||||
names.sort();
|
||||
|
||||
const rows: PluginRow[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name: string = names[i];
|
||||
const meta: Record<string, Object> | undefined = externalMeta.get(name);
|
||||
const tools: string[] | undefined = toolsByPlugin.get(name);
|
||||
const row: PluginRow = {
|
||||
name: name,
|
||||
loaded: loadedMap.get(name) ?? false,
|
||||
disabled: disabledNames.has(name),
|
||||
external: externalMeta.has(name),
|
||||
version: meta !== undefined ? meta['version'] as string ?? '' : '',
|
||||
description: meta !== undefined ? meta['description'] as string ?? '' : '',
|
||||
tools: tools,
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
@ -81,6 +199,20 @@ export struct PluginsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
|
||||
private statusOf(plugin: PluginRow): string {
|
||||
if (plugin.loaded && !plugin.disabled) {
|
||||
return 'loaded'; // 已加载
|
||||
}
|
||||
if (plugin.loaded && plugin.disabled) {
|
||||
return 'pending'; // 运行中(禁用待生效)
|
||||
}
|
||||
if (plugin.disabled) {
|
||||
return 'disabled'; // 已禁用
|
||||
}
|
||||
return 'notloaded'; // 未加载
|
||||
}
|
||||
|
||||
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||
@ -148,14 +280,29 @@ export struct PluginsPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情数据落地。
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||
*/
|
||||
private async loadDetail(name: string): Promise<void> {
|
||||
this.detailLoading = true;
|
||||
this.detailError = '';
|
||||
try {
|
||||
const d: PluginDetail = await fetchPluginDetail(name);
|
||||
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
|
||||
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = o['name'] as string ?? name;
|
||||
d.version = o['version'] as string ?? '';
|
||||
d.description = o['description'] as string ?? '';
|
||||
d.author = o['author'] as string ?? '';
|
||||
d.license = o['license'] as string ?? '';
|
||||
d.homepage = o['homepage'] as string ?? '';
|
||||
d.repository = o['repository'] as string ?? '';
|
||||
d.entry = o['entry'] as string ?? '';
|
||||
d.minVersion = o['min_version'] as string ?? '';
|
||||
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||
d.tags = this.strArray(o['tags']);
|
||||
d.files = this.strArray(o['files']);
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
@ -180,6 +327,21 @@ export struct PluginsPage {
|
||||
});
|
||||
}
|
||||
|
||||
private strArray(raw: Object | undefined): string[] {
|
||||
const out: string[] = [];
|
||||
if (raw === undefined || raw === null) {
|
||||
return out;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const s: string = arr[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private findRow(name: string): PluginRow | null {
|
||||
for (let i = 0; i < this.plugins.length; i++) {
|
||||
if (this.plugins[i].name === name) {
|
||||
@ -225,15 +387,8 @@ export struct PluginsPage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
PluginListView({
|
||||
plugins: this.plugins,
|
||||
busy: this.loading,
|
||||
activeName: this.activeName,
|
||||
hasConn: apiClient.hasConnection(),
|
||||
onSelect: (name: string) => {
|
||||
this.openDetail(name);
|
||||
},
|
||||
})
|
||||
this.ListStates()
|
||||
this.PluginList()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -256,15 +411,7 @@ export struct PluginsPage {
|
||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||
NavFloatOverlay({ tab: 1 }) {
|
||||
if (this.showInstallForm) {
|
||||
PluginInstallForm({
|
||||
url: this.installUrl,
|
||||
onUrlChange: (v: string) => {
|
||||
this.installUrl = v;
|
||||
},
|
||||
onInstall: () => {
|
||||
this.installPlugin();
|
||||
},
|
||||
})
|
||||
this.InstallForm()
|
||||
}
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
@ -294,7 +441,7 @@ export struct PluginsPage {
|
||||
}
|
||||
}
|
||||
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
||||
this.Toast()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@ -346,29 +493,503 @@ export struct PluginsPage {
|
||||
this.loadDetail(this.activeName);
|
||||
},
|
||||
}) {
|
||||
PluginDetailPane({
|
||||
detail: this.detail,
|
||||
activeName: this.activeName,
|
||||
busy: this.detailLoading,
|
||||
errorText: this.detailError,
|
||||
row: this.activeRow() ?? undefined,
|
||||
onToggle: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
},
|
||||
onRemove: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
},
|
||||
})
|
||||
this.DetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
|
||||
@Builder
|
||||
InstallForm() {
|
||||
Row() {
|
||||
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
|
||||
.layoutWeight(1)
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.onChange((v: string) => {
|
||||
this.installUrl = v;
|
||||
})
|
||||
Button('安装')
|
||||
.height(36)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ left: 6 })
|
||||
.onClick(() => {
|
||||
this.installPlugin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.margin({ bottom: 10 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
/** 加载中 / 未配置 / 空列表三种占位态 */
|
||||
@Builder
|
||||
ListStates() {
|
||||
if (this.loading && this.plugins.length === 0) {
|
||||
LoadingProgress()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.color(this.palette().accent)
|
||||
.margin({ top: 40 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!apiClient.hasConnection()) {
|
||||
Text(noConnectionMessage())
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
|
||||
Text('暂无已加载插件')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
|
||||
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
|
||||
*/
|
||||
@Builder
|
||||
PluginList() {
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
// 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
|
||||
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
|
||||
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
|
||||
Column() {
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
if (plugin.version !== undefined && plugin.version.length > 0) {
|
||||
Text('v' + plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(this.statusDotColor(plugin))
|
||||
Text(this.rowSubtitle(plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.activeName === plugin.name
|
||||
? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(plugin.name);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
// ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面:插件详情。
|
||||
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
|
||||
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
|
||||
*/
|
||||
@Builder
|
||||
DetailContent() {
|
||||
if (this.detailLoading) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 30, bottom: 30 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (this.detailError.length > 0) {
|
||||
Text(this.detailError)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.padding({ left: 4, bottom: 12 })
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 概览卡:名称、版本、状态徽标、描述
|
||||
PlainCard({ caption: '概览' }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.detail.version.length > 0) {
|
||||
Text('v' + this.detail.version)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
this.DetailBadges()
|
||||
|
||||
if (this.detail.description.length > 0) {
|
||||
Text(this.detail.description)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
|
||||
if (this.hasManifest()) {
|
||||
PlainCard({ caption: '清单' }) {
|
||||
this.KvRow('作者', this.detail.author)
|
||||
this.KvRow('许可证', this.detail.license)
|
||||
this.KvRow('主页', this.detail.homepage)
|
||||
this.KvRow('仓库', this.detail.repository)
|
||||
this.KvRow('入口', this.detail.entry)
|
||||
this.KvRow('最低内核版本', this.detail.minVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.tags.length > 0) {
|
||||
PlainCard({ caption: '标签' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.tags, (t: string) => {
|
||||
Text(t)
|
||||
.fontSize(10)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (t: string) => t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailTools()
|
||||
|
||||
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
|
||||
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
|
||||
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '插件配置' }) {
|
||||
SettingsEditor({
|
||||
prefix: 'plugin.' + this.activeName + '.',
|
||||
emptyHint: '该插件没有暴露可配置项',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.files.length > 0) {
|
||||
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
|
||||
ForEach(this.detail.files, (f: string) => {
|
||||
Text(f)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ bottom: 4 })
|
||||
}, (f: string) => f)
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailActions()
|
||||
}
|
||||
|
||||
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
|
||||
@Builder
|
||||
DetailBadges() {
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 7, height: 7 })
|
||||
.fill(this.activeStatusColor())
|
||||
Text(this.detailStatusLine())
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
private detailStatusLine(): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.activeStatusText());
|
||||
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
|
||||
if (this.detail.deprecated) {
|
||||
parts.push('已废弃');
|
||||
}
|
||||
const n: number = this.activeTools().length;
|
||||
if (n > 0) {
|
||||
parts.push(n.toString() + ' 个工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
|
||||
@Builder
|
||||
DetailTools() {
|
||||
if (this.activeTools().length > 0) {
|
||||
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.activeTools(), (tool: string) => {
|
||||
Text(tool)
|
||||
.fontSize(11)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (tool: string) => tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
DetailActions() {
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.activeIsDisabled() ? '启用' : '禁用')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeIsDisabled()
|
||||
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
|
||||
})
|
||||
.fontColor(this.activeIsDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
if (!this.activeIsBuiltin()) {
|
||||
Button('卸载')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor(COLOR_ERROR)
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 详情页取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
|
||||
|
||||
private hasManifest(): boolean {
|
||||
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
|
||||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
|
||||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
|
||||
}
|
||||
|
||||
private activeIsBuiltin(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? !r.external : false;
|
||||
}
|
||||
|
||||
private activeIsDisabled(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? r.disabled : false;
|
||||
}
|
||||
|
||||
private activeTools(): string[] {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r === null) {
|
||||
return [];
|
||||
}
|
||||
return r.tools ?? [];
|
||||
}
|
||||
|
||||
private activeStatusText(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeText(r) : '未加载';
|
||||
}
|
||||
|
||||
private activeStatusColor(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
|
||||
}
|
||||
|
||||
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
|
||||
@Builder
|
||||
KvRow(label: string, value: string) {
|
||||
if (value.length > 0) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
Text(value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 220 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
Row() {
|
||||
Text(this.toastMsg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 12 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
|
||||
private statusDotColor(plugin: PluginRow): string {
|
||||
return this.statusBadgeColor(plugin);
|
||||
}
|
||||
|
||||
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
|
||||
private rowSubtitle(plugin: PluginRow): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.statusBadgeText(plugin));
|
||||
parts.push(plugin.external ? '外部' : '内置');
|
||||
if (plugin.tools !== undefined && plugin.tools.length > 0) {
|
||||
parts.push(plugin.tools.length.toString() + ' 工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
private statusBadgeText(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '待生效';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '已禁用';
|
||||
}
|
||||
return '未加载';
|
||||
}
|
||||
|
||||
private statusBadgeColor(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '#17A964';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '#D99A2B';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '#E84026';
|
||||
}
|
||||
return this.palette().textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,14 +17,6 @@
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.READ_PASTEBOARD",
|
||||
"reason": "$string:read_pasteboard_reason",
|
||||
"usedScene": {
|
||||
"abilities": ["EntryAbility"],
|
||||
"when": "inuse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
|
||||
@ -3,10 +3,6 @@
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
},
|
||||
{
|
||||
"name": "read_pasteboard_reason",
|
||||
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -9,56 +9,30 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
|
||||
```
|
||||
HomeAgent/
|
||||
├── AppScope/ 应用级配置与图标
|
||||
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
|
||||
├── entry/src/main/
|
||||
│ ├── ets/
|
||||
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ ├── UserError.ets 错误转人类可读文案
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
|
||||
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
|
||||
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
|
||||
│ │ │ ├── ChatSession.ets 发送/中断(POST /chat、/chat/file)
|
||||
│ │ │ ├── ChatHistory.ets 历史载荷与 tool_calls 解析
|
||||
│ │ │ ├── ChatFormat.ets ForEach 键、工具卡状态/配色、渠道判定
|
||||
│ │ │ ├── AttachmentMeta.ets 附件解析与格式化(纯函数)
|
||||
│ │ │ ├── AttachmentImage.ets 附件字节获取与解码(沙箱/远端)
|
||||
│ │ │ ├── DeviceBridge.ets 设备桥客户端(socket 生命周期与命令分发)
|
||||
│ │ │ ├── BridgeProtocol.ets 设备桥协议消息与帧构造
|
||||
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||
│ │ │ ├── DeviceBridgeSession.ets 前台桥生命周期、网关地址推导
|
||||
│ │ │ ├── DeviceModel.ets 设备页纯逻辑(device_id 兜底、在线设备解析)
|
||||
│ │ │ ├── PluginApi.ets 插件列表/详情接口
|
||||
│ │ │ ├── PluginStatus.ets 插件状态判定与配色
|
||||
│ │ │ ├── SettingsModel.ets 设置载荷解析、分类归并、分页、路由 id
|
||||
│ │ │ └── MarkdownParser.ets Markdown 解析(块/行内/表格)
|
||||
│ │ ├── common/ 通信与全局状态
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
|
||||
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||
│ │ │ ├── ConnStore.ets 连接配置持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ └── UserError.ets 错误转人类可读文案
|
||||
│ │ ├── components/ 可复用组件
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
|
||||
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
|
||||
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
|
||||
│ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
|
||||
│ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
|
||||
│ │ │ ├── ChatAttachBar.ets 加号菜单 + 待发送附件条
|
||||
│ │ │ ├── SettingsHome.ets / SettingsRootEntries.ets / SettingsEntryCard.ets 设置一级页
|
||||
│ │ │ ├── ConnectionsPane.ets / AppearancePane.ets / BackendSettingsPane.ets 设置二级页
|
||||
│ │ │ ├── PluginListView.ets / PluginDetailPane.ets / PluginsOverlays.ets 插件页
|
||||
│ │ │ ├── DeviceRootEntries.ets / DevicePanes.ets 设备页
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── ToastBar.ets 统一提示条(插件页与设置页共用)
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器 / NavGroup / NavRow / PlainCard
|
||||
│ │ │ ├── MotionBase.ets 统一按压反馈与入场动画
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器
|
||||
│ │ │ └── GradientBackground.ets
|
||||
│ │ ├── model/Model.ets 共享类型定义
|
||||
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
|
||||
│ │ ├── pages/ 页面
|
||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||
│ │ │ ├── ChatPage.ets 对话
|
||||
│ │ │ ├── DevicePage.ets 设备
|
||||
@ -71,30 +45,11 @@ HomeAgent/
|
||||
└── oh-package.json5 依赖
|
||||
```
|
||||
|
||||
约定:**单个 `.ets` 不超过 400 行**,页面只做页面壳(导航栈 + 数据编排),
|
||||
可复用结构进 `components/`,无 UI 的逻辑进 `common/`。
|
||||
|
||||
这条约定有一个边界,别用反了:
|
||||
|
||||
> **不到 400 行的文件不要为了拆分而拆分。** `@Component` 的 `build()` 只允许一个根节点,
|
||||
> 把原来多节点的 `@Builder` 改成组件时会多出一层 `Column` 包裹 —— 布局等价是**推理**出来的、
|
||||
> 不是看出来的,每拆一次都要付一次"未上机验证"的账。所以拆分只用来解决真实的可读性/维护性
|
||||
> 问题(超长文件、职责混杂),而不是凑行数。`pages/Index.ets` 目前 396 行就属于"不动"的一类:
|
||||
> 没越线,余量本身也是有用的缓冲;等它真越线了再拆,并且优先看是不是又长出了大 `@Builder`。
|
||||
|
||||
## 编译
|
||||
|
||||
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
|
||||
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
|
||||
|
||||
0. **前置条件:`oh_modules/` 必须存在**(`ohpm install` 的产物)。
|
||||
|
||||
它被 `.gitignore` 忽略,所以干净 clone 后没有;而 hvigor **不会**自动补齐它 ——
|
||||
实测把 `oh_modules/` 移走后构建不会触发 `ohpm install`,而是直接报一堆
|
||||
`arkts-no-untyped-obj-literals`(依赖类型声明缺失),且不会重建该目录。
|
||||
所以:clone 后先 `ohpm install`,之后别把这个目录当垃圾清掉。
|
||||
`entry/build/`、`.hvigor/` 是纯构建产物,可以随时删除(冷构建 ~8s)。
|
||||
|
||||
1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
|
||||
|
||||
```bash
|
||||
@ -109,27 +64,19 @@ HomeAgent/
|
||||
2. **构建 HAP**:
|
||||
|
||||
```bash
|
||||
cd cmd/ohos/HomeAgent
|
||||
# ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位,
|
||||
# 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。
|
||||
# 一律用 command-line-tools 里的绝对路径(本机为 /opt/huawei/command-line-tools/bin/hvigorw):
|
||||
/opt/huawei/command-line-tools/bin/hvigorw \
|
||||
assembleHap --mode module -p product=default --no-daemon
|
||||
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||
/path/to/command-line-tools/bin/hvigorw \
|
||||
--mode module -p module=entry@default assembleHap --no-daemon
|
||||
```
|
||||
|
||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||
|
||||
3. **安装到设备**(`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**,
|
||||
否则下面这个路径不存在):
|
||||
3. **安装到设备**:
|
||||
|
||||
```bash
|
||||
hdc install entry/build/default/outputs/default/entry-default-signed.hap
|
||||
```
|
||||
|
||||
路径里的目录名随构建模式而变:默认是 `default/`,若用 `-p product=<名字>` 则是该产品名。
|
||||
拿不准就先 `find entry/build -name '*.hap'` 找一下。同目录还有 `entry-default-unsigned.hap`,
|
||||
`hdc install` 要用带 `-signed` 的那个。
|
||||
|
||||
## 连接 homed
|
||||
|
||||
首次启动在「设置」里填:
|
||||
@ -150,19 +97,3 @@ HomeAgent/
|
||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
||||
|
||||
## 改动后的运行时验证清单
|
||||
|
||||
构建通过只能证明编译期没问题;ArkUI 的状态绑定、过渡动画与手势行为
|
||||
必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
|
||||
|
||||
- [ ] 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
|
||||
- [ ] 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
|
||||
- [ ] 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
|
||||
- [ ] 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
|
||||
- [ ] 进插件列表与插件详情,确认状态色、开关与卸载正常
|
||||
- [ ] 进出设备页四个二级页,确认授权开关与在线设备列表正常
|
||||
- [ ] 宽屏(>=600vp)下确认左右分栏、返回手势与返回键行为
|
||||
|
||||
模拟器在无图形/无提权环境里可能起不来(需要写 `~/.Huawei` 等宿主目录),
|
||||
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。
|
||||
|
||||
@ -39,7 +39,7 @@ type msgEntry struct {
|
||||
|
||||
type daemonHandler struct {
|
||||
// homed 连接
|
||||
homeMu sync.Mutex
|
||||
homeMu sync.Mutex
|
||||
homeConn net.Conn
|
||||
homeR *bufio.Reader
|
||||
homeCfg *Config
|
||||
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
|
||||
if dg == "" || dt == "" {
|
||||
return
|
||||
}
|
||||
// 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。
|
||||
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
|
||||
// 设备桥重连循环:WS 断开时自动重连
|
||||
go runDeviceBridgeLoop(dg, dt)
|
||||
}
|
||||
|
||||
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
||||
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
||||
func runDeviceBridgeLoop(gateway, token string) {
|
||||
for {
|
||||
bridge, err := connectDeviceBridge(gateway, token, authorized)
|
||||
bridge, err := connectDeviceBridge(gateway, token)
|
||||
if err != nil {
|
||||
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
||||
}
|
||||
|
||||
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
||||
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
|
||||
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "local"
|
||||
@ -355,17 +355,12 @@ func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge
|
||||
}
|
||||
|
||||
bridge := client.New(gw, token, deviceID, hostname, caps, info)
|
||||
bridge.SetAuthorized(authorized)
|
||||
|
||||
// 注册命令处理器:cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
|
||||
// 注册命令处理器
|
||||
cr := client.NewCmdRouter()
|
||||
cr.Handle("homeagent-", handleHomeagentCmd)
|
||||
cr.HandleDefault(handleShellCmd)
|
||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||
if cmdType == "homeagent" {
|
||||
handleHomeagentCmd(reqID, command)
|
||||
return
|
||||
}
|
||||
bridge.OnCmd(func(reqID, command string) {
|
||||
cr.Dispatch(reqID, command)
|
||||
})
|
||||
|
||||
@ -376,6 +371,8 @@ func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge
|
||||
// 设置全局变量供 sendBridgeResult 使用
|
||||
deviceBridge = bridge
|
||||
deviceBridgeID = deviceID
|
||||
auth := true // daemon 模式默认授权(配置已指定)
|
||||
bridge.SetAuthorized(auth)
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
@ -61,22 +61,13 @@ func startDeviceBridge(addr, token string) error {
|
||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||
cmdRouter = client.NewCmdRouter()
|
||||
|
||||
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
|
||||
// 注册命令处理器
|
||||
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
||||
cmdRouter.HandleDefault(handleShellCmd)
|
||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||
if cmdType == "homeagent" {
|
||||
handleHomeagentCmd(reqID, command)
|
||||
return
|
||||
}
|
||||
bridge.OnCmd(func(reqID, command string) {
|
||||
cmdRouter.Dispatch(reqID, command)
|
||||
})
|
||||
|
||||
// agent 主动投递(output_send__device/<id>)→ 终端显示。
|
||||
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnData(TTS 音频等)。
|
||||
bridge.OnPush(func(reqID, typ, payload, meta string) {
|
||||
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
|
||||
})
|
||||
if err := bridge.Start(); err != nil {
|
||||
return fmt.Errorf("device bridge: %w", err)
|
||||
}
|
||||
@ -776,4 +767,4 @@ func sanitizeID(s string) string {
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,9 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/homed -data /var/lib/homeagent
|
||||
ExecStart=/usr/local/bin/homed -data /var/lib/homeagent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime
|
||||
StateDirectory=homeagent
|
||||
StartLimitBurst=3
|
||||
StartLimitInterval=60s
|
||||
|
||||
@ -28,9 +26,7 @@ DeviceAllow=/dev/dsp rw
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=256
|
||||
# Chinese-CLIP 本身实测约 1.15GB;加文档稠密索引、词向量、插件与
|
||||
# ORT arena 后生产实例约 4.5GB。2GB 会在首次全量建索引时被 cgroup OOM。
|
||||
MemoryMax=8G
|
||||
MemoryMax=2G
|
||||
CPUQuota=100%
|
||||
|
||||
[Install]
|
||||
|
||||
@ -15,13 +15,6 @@ BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
|
||||
|
||||
# 版本与提交的**权威来源**是上面注入的 meta.Version / meta.Commit,不是 Go 自带的
|
||||
# VCS 戳。后者不进 build cache key(Go 文档明确说明 VCS 变化不会触发重建),
|
||||
# 命中缓存时会把上一次的 revision 一并带回来——实测发布分支的产物上就出现了
|
||||
# 1715b5c(本机任何仓库都不存在的提交),用 `go version -m` 溯源会指向幽灵提交。
|
||||
# 统一 -buildvcs=false:宁可没有这个信号,也不要一个错的。
|
||||
# 溯源请用:`strings homed | grep -m1 '^<短 hash>$'`(meta.Commit 是字符串常量)。
|
||||
|
||||
TARGET="${1:-native}"
|
||||
COMPONENT="${2:-all}"
|
||||
|
||||
@ -62,7 +55,7 @@ case "$TARGET" in
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
|
||||
echo " [all|homed|waiter|initconfig|gui]"
|
||||
exit 1
|
||||
esac
|
||||
@ -125,21 +118,8 @@ build_homed() {
|
||||
# Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。
|
||||
# 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的
|
||||
# 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld)。
|
||||
#
|
||||
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间。
|
||||
# 不带这个标签时 providers/chineseclip 与 providers/qwen3vl 仍会注册,
|
||||
# 但打开时报「requires build tag」并优雅降级(不静默假装成功)。
|
||||
# 需要极简构建时可显式 HOMED_TAGS= 关掉。
|
||||
#
|
||||
# 运行期还需要 libonnxruntime.so(provider 按 /opt/onnxruntime、
|
||||
# /usr/local/lib、/usr/lib 顺序查找);缺失时同样是「日志里的明确错误 +
|
||||
# 降级」,不会假装启用。
|
||||
local _cc="${CC:-cc}"
|
||||
local _tags="${HOMED_TAGS-onnxruntime}"
|
||||
local -a _tagargs=()
|
||||
if [ -n "$_tags" ]; then _tagargs=(-tags "$_tags"); fi
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
${_tagargs[@]+"${_tagargs[@]}"} \
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
|
||||
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
|
||||
}
|
||||
@ -151,74 +131,28 @@ build_waiter() {
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] waiter ${plat} → $out"
|
||||
CGO_ENABLED=0 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# ---- initconfig(必须 cgo:写 config.db 用的是 go-sqlite3)----
|
||||
# ---- initconfig (CGO-free 配置初始化器) ----
|
||||
#
|
||||
# 这里**必须** CGO_ENABLED=1。此前写的是 CGO_ENABLED=0,而 cmd/initconfig 通过
|
||||
# database/sql 使用 mattn/go-sqlite3:CGO_ENABLED=0 时该库退化成 static_mock.go
|
||||
# 里的桩,sql.Open 是懒的所以不报错、第一次 Exec 才失败;而 main.go 当时忽略
|
||||
# 了所有错误——于是 initconfig 打印凭据、退出码 0、一个字节都没写进 config.db。
|
||||
# 安装脚本把这份凭据写进 credentials.txt,用户照它登录必然失败,全程无报错。
|
||||
#
|
||||
# NSIS 安装包(installer.nsi)与 package-linux.sh 的 stage_variant 都引用它,
|
||||
# 但此前 build.sh 从不构建它——Windows 安装包构建会直接失败在缺文件上。
|
||||
# NSIS 安装包(installer.nsi:220 File "..\build\initconfig.exe")与
|
||||
# package-linux.sh 的 stage_variant 都引用它,但此前 build.sh 从不构建它——
|
||||
# Windows 安装包构建会直接失败在缺文件上。
|
||||
build_initconfig() {
|
||||
local plat="${GOOS:-linux}/${GOARCH:-amd64}"
|
||||
local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}"
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] initconfig ${plat} → $out"
|
||||
CGO_ENABLED=1 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# ---- linux-payload(给 Windows 安装器用的 Linux 包)----
|
||||
#
|
||||
# Windows 不再安装 homed.exe:homed 依赖 fd 继承 + 统一共享内存区的段内偏移
|
||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
# Windows 安装器改为引导到 WSL2,并把 **Linux 包**送进发行版里安装。
|
||||
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
|
||||
# build/linux-payload/(installer.nsi 从这里 File /r 打进安装包)。
|
||||
#
|
||||
# 复用 package-linux.sh 的产物,而不是在这里另行编译:WSL 里跑的就是普通
|
||||
# linux/amd64,安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
|
||||
stage_linux_payload() {
|
||||
local src="$PROJECT_ROOT/dist/linux"
|
||||
local out="$BUILD_DIR/linux-payload"
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
local found=0
|
||||
for f in "$src"/*.deb "$src"/*.tar.gz; do
|
||||
[ -f "$f" ] || continue
|
||||
cp "$f" "$out/"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
|
||||
echo " (Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[BUILD] linux-payload ← $found 个包"
|
||||
ls -1 "$out" | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ---- gui (Electron) ----
|
||||
#
|
||||
# 输出目录必须用 --config.directories.output,**不能用 -o**:
|
||||
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
|
||||
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
|
||||
# ⨯ Unknown target: /home/program/trueagent/build
|
||||
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
|
||||
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
|
||||
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
|
||||
build_gui() {
|
||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
||||
@ -236,52 +170,22 @@ build_gui() {
|
||||
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
||||
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
||||
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
||||
#
|
||||
# GUI 失败不中断整体构建:homed/waiter/initconfig 是发布的主体,
|
||||
# 而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
|
||||
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
|
||||
if (cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
--config.directories.output="$BUILD_DIR"); then
|
||||
echo " OK"
|
||||
else
|
||||
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig)"
|
||||
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
|
||||
return 0
|
||||
fi
|
||||
(cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
-o "$BUILD_DIR")
|
||||
echo " OK"
|
||||
}
|
||||
|
||||
# ---- dispatch ----
|
||||
if [ "${GOOS:-}" = "windows" ]; then
|
||||
# Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
|
||||
# 需要的是:Linux 包(送进 WSL 安装)+ Windows 侧客户端(waiter CLI / GUI)。
|
||||
case "$COMPONENT" in
|
||||
all) build_waiter; stage_linux_payload; build_gui ;;
|
||||
waiter) build_waiter ;;
|
||||
payload) stage_linux_payload ;;
|
||||
gui) build_gui ;;
|
||||
homed|initconfig)
|
||||
echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2(或用 linux/amd64 目标)。" >&2
|
||||
echo "原因见 cmd/homed/platform_windows.go。" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.0.0"
|
||||
!define PRODUCT_VERSION "1.1.0"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
@ -76,10 +76,6 @@ Function GenKey
|
||||
FunctionEnd
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
; 许可页:AGPL-3.0-only(全文在仓库根 LICENSE)。
|
||||
; NSIS 的 File/!insertmacro 相对路径以**本 .nsi 所在目录**为基准解析,
|
||||
; 而本文件在 deploy/packaging/,故仓库根是 ..\..\ 。
|
||||
!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
!if "${HAS_CREDENTIALS}" == "1"
|
||||
@ -220,26 +216,17 @@ FunctionEnd
|
||||
|
||||
Section "Install" SEC_INSTALL
|
||||
SetOutPath "$INSTDIR"
|
||||
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
|
||||
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
CreateDirectory "$INSTDIR\data\log"
|
||||
CreateDirectory "$INSTDIR\data\plugins"
|
||||
CreateDirectory "$INSTDIR\data\adapters"
|
||||
|
||||
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
|
||||
; 解引用,Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
; Windows 侧改为引导到 WSL2,把 **Linux 包**送进发行版里按 Linux 的方式安装。
|
||||
; 所以这里带的是 linux/amd64 的 payload,不是 homed.exe。
|
||||
!if "${HAS_CORE}" == "1"
|
||||
SetOutPath "$PLUGINSDIR\linux-payload"
|
||||
File /r "..\..\build\linux-payload\*.*"
|
||||
SetOutPath "$INSTDIR"
|
||||
File "..\..\build\initconfig.exe"
|
||||
File "..\..\build\homed.exe"
|
||||
!endif
|
||||
|
||||
!if "${HAS_WAITER}" == "1"
|
||||
; waiter 是 CLI 客户端:WSL 侧会装上 Linux 版;Windows 侧仍可保留原生版
|
||||
; (它只是个客户端,不走插件体系)。
|
||||
File "..\..\build\waiter.exe"
|
||||
!endif
|
||||
|
||||
@ -250,24 +237,11 @@ Section "Install" SEC_INSTALL
|
||||
!endif
|
||||
|
||||
!if "${HAS_CORE}" == "1"
|
||||
; 在 WSL2 里安装 homed。凭据(页面上收的那三个)透传进去,避免
|
||||
; 「界面显示一份、config.db 里另一份」导致登录不上。
|
||||
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
|
||||
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
|
||||
DetailPrint "初始化配置数据库..."
|
||||
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 != 0
|
||||
; 退出码含义见 install-via-wsl.ps1:20/21 是「WSL 或发行版缺失,需要先装」,
|
||||
; 属于可指引的用户动作,不当成安装失败来恐吓人。
|
||||
${If} $0 == 20
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows,再重新运行本安装程序。"
|
||||
${ElseIf} $0 == 21
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行:$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
|
||||
${Else}
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0)。$\r$\n$\r$\n可进入 WSL 手动排查:wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
|
||||
${EndIf}
|
||||
${Else}
|
||||
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
|
||||
DetailPrint "警告: 数据库初始化可能未成功完成"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
|
||||
Architecture: ARCH_PLACEHOLDER
|
||||
Maintainer: HomeAgent Team <team@homeagent.ai>
|
||||
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
|
||||
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
|
||||
Depends: libc6 (>= 2.28)
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://github.com/trueagent/HomeAgent
|
||||
|
||||
@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
|
||||
Architecture: ARCH_PLACEHOLDER
|
||||
Maintainer: HomeAgent Team <team@homeagent.ai>
|
||||
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
|
||||
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
|
||||
Depends: libc6 (>= 2.28)
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://github.com/trueagent/HomeAgent
|
||||
|
||||
@ -2,63 +2,24 @@
|
||||
set -e
|
||||
|
||||
SERVICE_NAME="homeagent"
|
||||
SERVICE_FILE="/lib/systemd/system/${SERVICE_NAME}.service"
|
||||
HOMED_BIN="/usr/bin/homed"
|
||||
DATA_DIR="/var/lib/homeagent"
|
||||
SETUP_SH="/usr/lib/homeagent/setup.sh"
|
||||
|
||||
# unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查
|
||||
# /lib/systemd/system/(merged-usr 下等于 /usr/lib/systemd/system,那里没有
|
||||
# 这个文件)——于是 daemon-reload 与 enable **从未执行过**:装完不会开机自启,
|
||||
# 而 postinst 全程无报错。这里三个候选位置都看一下。
|
||||
find_unit() {
|
||||
for p in "/etc/systemd/system/${SERVICE_NAME}.service" \
|
||||
"/usr/lib/systemd/system/${SERVICE_NAME}.service" \
|
||||
"/lib/systemd/system/${SERVICE_NAME}.service"; do
|
||||
if [ -f "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
if [ -f "$HOMED_BIN" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 初始化凭据和数据库。
|
||||
#
|
||||
# 这里不能再用 `|| true` 吞失败:setup.sh 靠 initconfig 写 config.db,
|
||||
# 而 initconfig 曾因 CGO_ENABLED=0 静默空操作(凭据只进了
|
||||
# credentials.txt、没进数据库),用户拿它登录必然失败,安装却一声不响。
|
||||
# 失败必须看得见,并给出可直接执行的补救命令。
|
||||
if [ -x "$SETUP_SH" ]; then
|
||||
if ! HOMEAGENT_DATA="$DATA_DIR" "$SETUP_SH"; then
|
||||
echo "E: homeagent 初始化失败——凭据可能未写入 config.db。" >&2
|
||||
echo "E: 请手动重试:HOMEAGENT_DATA=$DATA_DIR $SETUP_SH" >&2
|
||||
fi
|
||||
else
|
||||
echo "W: 未找到 $SETUP_SH,跳过凭据初始化。" >&2
|
||||
# 初始化凭据和数据库
|
||||
if [ -x /usr/lib/homeagent/setup.sh ]; then
|
||||
HOMEAGENT_DATA="$DATA_DIR" /usr/lib/homeagent/setup.sh || true
|
||||
fi
|
||||
|
||||
# 注册 systemd 服务
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
if find_unit >/dev/null; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
# 首次安装就拉起来,装完即可用;升级时重启以真正加载新二进制
|
||||
# (仅 enable 不会让已在运行的进程换用新文件)。
|
||||
if [ -z "${2:-}" ]; then
|
||||
systemctl start "$SERVICE_NAME" 2>/dev/null || \
|
||||
echo "W: homeagent 服务未能启动,请检查:systemctl status $SERVICE_NAME" >&2
|
||||
else
|
||||
systemctl restart "$SERVICE_NAME" 2>/dev/null || \
|
||||
echo "W: homeagent 服务未能重启,请检查:systemctl status $SERVICE_NAME" >&2
|
||||
fi
|
||||
else
|
||||
echo "W: 未找到 ${SERVICE_NAME}.service,未启用服务。" >&2
|
||||
fi
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
@ -8,32 +8,16 @@ CRED_FILE="${DATA_DIR}/credentials.txt"
|
||||
CONFIG_DB="${DATA_DIR}/config.db"
|
||||
WAITER_CONF="${DATA_DIR}/waiter.yaml"
|
||||
INITCONFIG_BIN="/usr/bin/initconfig"
|
||||
BUNDLED_MODEL_DIR="/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
|
||||
MODEL_LINK="${DATA_DIR}/models/chinese-clip-vit-b16-onnx"
|
||||
|
||||
# 模型随 server/full 包安装到只读的 /usr/lib;配置默认仍指向 dataDir/models。
|
||||
# 用符号链接把两者接起来,既不复制 754MB,也保持 dataDir 可迁移语义。
|
||||
# 用户已有自定义目录时绝不覆盖;升级时既有链接自然指向新版包内容。
|
||||
if [ -d "$BUNDLED_MODEL_DIR" ]; then
|
||||
mkdir -p "${DATA_DIR}/models"
|
||||
if [ ! -e "$MODEL_LINK" ] && [ ! -L "$MODEL_LINK" ]; then
|
||||
ln -s "$BUNDLED_MODEL_DIR" "$MODEL_LINK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 如果已经初始化过,只跳过凭据/数据库生成;上面的模型链接仍须在升级时补齐。
|
||||
# 如果已经初始化过,跳过
|
||||
if [ -f "$CONFIG_DB" ] && [ -f "$CRED_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 生成随机凭据。
|
||||
#
|
||||
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
|
||||
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
|
||||
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
|
||||
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
|
||||
# 生成随机凭据
|
||||
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")
|
||||
WEBUI_USER="${WEBUI_USER:-admin}"
|
||||
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"
|
||||
|
||||
|
||||
@ -5,37 +5,11 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist/linux"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}"
|
||||
|
||||
# git describe 给出的是 v1.0.0-68-gba0b5a1-dirty 这类描述串,它不是合法的包版本:
|
||||
# deb 的 Version 必须以数字开头,rpm 的 Version 不允许 '-'(那是版本/发布的分隔符)。
|
||||
# 以前只有显式传 VERSION=1.0.3 才打得出来,默认路径一跑就死在 dpkg-deb 上——
|
||||
# 而且死在 stage 之后,前面每条日志都是真的,只有最后一个产物没生成。
|
||||
PKG_VERSION="${VERSION#v}"
|
||||
case "$PKG_VERSION" in
|
||||
[0-9]*) ;;
|
||||
*) echo "ERROR: 包版本必须以数字开头(得到 '$VERSION')。请显式设置 VERSION=x.y.z 后重试。" >&2; exit 1 ;;
|
||||
esac
|
||||
PKG_VERSION="$(printf '%s' "$PKG_VERSION" | sed -e 's/-/+/g')"
|
||||
PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
|
||||
ARCH="${1:-amd64}" # amd64 or arm64
|
||||
|
||||
# server/full 发行包默认带 Chinese-CLIP ONNX 产物与 ONNX Runtime。
|
||||
# 二进制大资产不进 git:发布环境通过这两个目录提供已验证的产物;若缺失,
|
||||
# server/full 打包必须明确失败,不能生成一个「默认启用但装完不能用」的假包。
|
||||
CHINESECLIP_BUNDLE_DIR="${CHINESECLIP_BUNDLE_DIR:-$BUILD_DIR/model-assets/chinese-clip-vit-b16-onnx}"
|
||||
ONNXRUNTIME_ASSET_DIR="${ONNXRUNTIME_ASSET_DIR:-$BUILD_DIR/runtime-assets/$ARCH}"
|
||||
ONNXRUNTIME_LIB="${ONNXRUNTIME_LIB:-$ONNXRUNTIME_ASSET_DIR/libonnxruntime.so}"
|
||||
ONNXRUNTIME_LICENSE="${ONNXRUNTIME_LICENSE:-$ONNXRUNTIME_ASSET_DIR/LICENSE}"
|
||||
ONNXRUNTIME_NOTICES="${ONNXRUNTIME_NOTICES:-$ONNXRUNTIME_ASSET_DIR/ThirdPartyNotices.txt}"
|
||||
|
||||
# 打包 staging 会把 719MB 模型真的复制一份,临时目录必须落在构建目录所在的磁盘,
|
||||
# 不能落在系统临时目录:本机 /tmp 是 9.8GB tmpfs,一次 full 包 staging 就能写满,
|
||||
# 而且失败发生在 cp 进行到一半,报出来是 "No space left on device"——看上去像
|
||||
# 资产/版本有问题,实际只是临时目录选错了文件系统。
|
||||
STAGE_TMP="${BUILD_DIR}/.stage-tmp"
|
||||
|
||||
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
|
||||
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
|
||||
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
|
||||
@ -54,7 +28,6 @@ esac
|
||||
|
||||
echo "=== HomeAgent Linux Packager ==="
|
||||
echo "Version: $VERSION"
|
||||
[ "$PKG_VERSION" = "$VERSION" ] || echo "Package: $PKG_VERSION (normalized for deb/rpm)"
|
||||
echo "Arch: $ARCH"
|
||||
echo ""
|
||||
|
||||
@ -116,7 +89,7 @@ restore_syso() {
|
||||
}
|
||||
|
||||
# ensure both are always restored on exit
|
||||
restore_all() { restore_gomod; restore_syso; rmdir "$STAGE_TMP" 2>/dev/null || true; }
|
||||
restore_all() { restore_gomod; restore_syso; }
|
||||
trap restore_all EXIT
|
||||
|
||||
# ---- build Go binaries via existing build.sh ----
|
||||
@ -126,29 +99,31 @@ build_go() {
|
||||
prepare_gomod || true
|
||||
hide_syso
|
||||
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed" 2>&1 || {
|
||||
echo "WARNING: homed build failed (CGO/sqlite3 issue). Server/full packages may be incomplete."
|
||||
}
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter" 2>&1 || {
|
||||
echo "WARNING: waiter build failed."
|
||||
}
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig" 2>&1 || {
|
||||
echo "WARNING: initconfig build failed(包内将缺少首次配置初始化器)。"
|
||||
}
|
||||
|
||||
local suffix="linux_${ARCH}"
|
||||
local homed_bin="$BUILD_DIR/homed_$suffix"
|
||||
local waiter_bin="$BUILD_DIR/waiter_$suffix"
|
||||
local initconfig_bin="$BUILD_DIR/initconfig_$suffix"
|
||||
|
||||
# 先删旧产物:否则本次构建失败后,残留文件会让「产物存在」判据假绿。
|
||||
rm -f "$homed_bin" "$waiter_bin" "$initconfig_bin"
|
||||
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed"
|
||||
test -x "$homed_bin"
|
||||
if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
|
||||
echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包:$homed_bin" >&2
|
||||
return 1
|
||||
if [ ! -f "$homed_bin" ]; then
|
||||
echo "ERROR: homed binary not found at $homed_bin"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$waiter_bin" ]; then
|
||||
echo "ERROR: waiter binary not found at $waiter_bin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter"
|
||||
test -x "$waiter_bin"
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig"
|
||||
test -x "$initconfig_bin"
|
||||
|
||||
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1), onnxruntime)"
|
||||
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
|
||||
echo " initconfig: $initconfig_bin ($(du -h "$initconfig_bin" | cut -f1))"
|
||||
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1))"
|
||||
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@ -335,8 +310,6 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
server)
|
||||
@ -345,12 +318,9 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
;;
|
||||
client)
|
||||
cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter"
|
||||
stage_license "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
esac
|
||||
@ -385,127 +355,6 @@ stage_setup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 项目自身的许可:**所有变体**都要带(client 也分发 waiter 与 GUI)。
|
||||
#
|
||||
# deb 按 Debian 惯例给 /usr/share/doc/homeagent/copyright(DEP-5 机器可读格式),
|
||||
# 同时把 LICENSE 全文放进去;rpm 的许可走 fpm 的 --license 元数据。
|
||||
# 与 stage_multimodal_assets 的 licenses/ 分工:那里放**第三方**(模型/运行库)的
|
||||
# 许可全文,这里放本项目自己的。
|
||||
stage_license() {
|
||||
local staging="$1"
|
||||
local docdir="$staging/usr/share/doc/homeagent"
|
||||
mkdir -p "$docdir"
|
||||
cp "$PROJECT_ROOT/LICENSE" "$docdir/LICENSE"
|
||||
cat > "$docdir/copyright" <<'EOF'
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: HomeAgent
|
||||
Source: https://gitcode.com/JianFeeeee/HomeAgent
|
||||
|
||||
Files: *
|
||||
Copyright: HomeAgent contributors
|
||||
License: AGPL-3.0-only
|
||||
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, version 3 of the License.
|
||||
.
|
||||
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/>.
|
||||
.
|
||||
The license is AGPL-3.0-only: no later version may be chosen. Note the network
|
||||
clause (§13 Remote Network Interaction) — offering modified versions of this
|
||||
software to users over a network also requires offering them the source.
|
||||
.
|
||||
Full text: /usr/share/doc/homeagent/LICENSE
|
||||
|
||||
Files: usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/*
|
||||
Copyright: OFA-Sys / Chinese-CLIP authors
|
||||
License: Apache-2.0
|
||||
Full text: /usr/share/doc/homeagent/licenses/Chinese-CLIP-Apache-2.0.txt
|
||||
Comment: pre-trained model artifacts; NOT covered by this package's AGPL grant
|
||||
|
||||
Files: usr/lib/homeagent/onnxruntime/*
|
||||
Copyright: Microsoft Corporation
|
||||
License: MIT
|
||||
Full text: /usr/share/doc/homeagent/licenses/ONNX-Runtime-MIT.txt
|
||||
Comment: license texts and third-party notices under licenses/ONNX-Runtime-*
|
||||
EOF
|
||||
chmod 644 "$docdir/LICENSE" "$docdir/copyright"
|
||||
}
|
||||
|
||||
# server/full 的 ONNX 资产。模型与运行库是发行版能力的一部分,不是可选下载:
|
||||
# 只要打 server/full 包,两者缺一就失败。client 包不运行 homed,故不携带。
|
||||
stage_multimodal_assets() {
|
||||
local staging="$1"
|
||||
local model_dst="$staging/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
|
||||
local ort_dst="$staging/usr/lib/homeagent/onnxruntime"
|
||||
local licenses="$staging/usr/share/doc/homeagent/licenses"
|
||||
|
||||
if [ ! -d "$CHINESECLIP_BUNDLE_DIR" ]; then
|
||||
echo "ERROR: Chinese-CLIP 产物目录不存在:$CHINESECLIP_BUNDLE_DIR" >&2
|
||||
echo "先运行 scripts/export_chineseclip_onnx.py,再通过 CHINESECLIP_BUNDLE_DIR 指向产物。" >&2
|
||||
return 1
|
||||
fi
|
||||
for f in TextEncoder.onnx VisionEncoder.onnx embed_config.json vocab.txt reference.json SHA256SUMS; do
|
||||
if [ ! -s "$CHINESECLIP_BUNDLE_DIR/$f" ]; then
|
||||
echo "ERROR: Chinese-CLIP 产物缺少或为空:$CHINESECLIP_BUNDLE_DIR/$f" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if ! (cd "$CHINESECLIP_BUNDLE_DIR" && sha256sum -c SHA256SUMS); then
|
||||
echo "ERROR: Chinese-CLIP SHA256SUMS 校验失败,拒绝打包。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$ONNXRUNTIME_LIB" ]; then
|
||||
echo "ERROR: ONNX Runtime 不存在:$ONNXRUNTIME_LIB" >&2
|
||||
echo "通过 ONNXRUNTIME_ASSET_DIR 或 ONNXRUNTIME_LIB 指向与目标架构匹配的资产。" >&2
|
||||
return 1
|
||||
fi
|
||||
for notice in "$ONNXRUNTIME_LICENSE" "$ONNXRUNTIME_NOTICES"; do
|
||||
if [ ! -s "$notice" ]; then
|
||||
echo "ERROR: ONNX Runtime 许可证资产缺失:$notice" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
local runtime_desc
|
||||
runtime_desc=$(file -b "$ONNXRUNTIME_LIB")
|
||||
case "$ARCH" in
|
||||
amd64) printf '%s' "$runtime_desc" | grep -qE 'x86-64|x86_64' || {
|
||||
echo "ERROR: ONNX Runtime 架构不是 amd64:$runtime_desc" >&2; return 1; } ;;
|
||||
arm64) printf '%s' "$runtime_desc" | grep -qE 'aarch64|ARM aarch64' || {
|
||||
echo "ERROR: ONNX Runtime 架构不是 arm64:$runtime_desc" >&2; return 1; } ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$model_dst" "$ort_dst" "$licenses"
|
||||
cp -a "$CHINESECLIP_BUNDLE_DIR/." "$model_dst/"
|
||||
install -m 0755 "$ONNXRUNTIME_LIB" "$ort_dst/libonnxruntime.so"
|
||||
|
||||
# 许可证随二进制分发:Chinese-CLIP = Apache-2.0;ONNX Runtime = MIT,
|
||||
# 同时携带其 ThirdPartyNotices(含 MKL/protobuf/zlib 等第三方条款)。
|
||||
cp /usr/share/common-licenses/Apache-2.0 "$licenses/Chinese-CLIP-Apache-2.0.txt"
|
||||
cp "$ONNXRUNTIME_LICENSE" "$licenses/ONNX-Runtime-MIT.txt"
|
||||
cp "$ONNXRUNTIME_NOTICES" "$licenses/ONNX-Runtime-ThirdPartyNotices.txt"
|
||||
cat > "$licenses/MODEL-SOURCES.txt" <<EOF
|
||||
Chinese-CLIP ViT-B/16
|
||||
upstream: https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16
|
||||
license: Apache-2.0
|
||||
exported-by: scripts/export_chineseclip_onnx.py
|
||||
dimensions: 512
|
||||
modalities: text,image
|
||||
|
||||
ONNX Runtime
|
||||
upstream: https://github.com/microsoft/onnxruntime
|
||||
license: MIT (see ONNX-Runtime-MIT.txt and ONNX-Runtime-ThirdPartyNotices.txt)
|
||||
EOF
|
||||
|
||||
echo " ONNX assets: model=$(du -sh "$model_dst" | cut -f1) runtime=$(du -h "$ort_dst/libonnxruntime.so" | cut -f1)"
|
||||
}
|
||||
|
||||
# ---- create .deb ----
|
||||
build_deb() {
|
||||
local variant="$1"
|
||||
@ -513,9 +362,9 @@ build_deb() {
|
||||
local deb_dir="${DIST_DIR}/deb"
|
||||
mkdir -p "$deb_dir"
|
||||
|
||||
local pkg_name="homeagent-${variant}_${PKG_VERSION}_${DEB_ARCH}.deb"
|
||||
local pkg_name="homeagent-${variant}_${VERSION}_${DEB_ARCH}.deb"
|
||||
local deb_root
|
||||
deb_root="$(mktemp -d "$STAGE_TMP/deb.XXXXXX")"
|
||||
deb_root="$(mktemp -d)"
|
||||
|
||||
mkdir -p "$deb_root/DEBIAN"
|
||||
|
||||
@ -523,7 +372,7 @@ build_deb() {
|
||||
local installed_size_kb
|
||||
installed_size_kb=$(du -sk "$staging" | cut -f1)
|
||||
|
||||
sed -e "s/VERSION_PLACEHOLDER/$PKG_VERSION/g" \
|
||||
sed -e "s/VERSION_PLACEHOLDER/$VERSION/g" \
|
||||
-e "s/ARCH_PLACEHOLDER/$DEB_ARCH/g" \
|
||||
-e "s/INSTALLED_SIZE_PLACEHOLDER/$installed_size_kb/g" \
|
||||
"$control_file" > "$deb_root/DEBIAN/control"
|
||||
@ -552,13 +401,13 @@ build_tar() {
|
||||
local tar_dir="${DIST_DIR}/tar"
|
||||
mkdir -p "$tar_dir"
|
||||
|
||||
local archive_name="homeagent_${PKG_VERSION}_linux_${TAR_ARCH}.tar.gz"
|
||||
local archive_dir="homeagent-${PKG_VERSION}-linux-${TAR_ARCH}"
|
||||
local archive_name="homeagent_${VERSION}_linux_${TAR_ARCH}.tar.gz"
|
||||
local archive_dir="homeagent-${VERSION}-linux-${TAR_ARCH}"
|
||||
|
||||
# build combined staging
|
||||
local staging
|
||||
staging="$(mktemp -d "$STAGE_TMP/tar.XXXXXX")"
|
||||
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent" "$staging/etc/systemd/system"
|
||||
staging="$(mktemp -d)"
|
||||
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent"
|
||||
|
||||
# copy all available binaries
|
||||
for bin in homed waiter initconfig; do
|
||||
@ -569,9 +418,6 @@ build_tar() {
|
||||
# setup script
|
||||
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
|
||||
[ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh"
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
|
||||
# GUI if available
|
||||
local gui_src="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
|
||||
@ -600,7 +446,7 @@ build_rpm() {
|
||||
local rpm_dir="${DIST_DIR}/rpm"
|
||||
mkdir -p "$rpm_dir"
|
||||
|
||||
local pkg_name="homeagent-${variant}-${PKG_VERSION}-1.${RPM_ARCH}.rpm"
|
||||
local pkg_name="homeagent-${variant}-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
|
||||
# find fpm
|
||||
local fpm_bin="$(command -v fpm 2>/dev/null || true)"
|
||||
@ -649,7 +495,7 @@ build_rpm() {
|
||||
-a "$RPM_ARCH" \
|
||||
--description "HomeAgent ${variant^} package" \
|
||||
--url "https://github.com/trueagent/HomeAgent" \
|
||||
--license "AGPL-3.0-only" \
|
||||
--license "Proprietary" \
|
||||
-C "$staging" \
|
||||
-p "$rpm_dir/$pkg_name" \
|
||||
. 2>&1
|
||||
@ -660,7 +506,7 @@ build_rpm() {
|
||||
main() {
|
||||
local target_arch="$ARCH"
|
||||
|
||||
mkdir -p "$BUILD_DIR" "$STAGE_TMP"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
case "$ACTION" in
|
||||
all|build)
|
||||
@ -677,10 +523,6 @@ main() {
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# 上次成功构建留下的校验和必须在本次开工前删掉:本次若中途失败,脚本直接退出、
|
||||
# 不重算 SHA256SUMS,旧的它会一直躺在 dist 里,看上去像在为这一批残缺产物背书。
|
||||
rm -f "$DIST_DIR/SHA256SUMS"
|
||||
|
||||
for variant in full server client; do
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
@ -688,7 +530,7 @@ main() {
|
||||
echo "=============================================="
|
||||
|
||||
local staging
|
||||
staging=$(mktemp -d "$STAGE_TMP/stage.XXXXXX")
|
||||
staging=$(mktemp -d)
|
||||
stage_variant "$variant" "$staging"
|
||||
|
||||
case "$ACTION" in
|
||||
@ -710,26 +552,9 @@ main() {
|
||||
echo "=== Done! Packages in: $DIST_DIR ==="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
# 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS
|
||||
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
|
||||
# (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
|
||||
for f in "${release_files[@]}"; do
|
||||
find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort | while read -r f; do
|
||||
echo " $(du -h "$f" | cut -f1) $f"
|
||||
done
|
||||
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
|
||||
# 名字用**平铺名**(basename):下载页的附件名就是平铺的,
|
||||
# 清单里若写 ./deb/xxx.deb,用户下载后 `sha256sum -c` 会找不到文件。
|
||||
if [ ${#release_files[@]} -gt 0 ]; then
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
|
||||
for f in "${release_files[@]}"; do
|
||||
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
|
||||
done | sort -k2 > SHA256SUMS
|
||||
)
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建 Windows 安装器(NSIS)。
|
||||
#
|
||||
# ❗安装器**不往 Windows 装 homed**:homed 依赖 fd 继承与统一共享内存区的段内偏移
|
||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。所以安装器的
|
||||
# 职责是**引导 WSL2,并把 Linux 包送进发行版里按 Linux 方式安装**
|
||||
# (deploy/packaging/windows/install-via-wsl.ps1)。
|
||||
#
|
||||
# 用法: VERSION=1.3.10 bash deploy/packaging/package-windows.sh <server|client|full> [arch]
|
||||
# 前置: 先产出对应的 Linux 包(VERSION=x bash deploy/packaging/package-linux.sh amd64)
|
||||
#
|
||||
# 为什么不复用 build.sh 的 stage_linux_payload:那一段把 dist/linux 下**所有** deb+tar
|
||||
# 都塞进 payload,而 server/full 的 deb 各带 ~719MB 的 Chinese-CLIP 模型 ⇒ 任何变体的
|
||||
# 安装器都会膨胀到 ~2.4GB。WSL 侧脚本只取 payload 里的**第一个** .deb
|
||||
# (install-via-wsl.ps1:141),所以这里按变体只放对应的那一个包。
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
# 三个目录都可覆盖:发布件常在 tag 的干净 worktree 里构建,而这个脚本本身
|
||||
# 可能只存在于 main(例如刚补的驱动脚本还没进 tag)——那种情况下用主仓的脚本 +
|
||||
# DIST_LINUX/BUILD_DIR/DIST_RELEASE 指向 worktree,避免"脚本不存在"或产物错位。
|
||||
BUILD_DIR="${BUILD_DIR:-$PROJECT_ROOT/build}"
|
||||
DIST_LINUX="${DIST_LINUX:-$PROJECT_ROOT/dist/linux}"
|
||||
DIST_RELEASE="${DIST_RELEASE:-$PROJECT_ROOT/dist/release}"
|
||||
# ❗NSIS 的 `File` 路径是**相对 .nsi 所在目录**解析的:在 tag 的 worktree 里构建时,
|
||||
# 必须用**该 tag 里的** installer.nsi,否则它会去主仓的 build/linux-payload 找载荷
|
||||
# (实测报 `File: "..\..\build\linux-payload\*.*" -> no files found`)。
|
||||
# 用 tag 里的 .nsi 也正是"发布件与当时的脚本同源"的正确做法。
|
||||
NSI="${NSI:-$PROJECT_ROOT/deploy/packaging/installer.nsi}"
|
||||
if [ ! -f "$NSI" ]; then
|
||||
echo "[FAIL] 找不到 NSIS 脚本: $NSI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VARIANT="${1:-server}"
|
||||
ARCH="${2:-amd64}"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags 2>/dev/null || echo 0.0.0)}"
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
case "$VARIANT" in
|
||||
server) DEB_GLOB="homeagent-server_${VERSION}_${ARCH}.deb"; SUFFIX="Server"; WANT_GUI=0; WANT_WAITER=0 ;;
|
||||
full) DEB_GLOB="homeagent-full_${VERSION}_${ARCH}.deb"; SUFFIX="Full"; WANT_GUI=1; WANT_WAITER=1 ;;
|
||||
client) DEB_GLOB="homeagent-client_${VERSION}_${ARCH}.deb"; SUFFIX="Client"; WANT_GUI=1; WANT_WAITER=1 ;;
|
||||
*) echo "用法: $0 <server|client|full> [arch]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
DEB="$(ls -1 "$DIST_LINUX/deb/$DEB_GLOB" "$DIST_LINUX/$DEB_GLOB" 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "[FAIL] 找不到 $DEB_GLOB" >&2
|
||||
echo " 先产出 Linux 包:VERSION=$VERSION bash deploy/packaging/package-linux.sh $ARCH" >&2
|
||||
echo " (安装器的作用是把 Linux 包送进 WSL2,所以必须先有 Linux 包)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# client/full 还要带 Windows GUI(HAS_GUI=1)。本机缺 electron-builder,若 build/ 下
|
||||
# 没有可用的 win32-x64 payload 就**明确失败**,不产出"装完没有界面"的半残包。
|
||||
if [ "$WANT_GUI" = 1 ]; then
|
||||
if [ -z "$(ls -1 "$BUILD_DIR"/homeagent-gui-win32-x64/*.exe 2>/dev/null | head -1 || true)" ]; then
|
||||
echo "[FAIL] 变体 $VARIANT 需要 Windows GUI payload(build/homeagent-gui-win32-x64/*.exe)" >&2
|
||||
echo " 本机无 electron-builder:npm i -g electron-builder &&" >&2
|
||||
echo " bash deploy/packaging/build.sh windows/amd64 gui" >&2
|
||||
echo " (只装内核+CLI 的 WSL 场景请用 server 变体)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$WANT_WAITER" = 1 ]; then
|
||||
echo "[BUILD] waiter.exe(Windows 侧 CLI;CGO 关闭,跨平台安全)"
|
||||
( cd "$PROJECT_ROOT" && GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
|
||||
go build -buildvcs=false -trimpath -o "$BUILD_DIR/waiter.exe" ./cmd/waiter )
|
||||
fi
|
||||
|
||||
# ---- 变体定向 payload:只放本变体那一个 Linux 包 ----
|
||||
rm -rf "$BUILD_DIR/linux-payload"
|
||||
mkdir -p "$BUILD_DIR/linux-payload"
|
||||
cp "$DEB" "$BUILD_DIR/linux-payload/"
|
||||
echo "[STAGE] payload ← $(basename "$DEB")($(du -h "$DEB" | cut -f1))"
|
||||
|
||||
if [ -z "$(ls -1 "$BUILD_DIR/linux-payload" 2>/dev/null | head -1 || true)" ]; then
|
||||
echo "[FAIL] payload 为空:$BUILD_DIR/linux-payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[BUILD] makensis -DVARIANT=$VARIANT -DPRODUCT_VERSION=$VERSION(nsi: $NSI)"
|
||||
makensis -V2 -DVARIANT="$VARIANT" -DPRODUCT_VERSION="$VERSION" "$NSI"
|
||||
|
||||
OUT="$BUILD_DIR/HomeAgent_v${VERSION}_${SUFFIX}_win64.exe"
|
||||
if [ ! -f "$OUT" ]; then
|
||||
echo "[FAIL] 未找到产物 $OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$DIST_RELEASE"
|
||||
cp "$OUT" "$DIST_RELEASE/"
|
||||
echo "[OK] $(basename "$OUT")($(du -h "$OUT" | cut -f1))→ $DIST_RELEASE/"
|
||||
echo " 它会在 Windows 侧引导 WSL2,并把 $(basename "$DEB") 送进去安装(homed 跑在 WSL 里)。"
|
||||
@ -1,259 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
在 WSL2 中安装 HomeAgent(homed + 插件 + WebUI)。
|
||||
|
||||
.DESCRIPTION
|
||||
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go:
|
||||
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」,
|
||||
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI,
|
||||
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
|
||||
|
||||
本脚本因此把 Windows 安装流程变成一条引导链:
|
||||
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd)
|
||||
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
|
||||
|
||||
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
|
||||
「WSL 里就是普通 linux/amd64」这一点必须保持成立,否则等于又开了第三个平台。
|
||||
|
||||
.PARAMETER PayloadDir
|
||||
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
|
||||
优先取 *.deb;没有 deb 时回退 *.tar.gz。
|
||||
|
||||
.PARAMETER Distro
|
||||
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
|
||||
|
||||
.PARAMETER DataDir
|
||||
WSL 内的数据目录。默认 /var/lib/homeagent(与 Linux 原生安装一致)。
|
||||
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
|
||||
|
||||
.NOTES
|
||||
⚠️ 本脚本在开发环境(Linux)中只能做语法/逻辑审查,**未在真实 Windows + WSL
|
||||
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
|
||||
便于定位到具体哪一步与预期不符。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PayloadDir,
|
||||
[string]$Distro = "",
|
||||
[string]$DataDir = "/var/lib/homeagent",
|
||||
[string]$ApiKey = "",
|
||||
[string]$WebUIUser = "",
|
||||
[string]$WebUIPass = "",
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:StageNo = 0
|
||||
$script:DistroName = $Distro
|
||||
|
||||
function Write-Stage([string]$Text) {
|
||||
$script:StageNo++
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green }
|
||||
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
|
||||
function Fail([string]$Text, [string]$Hint = "") {
|
||||
Write-Host ""
|
||||
Write-Host " 安装中止:$Text" -ForegroundColor Red
|
||||
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
|
||||
Write-Stage "前置检查"
|
||||
|
||||
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
|
||||
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
|
||||
}
|
||||
Write-Ok "管理员权限"
|
||||
|
||||
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
|
||||
Write-Warn2 "未找到 wsl.exe"
|
||||
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
|
||||
Write-Host ""
|
||||
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install" -ForegroundColor White
|
||||
Write-Host " 然后重启 Windows,再重新运行本安装程序。"
|
||||
Write-Host ""
|
||||
Write-Host " (Windows 10 需 2004+ 且启用虚拟机平台;Windows 11 开箱可用)"
|
||||
exit 20
|
||||
}
|
||||
Write-Ok "wsl.exe 可用"
|
||||
|
||||
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
|
||||
Write-Stage "检测 WSL 与发行版"
|
||||
|
||||
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
|
||||
# 用 --status 取默认发行版,再单独枚举列表。
|
||||
$distros = @()
|
||||
try {
|
||||
$raw = (& wsl.exe -l -q 2>$null | Out-String)
|
||||
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
} catch {
|
||||
$distros = @()
|
||||
}
|
||||
|
||||
if ($distros.Count -eq 0) {
|
||||
Write-Warn2 "WSL 已安装,但没有任何发行版"
|
||||
Write-Host ""
|
||||
Write-Host " 请先安装发行版(推荐 Ubuntu):" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
|
||||
exit 21
|
||||
}
|
||||
|
||||
if ($script:DistroName -eq "") {
|
||||
try {
|
||||
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
|
||||
ForEach-Object { ($_ -split ":")[1].Trim() })
|
||||
} catch { }
|
||||
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
|
||||
}
|
||||
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', '))"
|
||||
|
||||
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
|
||||
Write-Stage "确保使用 WSL2"
|
||||
|
||||
# WSL1 没有真正的 Linux 内核、没有 systemd,且在共享内存/事件语义上与 WSL2 不同。
|
||||
# homed 依赖 eventfd + mmap 语义,WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
|
||||
try {
|
||||
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
|
||||
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
|
||||
if ($verLine -match "\b1\b") {
|
||||
Write-Warn2 "该发行版当前是 WSL1,正在升级为 WSL2 ..."
|
||||
& wsl.exe --set-version $script:DistroName 2
|
||||
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行:wsl --set-version $($script:DistroName) 2" }
|
||||
}
|
||||
} catch { }
|
||||
& wsl.exe --set-default-version 2 | Out-Null
|
||||
Write-Ok "已使用 WSL2"
|
||||
|
||||
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
|
||||
Write-Stage "准备 Linux 安装包"
|
||||
|
||||
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($deb) {
|
||||
$pkg = $deb.FullName
|
||||
$pkgKind = "deb"
|
||||
} elseif ($tar) {
|
||||
$pkg = $tar.FullName
|
||||
$pkgKind = "tar"
|
||||
} else {
|
||||
Fail "在 $PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
|
||||
}
|
||||
Write-Ok "使用 $(Split-Path $pkg -Leaf)($pkgKind)"
|
||||
|
||||
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
|
||||
Write-Stage "把安装包送入 WSL"
|
||||
|
||||
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
|
||||
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
|
||||
$winPath = (Resolve-Path $pkg).Path
|
||||
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
|
||||
Write-Host " 源:$mntPath"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动:wsl -d $($script:DistroName)" }
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
|
||||
Write-Ok "已送到 /tmp/homeagent-install/"
|
||||
|
||||
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
|
||||
Write-Stage "在 WSL 内安装 homed"
|
||||
|
||||
# 凭据经环境变量传给 setup.sh(它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS)。
|
||||
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
|
||||
$credEnv = ""
|
||||
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
|
||||
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
|
||||
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
|
||||
|
||||
# 安装逻辑复用 Linux 侧:deb 走 apt(postinst 会调用 setup.sh 生成凭据与 config.db),
|
||||
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
|
||||
# 「WSL 里就是普通 linux/amd64」必须保持成立,否则等于又开了第三个平台。
|
||||
if ($pkgKind -eq "deb") {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
|
||||
"@
|
||||
} else {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
mkdir -p /opt/homeagent /tmp/homeagent-extract
|
||||
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
|
||||
cd /tmp/homeagent-extract
|
||||
# 与 deb 完全相同的布局:/usr/bin/homed + /usr/lib/homeagent/setup.sh。
|
||||
# 两套安装若落到不同路径,之后的升级/排障就会出现「按文档找不到文件」。
|
||||
install -m 0755 homed /usr/bin/homed
|
||||
install -m 0755 waiter /usr/bin/waiter
|
||||
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
|
||||
if [ -f homeagent.service ]; then
|
||||
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
|
||||
fi
|
||||
mkdir -p /usr/lib/homeagent
|
||||
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
|
||||
"@
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "WSL 内安装失败(退出码 $LASTEXITCODE)" "可进入 WSL 手动排查:wsl -d $($script:DistroName)"
|
||||
}
|
||||
Write-Ok "安装完成"
|
||||
|
||||
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
|
||||
Write-Stage "启动 homed 与自启配置"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
|
||||
systemctl enable homeagent 2>/dev/null || true
|
||||
systemctl restart homeagent
|
||||
echo ' ✓ systemd 服务 homeagent 已启动并设为自启'
|
||||
else
|
||||
# 没有 systemd(WSL2 默认可能没开):用 nohup 起,并把自启交给 Windows 侧的计划任务。
|
||||
pkill -f '/usr/bin/homed' 2>/dev/null || true
|
||||
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
|
||||
echo ' ✓ 已用 nohup 启动(未检测到 systemd)'
|
||||
fi
|
||||
"@
|
||||
|
||||
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " HomeAgent 已在 WSL2($($script:DistroName))内安装完成" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " WebUI:http://localhost:8080" -ForegroundColor White
|
||||
Write-Host " (WSL2 会把 WSL 内的端口映射到 Windows 的 localhost,无需额外配置)"
|
||||
Write-Host ""
|
||||
if ($creds) {
|
||||
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt):" -ForegroundColor Yellow
|
||||
Write-Host $creds
|
||||
} else {
|
||||
Write-Host " 未读到凭据文件,请进入 WSL 检查:cat $DataDir/credentials.txt" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " 常用操作(在 PowerShell 中):"
|
||||
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
|
||||
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
|
||||
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
|
||||
Write-Host ""
|
||||
Write-Host " 注意:WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
|
||||
Write-Host " 可创建一个登录时触发的计划任务执行:"
|
||||
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
|
||||
exit 0
|
||||
@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/home/newqqagent
|
||||
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
|
||||
Environment=JINA_PORT=18999
|
||||
Environment=JINA_DIMENSION=768
|
||||
Environment=OMP_NUM_THREADS=8
|
||||
Environment=MKL_NUM_THREADS=8
|
||||
Environment=TOKENIZERS_PARALLELISM=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,248 +0,0 @@
|
||||
# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
|
||||
|
||||
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10,
|
||||
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
|
||||
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
|
||||
>
|
||||
> 原始状态(修复前):**待修复**
|
||||
> 影响:Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`,80+ 次工具调用)
|
||||
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限),QQ 插件侧已是最小回执,改插件无效**
|
||||
|
||||
---
|
||||
|
||||
## 1. 现象
|
||||
|
||||
生产日志(`/home/newqqagent`,homed 运行期):
|
||||
|
||||
```
|
||||
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:32:06 ...
|
||||
18:32:14 ...
|
||||
(每 8~12 秒一条,payload 长度各异:387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…)
|
||||
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
|
||||
```
|
||||
|
||||
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
|
||||
- 不是 webhook 回声(15 分钟内只有 1 条真实入站中断)。
|
||||
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
|
||||
|
||||
---
|
||||
|
||||
## 2. 根因链(核心侧,三层)
|
||||
|
||||
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go`(`handleChannelOutput` 尾部):
|
||||
|
||||
```go
|
||||
if sendErr != nil {
|
||||
return nil, sendErr
|
||||
}
|
||||
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
|
||||
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
|
||||
return "ok", nil
|
||||
```
|
||||
|
||||
插件返回的是字符串 `"ok"`。
|
||||
|
||||
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
|
||||
|
||||
`internal/plugin/proc/plugin.go:336`(`(*Plugin).invokeOutput`):
|
||||
|
||||
```go
|
||||
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
|
||||
if err != nil { return nil, err }
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "sent"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object,落到这里
|
||||
}
|
||||
if _, ok := res["status"]; !ok {
|
||||
res["status"] = "sent" // ← 再兜底
|
||||
}
|
||||
return res, nil
|
||||
```
|
||||
|
||||
插件返回 `"ok"` → `json.Unmarshal` 进 `map[string]interface{}` 失败 → 核心合成 `{status: sent}`。
|
||||
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
|
||||
|
||||
### 2.3 核心把这个富回执喂给模型
|
||||
|
||||
`internal/agent/core/output.go:81`(HEAD / 部署中的 homed 行为):
|
||||
|
||||
```go
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
|
||||
// → "已通过 [qq] 通道发送: map[status:sent]"
|
||||
```
|
||||
|
||||
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
|
||||
|
||||
### 2.4 核心没有工具轮次硬上限(放大器)
|
||||
|
||||
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**:
|
||||
|
||||
```
|
||||
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
|
||||
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
|
||||
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
|
||||
```
|
||||
|
||||
`internal/agent/core/process.go:242` 的唯一终止条件是:
|
||||
|
||||
```go
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
```
|
||||
|
||||
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
|
||||
|
||||
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
|
||||
|
||||
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**:
|
||||
|
||||
```go
|
||||
for turn := 0; ; turn++ {
|
||||
for _, interrupt := range a.drainInterrupts() { ... }
|
||||
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
})
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`msgs` 于 `process.go:32` 在循环**外**创建,循环内只增不减:
|
||||
|
||||
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
|
||||
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`;
|
||||
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
|
||||
|
||||
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
|
||||
|
||||
**预期行为**:占位消息应当(a)仅在没有尾部 user 消息时补一条,或(b)补之前先移除上一条同类占位,保持至多一条;绝不能线性累积。
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有未完成/未部署的修复
|
||||
|
||||
| 文件 | 状态 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)` → `return "ok"`(含解释回声的注释) |
|
||||
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
|
||||
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
|
||||
|
||||
运行中的 `homed` 是 **Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
|
||||
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
### 4.1 (必须)让模型只看到最小回执
|
||||
|
||||
**方案 A(最小改动,已在工作区)**:`internal/agent/core/output.go`
|
||||
|
||||
```go
|
||||
// internal/agent/core/output.go:81
|
||||
// 成功回执:只返回极简标记,不回传完整插件响应。
|
||||
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
|
||||
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
|
||||
return "ok"
|
||||
```
|
||||
|
||||
**方案 B(同时修掉 proc 桥的伪造)**:`internal/plugin/proc/plugin.go:336`
|
||||
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
|
||||
|
||||
```go
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
|
||||
var scalar interface{}
|
||||
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
|
||||
return scalar, nil
|
||||
}
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
```
|
||||
|
||||
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map),语义正确。
|
||||
|
||||
### 4.2 (必须)工具循环硬上限
|
||||
|
||||
在 `internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`:
|
||||
|
||||
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
|
||||
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N,请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
|
||||
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
|
||||
|
||||
### 4.3 (建议)QQ 插件侧保持最小回执
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go` 的 `return "ok", nil` 是正确的,保留即可。
|
||||
**不要**再依赖插件侧修这个回声——见 §2.2。
|
||||
|
||||
### 4.4 (必须)修掉每轮重复追加的 user 占位
|
||||
|
||||
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
|
||||
|
||||
```go
|
||||
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
|
||||
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: continuationPlaceholder,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
|
||||
|
||||
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署前提与步骤
|
||||
|
||||
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
|
||||
|
||||
1. **确认工具链协议与要部署的 homed 一致**
|
||||
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
|
||||
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK(`third_party/homeagent-sdk` HEAD)重建全部插件。
|
||||
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`。
|
||||
2. **构建 homed**:`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
|
||||
3. **重启**:`systemctl restart homeagent.service`
|
||||
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
修复后,发一条会触发回复的 QQ 消息,应满足:
|
||||
|
||||
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`;
|
||||
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**;
|
||||
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
|
||||
4. `eventloop.go:426` 的 `text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
|
||||
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条(N=轮数),这正是 §2.5 的判据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关背景(避免误修)
|
||||
|
||||
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`),**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
|
||||
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
|
||||
- `internal/plugin/proc/plugin.go:122` 的 `invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。
|
||||
@ -1,124 +0,0 @@
|
||||
# 检索方案对比报告(2026-09-09)
|
||||
## 测试数据
|
||||
- 文档库:492 篇生产文档(过滤 108 条健康检查测试文档)
|
||||
- 媒体库:3 张生产图片(验证码、新闻截图、深色模式备忘录)
|
||||
- 文本查询:10 组(精确匹配、语义、跨语言、模糊表达)
|
||||
- 媒体查询:6 组(中文/英文查图片,3 张图片各 2 条)
|
||||
|
||||
---
|
||||
|
||||
## 一、文本检索对比(文档库)
|
||||
|
||||
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|
||||
|------|-------|-------|-----|----------|
|
||||
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
|
||||
| fastText(200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
|
||||
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
|
||||
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **Jina 的优势来自"短语语义"能力**:
|
||||
- "邮件代理是否已经成功接入" → TF-IDF rank 5,Jina rank 1
|
||||
- "升级安装 QQ 插件包" → fastText rank 169,Jina rank 1(margin +0.30)
|
||||
- "我所在城市的天气预报" → fastText rank 44,Jina rank 1
|
||||
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1,Jina rank 1(margin +0.33)
|
||||
|
||||
2. **TF-IDF 在精确匹配上不可替代**:
|
||||
- "长期文档记忆功能是否健康" → TF-IDF rank 3,Jina rank 1
|
||||
- "重新加载全部扩展组件" → TF-IDF rank 0(完全未命中),Jina rank 2
|
||||
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
|
||||
|
||||
3. **RRF 融合反而变差**:
|
||||
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
|
||||
- 原因:两种稀疏向量的排序在语义查询上高度重叠,RRF 无法弥补各自短板
|
||||
|
||||
---
|
||||
|
||||
## 二、图片检索对比(同 3 张图片,6 条查询)
|
||||
|
||||
| 方案 | Hit@1 | MRR | 平均 margin |
|
||||
|------|-------|-----|-------------|
|
||||
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
|
||||
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
|
||||
|
||||
### 逐条对比
|
||||
|
||||
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|
||||
|------|-----------|-------------|-----------|-------------|
|
||||
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
|
||||
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
|
||||
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
|
||||
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
|
||||
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
|
||||
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **中文文本→图片**:Jina 明显优于 CLIP(MRR 0.833 vs 0.611)
|
||||
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
|
||||
- Jina 最差也是 +0.045,正样本始终高于负样本
|
||||
|
||||
2. **新闻截图是共同弱点**:
|
||||
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
|
||||
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
|
||||
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
|
||||
|
||||
3. **margin 的实际意义**:
|
||||
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
|
||||
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
|
||||
- 但两者的 margin 都很小(< 0.1),生产环境仍需阈值校准
|
||||
|
||||
---
|
||||
|
||||
## 三、延迟与资源
|
||||
|
||||
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|
||||
|------|-------------|----------|------|
|
||||
| TF-IDF | 0.3ms | <1s | ~50MB |
|
||||
| fastText | 8.3ms | <1s | ~200MB |
|
||||
| CLIP ONNX | 26ms | N/A | ~600MB |
|
||||
| Jina v5-omni CPU | 39.9ms | 78s(492篇) | ~4GB |
|
||||
|
||||
---
|
||||
|
||||
## 四、结论与建议
|
||||
|
||||
### 核心判断
|
||||
|
||||
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|
||||
|------|-----------------|------|--------------|
|
||||
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
|
||||
| 文本语义检索 | ★★ | N/A | ★★★★★ |
|
||||
| 中文文本→图片 | 无能力 | ★ | ★★★★ |
|
||||
| 英文文本→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 图片→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
|
||||
| 延迟 | ★★★★★ | ★★★ | ★★ |
|
||||
|
||||
### 架构建议
|
||||
|
||||
1. **保留 TF-IDF 作为精确召回的一级通道**:
|
||||
- 0.3ms 延迟不可替代
|
||||
- Hit@5 70% 证明在关键词匹配场景仍有价值
|
||||
- 特别是"插件安装"、"设备查询"这类精确操作指令
|
||||
|
||||
2. **用 Jina 替换 fastText + CLIP 的稠密通道**:
|
||||
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
|
||||
- 统一空间消除三条通道的维护成本
|
||||
- 中文文本→图片从"无法检索"提升到"可检索"
|
||||
|
||||
3. **两路融合:TF-IDF + Jina RRF**(而非 TF-IDF + fastText RRF):
|
||||
- TF-IDF 精确匹配 + Jina 语义覆盖
|
||||
- RRF 避免跨空间分数归一化问题
|
||||
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
|
||||
|
||||
4. **图片检索仍需阈值校准**:
|
||||
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
|
||||
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
|
||||
|
||||
### 下一步
|
||||
|
||||
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
|
||||
- 用更多生产图片标定 Jina 的图片检索阈值
|
||||
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代(L0 相关性计算)
|
||||
@ -1,6 +1,6 @@
|
||||
# Git 分支管理规范
|
||||
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七),2026-09-13 修订(发布线路牌随 patch 推进 + 版本号不得固化落库)。
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七)。
|
||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
||||
|
||||
@ -63,11 +63,6 @@ main ──────────────── E ────────
|
||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
||||
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||
- **发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走**(`release/v1.1.x` 末态
|
||||
`1.1.1`、`release/v1.2.x` 末态 `1.2.2`、`release/v1.3.x` 末态 `1.3.6`)。
|
||||
⚠️ **不要只用构建参数(`-ldflags -X ...meta.Version`)打版本号而不改源码**:
|
||||
二进制自称 1.3.4、源码路牌还停在 1.3.0,追溯时对不上账(2026-09-13 真实踩过,
|
||||
1.3.1–1.3.4 四个 patch 都是这么打的,`release/v1.3.x` 的路牌一直没动)。
|
||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||
|
||||
### 4. 三级发布通道(alpha / beta / 正式)
|
||||
@ -135,82 +130,28 @@ main ──────────────── E ────────
|
||||
|
||||
---
|
||||
|
||||
### 7. 开发者文档的发布归属(以 rel 分支的形态为准)
|
||||
|
||||
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
|
||||
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
|
||||
|
||||
为什么不能直接改 main:
|
||||
|
||||
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
|
||||
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
|
||||
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
|
||||
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
|
||||
|
||||
做法:
|
||||
|
||||
```bash
|
||||
git switch release/v1.2.x
|
||||
# 按这一版口径修改:版本号、当前工具名(hmapdev)、已移除机制不再写成现行
|
||||
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
|
||||
git commit -m "docs: 按 v1.2.x 口径修正 …"
|
||||
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
```
|
||||
|
||||
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
|
||||
不得让读者以为它已发布。
|
||||
|
||||
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**:
|
||||
|
||||
| 现象 | 后果 |
|
||||
|---|---|
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||
| 人格文本在**播种时**就把 `meta.Version` 插值写进配置库 | 装机那天即冻住版本号:内核 1.3.x 的实例仍向用户自称 `v1.0.3`(2026-09-13 用户当场发现) |
|
||||
| 发布线只用 `-ldflags -X` 打版本、源码 `meta.Version` 不动 | 二进制自称 1.3.4、源码路牌仍是 1.3.0,溯源对不上账;同类还有给 SDK 误发 patch tag(§七.1 要求 patch 位恒为 `.0`) |
|
||||
|
||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||
要么**在渲染时**用 `meta.Version` 插值(系统提示词占位符 `{{kernel_version}}` 即此机制),
|
||||
要么要求读运行时快照,并用测试钉住(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
**"插值"指的是每次组装时现算,不是把算好的结果固化进配置库/文档** ——
|
||||
固化过的版本号与写死没有区别,而且更难发现。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-13 更新)
|
||||
## 三、当前分支对齐(2026-09-04 执行)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 **`1.4.0`** —— `1.3.0` 已归发布线所有) | ✅ 保持 |
|
||||
| `release/v1.3.x` | **本条发布线**,`meta.Version` = **`1.3.6`**(该线最后一个 patch);承载 `v1.3.1`…`v1.3.6`;vendored SDK 定版 `1.3.0` | ✅ 保持 |
|
||||
| `release/v1.2.x` | 承载 `v1.2.0` / `v1.2.1` / `v1.2.2`,末态 `meta.Version` = `1.2.2` | 📦 已退役(§2.6),保留供追溯 |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1`,末态 `meta.Version` = `1.1.1` | 📦 已退役,保留供追溯 |
|
||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||
|
||||
> - `v1.3.0` 是**已撤回**的坏 tag:设备输出通道名 `device/<id>` 里的 `/` 拼进 LLM 函数名
|
||||
> `output_send__device/<id>`,上游按**整条请求** 400,全量对话不可用(修复见 `v1.3.1`)。
|
||||
> - `feature/*` 分支(`input-semantics`、`multimodal-embedding`、`memory-media`、`plugin-proc-migration`)
|
||||
> 均已合入并删除。
|
||||
| `main` | 含全部 hotfix(逐个 cherry-pick),`meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.0.x` | 承载 v1.0.0 / v1.0.1 / v1.0.3 全部 tag | ✅ **由 `release/v1.0.1` 重命名而来**(2026-09-04) |
|
||||
| `release/v1.0.0` | `9b92a04`,已被 1.0.x 线完全包含(`merge-base --is-ancestor` 验证通过) | 🗑️ **已删除**(本地 + 远端),tag `v1.0.0` 保留全部历史 |
|
||||
| `release/v1.0.1` | 旧 patch 号命名 | 🗑️ **已重命名为 `release/v1.0.x`**(远端旧名删除) |
|
||||
| `feature/memory-media` | 记忆系统媒体(多模态)支持,进行中 | ⏳ 完成后合回 main 并删除 |
|
||||
| `feature/plugin-proc-migration` | 已合入 main(`525aa1f`) | ⏳ 待删(规范要求合回后删除) |
|
||||
| `release/v1.1.x` | 承载 v1.1.0 / v1.1.0-beta.1 / v1.1.1 全部 tag | ✅ 1.1 线的唯一发布分支 |
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.4.0`**)——`1.3.0` 已随核心**正式** tag 定版(§七.3),故路牌推进 | ✅ 保持 |
|
||||
| `release/v1.3.x` | `meta.Version` = `1.3.0`,承载 tag `v1.3.0`(5 平台 `hmapdev` + `SHA256SUMS` + 4 个源码归档) | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | `meta.Version` = `1.2.0`,承载 tag `v1.2.0`;同线的 `v1.2.1` 属**误发的 patch tag**(§七.1 违规),其 gitcode 条目标题已标注「(已撤回)」 | 📦 已退役 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应(已退役) |
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心 `release/v1.1.x` 对应 |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
|
||||
> ❗**SDK 仓不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`。
|
||||
> 2026-09-13 曾误发 `v1.3.1`(文档用),**已撤回**(远端 tag 已删,本地 commit `05b7a20` 可恢复);
|
||||
> `v1.2.1` 是同一类历史遗留。
|
||||
> ❗**现网 SDK store 例外**:本机 `hmapdev` store 用 `--from` 装的是 SDK 源码构建的 1.3.0,
|
||||
> 与 tag 内容一致。
|
||||
|
||||
### 1.0.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | 说明 |
|
||||
@ -233,18 +174,6 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
|
||||
> 已知代价:beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
|
||||
|
||||
### 1.2.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | SDK | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `v1.2.0-beta.1` | `215804c` | beta | 不发 | 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存(RPC 协议 **2**,与 1.x 不兼容)。按 §七.2,beta 不伴随 SDK 发版 |
|
||||
| (正式 tag 待打) | — | — | — | 试运行 beta 无回退问题后打 `v1.2.0`,并同步 SDK 仓 `release/v1.2.x` + `v1.2.0` |
|
||||
|
||||
> 1.2.x 与存量插件**不兼容**:RPC 协议升到 2(fd3 布局改变),存量外部插件必须用
|
||||
> 新版 plugindev 重编为 `plugin.bin`——**不支持滚动升级**,内核与插件须同批重建、同批安装。
|
||||
> 按 §2.4,跳级直发正式版需在发布说明里列明「单点修复 / 反向验证 / 全类审计」三项;
|
||||
> 本次改动面大(统一多模态向量空间 + 协议 2 + 数据面全量迁移),不满足跳级条件。
|
||||
|
||||
---
|
||||
|
||||
## 四、现网部署与版本对应(运维纪律)
|
||||
@ -361,93 +290,13 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
||||
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
||||
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
||||
4. 上传 5 平台 `hmapdev`(插件开发工具链)产物 + `SHA256SUMS`。
|
||||
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
|
||||
|
||||
同一中版本内的后续核心 patch(1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0,
|
||||
没有新东西要发。只有接口再次变化并进入下一个中版本时,SDK 才发 1.2.0。
|
||||
|
||||
### 5. 发版产物清单(可复现)
|
||||
|
||||
**推 tag ≠ 完成发版**:还要打包产物、建 gitcode release 条目、上传附件。
|
||||
2026-09-13 出现过"tag 推了、release 条目和产物都没有"的情况(`v1.3.1`–`v1.3.6`),
|
||||
事后才补 —— 记录在此以免重犯。
|
||||
|
||||
**核心仓**(在 tag 的**干净 worktree** 里构建,不要用带其它会话改动的工作区):
|
||||
|
||||
| 产物 | 生成方式 |
|
||||
|---|---|
|
||||
| `homeagent_<版本>_linux_amd64.tar.gz` | `VERSION=<版本> bash deploy/packaging/package-linux.sh amd64` |
|
||||
| `homeagent-client_<版本>_amd64.deb`、`-server`、`-full` | 同上;server/full 需要 Chinese-CLIP 与 ONNX Runtime 资产目录(`build/model-assets/`、`build/runtime-assets/`) |
|
||||
| `SHA256SUMS` | **全部产物生成完毕之后**统一计算(边打边算会漏掉后生成的包) |
|
||||
| 4 个源码归档(`.zip` / `.tar.gz` / `.tar.bz2` / `.tar`) | gitcode 打 tag 时自动生成,无需上传 |
|
||||
|
||||
**SDK 仓**:`VERSION=<版本> bash package/build.sh all hmapdev` ⇒
|
||||
`hmapdev_{linux,darwin}_{amd64,arm64}` + `hmapdev_windows_amd64.exe` + `SHA256SUMS`。
|
||||
|
||||
**Windows 安装器(WSL 安装型)**:`homed` **不再装到 Windows**(插件体系依赖 fd 继承与
|
||||
共享内存区段内偏移解引用,Windows 句柄模型表达不了),安装器的职责是**引导 WSL2 并把
|
||||
Linux 包送进发行版里安装**。产物 `HomeAgent_v<版本>_{Server,Client,Full}_win64.exe`:
|
||||
|
||||
```bash
|
||||
# 先有 Linux 包(安装器送进 WSL 的就是它),再打安装器
|
||||
VERSION=<版本> bash deploy/packaging/package-linux.sh amd64
|
||||
VERSION=<版本> bash deploy/packaging/package-windows.sh server amd64 # 只装内核+CLI 的 WSL 场景
|
||||
VERSION=<版本> bash deploy/packaging/package-windows.sh client amd64 # 需要 Windows GUI payload
|
||||
```
|
||||
|
||||
- `package-windows.sh` 会按**变体只放对应的那一个 deb** 进 payload。为什么:WSL 侧脚本只取
|
||||
payload 里第一个 `.deb`(`install-via-wsl.ps1`),而 server/full 的 deb 各带 ~719MB 模型 ——
|
||||
照 `build.sh` 的 `stage_linux_payload`(把所有 deb+tar 全塞)打出来会是 ~2.4GB 的安装器。
|
||||
- `client`/`full` 变体还带 Windows GUI,需要 electron-builder 产出
|
||||
`build/homeagent-gui-win32-x64/`;缺它就**明确失败**,不产出"装完没有界面"的半残包。
|
||||
|
||||
**上传**(两仓同一个脚本):
|
||||
|
||||
```bash
|
||||
# 核心仓
|
||||
python3 deploy/scripts/upload_assets.py <tag> <token> # 默认上传 dist/release 下可识别的产物
|
||||
# SDK 仓(hmapdev_* 没有扩展名,不会被自动识别 ⇒ 必须显式列文件名)
|
||||
GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
||||
python3 deploy/scripts/upload_assets.py <tag> <token> hmapdev_linux_amd64 ...
|
||||
```
|
||||
|
||||
- 脚本先向 `releases/<tag>/upload_url` 取 **OBS 预签名 URL** 再 PUT ⇒ **release 条目必须先存在**;
|
||||
- alpha/beta 的产物可以上传,但必须在 release 条目上勾选**预发布**标志(§2.4);
|
||||
- 校验和必须覆盖**全部**附件,否则等于没有校验。
|
||||
- ❗❗**gitcode 的 release 附件是"同名只写一次"**(实测:同名两次不同内容,下载始终是第一次那份;
|
||||
且没有可用的删除接口 —— release JSON 不含 `id`,附件列表接口 404,`DELETE .../attach_files/<名>`
|
||||
只要数字 id)。**后果**:`SHA256SUMS` 若第一次上传时只覆盖了部分平台,之后**永远改不回来** ——
|
||||
1.3.1–1.3.10 都踩了:首次只传了 linux/amd64,后来补 arm64/darwin/win 时合并重传**全部无效**,
|
||||
线上那份至今只有 4 项。
|
||||
⇒ **纪律:首次上传 `SHA256SUMS` 前必须已打包全部平台**;分批上传时**先传产物、最后传校验和**,
|
||||
且校验和只传一次。补救只能换名(如 `SHA256SUMS.complete`)或重建 release(要重传全部产物)。
|
||||
- ❗**流水线脚本必须 `set -e`(或显式检查每步)**:否则某一步失败(例如驱动脚本在 tag 里
|
||||
不存在)之后它仍会继续跑到上传,把**半成品校验和**推上去覆盖全量的那份。
|
||||
(实测:v1.3.10 的校验和被 4 项覆盖掉,只能重建。)
|
||||
- ❗**在 tag 的 worktree 里构建时,驱动脚本要么已进该 tag,要么支持目录覆盖**:
|
||||
新补的脚本只存在于 main,去 tag 的 worktree 里调就是 `No such file or directory`。
|
||||
现 `package-windows.sh` 支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,
|
||||
可以"用主仓的脚本 + 产物目录指向 worktree"。
|
||||
- ❗**分批上传时,后一轮必须在全量产物上重算 `SHA256SUMS`**,不能只算本轮那几个文件:
|
||||
同名附件会**覆盖**前一轮的校验和(实测:先传 amd64 的 9 个资产,后补 arm64 时
|
||||
只算了 arm64 的 4 个,结果 amd64 的校验和从 release 上消失 ⇒ 已下载的包失去校验依据,
|
||||
只能把产物全部下回来重算)。要么一次打包全部平台再算,要么后一轮把**已上传的**
|
||||
也纳入计算。
|
||||
|
||||
### 4. 版本号在两仓 main 上的含义
|
||||
|
||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
|
||||
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
|
||||
|
||||
**但两仓「同步推进」是有条件的**(这一点曾导致误判,现补写清楚):
|
||||
推进的前提是**该中版本已经正式发布过**。具体到当前:
|
||||
|
||||
- 核心:切出 `release/v1.2.x` 后,1.2.0 就归发布线所有,main 立即推进到 `1.3.0`;
|
||||
**即使 1.2.0 目前只有 beta tag**(beta 不上现网,但发布线已占住这个号)。
|
||||
- SDK:因为 §七.2 **beta 不发 SDK**,SDK 1.2.0 要等核心的**正式** tag 才定版、
|
||||
建 `release/v1.2.x`、打 `v1.2.0`(§七.3)。在那之前,SDK 的「下一个未发布中版本」
|
||||
仍然是 `1.2.0`,其 main 不得越过它。
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
@ -1,631 +0,0 @@
|
||||
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
|
||||
|
||||
> **模型更正(2026-09-13,据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12)**
|
||||
>
|
||||
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名
|
||||
> (qq→L2、cli→L3)由内核推断级别。那是错的。正确模型是**两类别 + 四级**:
|
||||
>
|
||||
> | | 中断输入(interrupt) | 排队输入(queued) |
|
||||
> |---|---|---|
|
||||
> | 注入 API | `InjectInterrupt*` | `InjectText*` / `InjectInputSync*` / 内核自循环 |
|
||||
> | 级别 | L1–L4 | **无级别** |
|
||||
> | 定位 | 需要及时处理 | 不需要及时处理 |
|
||||
> | 可被谁打断 | 仅**严格更高级**的中断 | **任何**中断 |
|
||||
>
|
||||
> 级别(“这项工作有多不能等”)由来源在 `InjectOptions.Priority` 里声明。
|
||||
> L1–L3 任何插件可声明;**L4 是“立即打断”能力**,只有**内核自身**(panic /
|
||||
> 内核事件 selfip,经 `raiseKernelInterrupt`)与**内核级插件**(编译期内置插件,
|
||||
> 如 WebUI 的终止按钮)能用。外部插件的 L4 会被夹到 L3。
|
||||
> 类别由**用哪个注入 API**决定,与通道名无关——QQ 走的是 `InjectInterruptTextOpts`,
|
||||
> 所以它是**低级别中断(L1)**,不是排队输入。
|
||||
|
||||
> 分支:`feature/input-semantics`
|
||||
> 状态:**设计稿 v1**(待确认项见 §12,未确认处按 §12 的「默认取值」推进)
|
||||
> 影响面:`internal/agent/core`、`internal/agent/io`、`internal/sdk`(**仅内部**)
|
||||
> 公开 SDK:**v1 不改**(`third_party/homeagent-sdk/sdk/` diff 必须保持为 0,理由见 §13)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景:现状与要解决的问题
|
||||
|
||||
现有两条输入语义(见审查结论),都建立在**串行 `eventLoop`** 之上:
|
||||
|
||||
| 语义 | 入口 | 路径 |
|
||||
|---|---|---|
|
||||
| 排队 | `InjectText` / `InjectInput*` | `io.inputCh` → `eventLoop` → `processInput` → `process()` |
|
||||
| 中断 | `InjectInterruptText` / `InjectInterrupt*` | `io.interruptCh` → `interceptLoop` → `cancelLLM` + `a.interceptCh` |
|
||||
|
||||
已确认的具体问题(均为源码事实):
|
||||
|
||||
1. **队头阻塞**:`eventLoop` 单 goroutine,`process()` 全程持 `a.mu`(`internal/agent/core/process.go:103-104`),一轮对话(含 N 轮工具)期间后续输入全部排队。
|
||||
2. **中断只在一种时刻成立**:`interceptLoop` 有三条降级回排队的路径——当前无 LLM 在跑、当前轮是 `_consolidation_`、`a.interceptCh` 满(`internal/agent/core/eventloop.go:74-116`)。它不是独立管线,而是"抢占 + 三次降级"。
|
||||
3. **回执误投风险**:`ResponseCh` 只由全局 `emitResponse` 写(`eventloop.go:475`),无任务归属。一旦引入抢占,中断的回执会写进被挂起任务的等待者。
|
||||
4. **断链点静默**:`processInput` 有多条提前 `return` 而不 `emitResponse` 的路径(`resolveInput` 失败 `:309`、10s 去重命中 `:317`、`_consolidation_` `:326`),同步调用方(`cli`、`clawhubadapter` 无超时)永久挂起。
|
||||
5. **背压策略分裂**:`inputCh` 满 → 阻塞发送方;`selfInputCh` 满 → 静默丢弃;`a.interceptCh` 满 → 降级回 `inputCh`。
|
||||
6. **假取消**:工具超时只是放弃等待,内层继续执行且副作用照做(`internal/agent/core/toolcall.go:33-45`)。
|
||||
7. **不可观测**:没有任何"当前在跑什么、谁被挂起、降级了多少次"的统一入口。
|
||||
|
||||
**目标**:把上述隐式行为收敛成一个**显式、可 dump、可单测、可断言**的调度器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语与模型
|
||||
|
||||
```
|
||||
Task = { id, class, level, origin, frame, state, enqueueAt, preemptCount, responseCh }
|
||||
class: queued | interrupt // **类别由注入 API 决定,与通道名无关**
|
||||
queued —— 无级别;用于“不需及时处理”的场景;可被**任何**中断打断
|
||||
interrupt —— 带级别 L1..L4;仅被**严格更高级**的中断打断(被打断则压入中断栈)
|
||||
level: 仅 interrupt 有意义(queued 恒无级别,effectiveLevel 视作 0)
|
||||
state: ready | running | suspended | done
|
||||
|
||||
TaskFrame = {
|
||||
msgs []Message // 本任务自己的 LLM 消息序列
|
||||
step Step // 下一个要执行的 step(安全点游标)
|
||||
turn int // 已完成的工具轮数
|
||||
toolIdx int // 当前工具批内的下标
|
||||
toolResults []ToolResultItem
|
||||
toolsUsed []string
|
||||
lastBatchReplyOnly bool
|
||||
stageCtx *sdk.StageContext
|
||||
budget TokenBudget
|
||||
outputChannel string // 该任务的输出通道
|
||||
responseCh chan<- *OutputEvent // 任务级回执信道(可为 nil)
|
||||
startedAt time.Time
|
||||
input string // 触发本任务的输入文本(COMMIT 时写记忆)
|
||||
noMemory bool
|
||||
originSource string
|
||||
}
|
||||
|
||||
Step(枚举,顺序执行,步与步之间是安全点):
|
||||
S_PREPARE 构建 msgs / 应用中断标记 / 合并 stage 上下文
|
||||
S_LLM LLM 流式调用(**可抢占**:cancel 即丢弃)
|
||||
S_POST StagePostAction
|
||||
S_LLM_JUDGE 无 tool_call → 去 S_BEFORE_OUTPUT;有 → 去 S_TOOL_BEGIN
|
||||
S_TOOL_BEGIN replyOnly 判定 + 取当前 tc
|
||||
S_TOOL_BEFORE StageBeforeToolcall
|
||||
S_TOOL_EXEC 工具执行(**临界区,不可抢占**)
|
||||
S_TOOL_AFTER StageAfterToolcall
|
||||
S_TOOL_NEXT 批内下一个 / 批结束 → S_LLM
|
||||
S_BEFORE_OUTPUT / S_AFTER_OUTPUT
|
||||
S_COMMIT context.Append + emitMemoryCandidate(**原子,不可抢占**)
|
||||
S_FINISH 写 responseCh、发事件
|
||||
```
|
||||
|
||||
四个容器(**不是**“三集合并成一个比较器”):
|
||||
|
||||
| 容器 | 内容 | 取出规则 |
|
||||
|---|---|---|
|
||||
| `interruptQueues[1..4]` | **中断队列**,每条队列一个级别 | 从 L4 到 L1 依次扫描;同级 FIFO |
|
||||
| `immediate` | 刚抢占成功的那一条中断(**至多一个**) | 最先取出——抢占必须**立即生效** |
|
||||
| `queue` | **排队输入**形成的新任务 | 纯 FIFO(无级别可比) |
|
||||
| `suspendStack`(**中断栈**) | 被打断、保存了现场的任务 | **LIFO,只比栈顶**;栈内不做重排 |
|
||||
|
||||
> 用词(已更正):它**就是中断栈**。用户明确存在「中断被中断」的场景,被打断的现场必须压栈;
|
||||
> 因此恢复纪律是**严格 LIFO(只比栈顶)**,栈内不做优先级重排。
|
||||
> 早期稿把它写成“不是栈、按优先级取”是错的。
|
||||
>
|
||||
> 早期稿还让 `immediate` 与别的容器共用同一个比较器,于是出现“抢占成功后,
|
||||
> 抢占者与被挂起者同级 → 原任务被立刻选回 → 抢占空转”——为此打的
|
||||
> “同级 pending 优先”补丁已删除:抢占者根本不进队列。
|
||||
|
||||
---
|
||||
|
||||
## 3. 优先级(只属于中断)
|
||||
|
||||
### 3.1 两类别 + 四级
|
||||
|
||||
**类别(`TaskClass`)由注入 API 决定,与通道名无关**:
|
||||
|
||||
| 类别 | 注入入口 | 级别 | 可被谁打断 |
|
||||
|---|---|---|---|
|
||||
| `queued` 排队 | `InjectText*` / `InjectInputSync*` / `InjectInputMedia*` / 内核自循环(`selfInputCh`) | **无** | **任何**中断(L1 也能) |
|
||||
| `interrupt` 中断 | `InjectInterrupt*` | L1–L4 | 仅**严格更高级**的中断 |
|
||||
|
||||
**级别(`Level`)语义是“这项工作有多不能等”**:
|
||||
|
||||
| Level | 名称 | 语义 | 典型来源 |
|
||||
|---|---|---|---|
|
||||
| `L4` | CRITICAL | 内核紧急 | **内核独占**:panic 中断、内核事件中断(selfip) |
|
||||
| `L3` | INTERACTIVE | 需及时处理 | 时钟/定时器到达、终端输出、交互输入 |
|
||||
| `L2` | MESSAGE | 一般提醒 | 插件希望尽快看到、但不紧急的提示 |
|
||||
| `L1` | BACKGROUND | 完全可等 | 异步消息(QQ/微信)、批量通知 |
|
||||
|
||||
- **`queued` 没有级别**:它本就是“不需及时处理”的那一类,
|
||||
所以“可被任何中断打断”不是漏洞而是定义(`effectiveLevel(queued) == 0`)。
|
||||
- **默认级 = `L1`**:未声明一律最低级(“显式才是特权”,新插件不会默认拿到抢占权)。
|
||||
|
||||
### 3.2 级别从哪来
|
||||
|
||||
| 来源 | 可达级别 | 入口 |
|
||||
|---|---|---|
|
||||
| 普通插件(外部,独立进程/动态库) | L1–L3 | `InjectOptions.Priority`(空/非法 → L1;L4 被夹到 L3) |
|
||||
| **内核级插件**(编译期内置,`init()` 自注册) | L1–**L4** | 同上;L4 用于实现**中断能力**,例如 WebUI 的终止按钮 |
|
||||
| 内核自身 | L4 | `(*Agent).raiseKernelInterrupt`(panic / selfip) |
|
||||
|
||||
- ❌ **不是运维可调项**。不引入 `core.agent.priority.<channel>` 这类配置键,
|
||||
也不把 `PriorityLookup` 做成可注入的策略表。
|
||||
- ✅ 插件**可以声明**自己中断的级别(这不是“把内核内部属性外化”,
|
||||
而是调用方声明它自己那件事有多不能等)。
|
||||
- ✅ **L4 给“立即打断”能力**:内核自身(panic / selfip)与**内核级插件**
|
||||
(编译期内置插件,如 WebUI 终止按钮)可声明。为什么必须给内置插件:
|
||||
用户按下终止按钮时,内核需要一条能立刻打断当前任务的中断;这条能力不能给
|
||||
外部插件,否则任何第三方插件都能随时打断用户的一切工作。
|
||||
- **判据是“这个插件是不是编译期内置”,不是它自报的名字**:
|
||||
- 第一道闸在 **proc 桥**(外部进程的唯一入口):走它的一律把 L4 夹到 L3。
|
||||
在这里夹而不是只按 `source` 判,是因为 `source` 是插件自报字段、可以冒名。
|
||||
- 第二道闸在 **core**:`isKernelLevelSource(source)` 查
|
||||
`pluginReg.IsBuiltinPlugin`,只有内置工厂才承认 L4(纵深防御)。
|
||||
- `source` 的约定是 `插件名` 或 `插件名/实例`(如 `webui/<deviceID>`),
|
||||
判据取第一段——否则带设备身份的 WebUI 来源会被误判成外部插件。
|
||||
|
||||
### 3.3 抢占判据
|
||||
|
||||
```go
|
||||
effectiveLevel(queued) == 0
|
||||
canPreempt(incoming, running) = incoming.Class == TaskInterrupt
|
||||
&& effectiveLevel(incoming) > effectiveLevel(running)
|
||||
```
|
||||
|
||||
因为 `queued` 的有效级恒为 0,这一个比较同时覆盖两条规则:
|
||||
|
||||
```
|
||||
running 是排队任务 → 任何中断(≥L1)都抢占
|
||||
running 是中断 Li → 只有 Lj > Li 的中断抢占(严格大于)
|
||||
incoming 是排队输入 → 永不抢占
|
||||
```
|
||||
|
||||
**严格大于才抢占**;相等一律入队——这条保证确定性,也是“较低无法打断较高”的字面实现。
|
||||
|
||||
## 4. 调度规则
|
||||
|
||||
### 4.1 选择函数(四容器 · 固定次序)
|
||||
|
||||
任务结束、或运行任务到达安全点且存在待处理抢占请求时,执行:
|
||||
|
||||
```
|
||||
1. immediate 非空 → 取它(刚抢占成功的中断,抢占必须立即生效)
|
||||
2. 中断队列非空 → 取 L4→L1 中最高级非空队列的队头(同级 FIFO)
|
||||
3. 中断栈非空(与 2 比高) → 栈顶有效级 ≥ 队头级别 ? 弹栈顶 : 取队头
|
||||
4. queue 非空 → 取队头(纯 FIFO)
|
||||
5. 都没有 → 空闲(阻塞等新输入 / 新中断)
|
||||
```
|
||||
|
||||
- **中断栈只把栈顶**放进比较(严格 LIFO)——栈内更老的任务即使因饥饿防护
|
||||
提升了有效级,也不得越过栈顶;“后被打断的先恢复”才是栈语义。
|
||||
- 第 3 步就是用户给的规则:“先判断中断队列是否为空,同时判断中断栈中任务的
|
||||
优先级,哪个优先级高取出哪个”。栈顶是 `queued`(有效级 0)时,任何中断都赢。
|
||||
- 第 1 步的存在,使“抢占者与被抢占者同级”这个比较**根本不会发生**:
|
||||
抢占者不经队列。这是删除早期“同级 pending 优先”补丁后的正确形态。
|
||||
- 排队任务只在中断与挂起现场都处理完后才执行——这正对应“排队输入用于
|
||||
不需要及时处理的场景”。
|
||||
|
||||
### 4.2 安全点(可切换点)
|
||||
|
||||
**只有 step 与 step 之间是安全点。** 明确:
|
||||
|
||||
- ✅ `S_LLM` 之后、`S_TOOL_BEFORE` 之后、`S_TOOL_EXEC` **之后**、`S_TOOL_AFTER` 之后……
|
||||
- ❌ `S_TOOL_EXEC` **执行中不是安全点**:工具副作用不可回滚,无法"保存现场"。
|
||||
|
||||
### 4.3 临界区
|
||||
|
||||
```
|
||||
CriticalSection:step 标记 nonPreemptible = true,或任务进入声明区间
|
||||
```
|
||||
|
||||
- 实现方式:**调度器在临界区期间不求值抢占**(协作式单线程下即"不 yield"),不使用 `sync.Mutex`。
|
||||
- 资源互斥不用锁,而是**调度器持有的资源表**(若某资源被 running 占用,则不会选出同样占用它的任务)——纯数据判定,天然无优先级反转。
|
||||
- **v1 临界区清单**(显式列出,避免"隐式临界区"):
|
||||
|
||||
| 临界区 | 理由 |
|
||||
|---|---|
|
||||
| `S_TOOL_EXEC`(单次工具执行全程) | 副作用不可回滚;插件 RPC 不可取消 |
|
||||
| `S_COMMIT` | 上下文/记忆写入必须原子 |
|
||||
| 需 ONNX 嵌入的 `S_PREPARE` 片段 | ONNX `Run` 不可取消 |
|
||||
| 媒体 CAS 落盘 | 同上 |
|
||||
| 显式声明的 `_consolidation_` 类任务 | 记忆一致性 |
|
||||
|
||||
- 临界区期间到达的抢占请求**不丢失**:按级别进入中断队列,在临界区结束后的第一个安全点重新求值。
|
||||
|
||||
### 4.4 背压(v1 统一为一种)
|
||||
|
||||
- `readyQueue` 有界(默认 256,可配)。
|
||||
- 满时:**阻塞发送方**(与现状 `inputCh` 一致,避免静默丢用户输入),但必须**计数并打日志**。
|
||||
- 中断队列合计有界(默认同 `maxQueue`);满时**丢弃最低级别里最老的一条并计数**(中断是提示性输入,宁可丢旧保新)。
|
||||
- 中断栈帧数上界是**结构推论 = 4**(见 §6.3),不是配置项。
|
||||
|
||||
---
|
||||
|
||||
## 5. 中断语义
|
||||
|
||||
### 5.1 中断产生线程的职责(钉死)
|
||||
|
||||
`interruptLoop` 只做三件事,**绝不触碰任何 TaskFrame**:
|
||||
|
||||
```
|
||||
① 从 io.interruptCh 收中断 → 定级(读 payload["priority"],插件声明 L1..L3)
|
||||
② 决策(scheduler.registerInterrupt 内):
|
||||
canPreempt(incoming, running) 且 running 不在临界区
|
||||
→ 置让位信号 + 把 incoming 放进 immediate 槽,并返回 true(调用方据此
|
||||
取消当前可取消的 step,即 LLM 流式)
|
||||
否则
|
||||
→ 按级别进入对应的中断队列
|
||||
③ 唤醒调度器(scheduler.wake,cap 1)
|
||||
```
|
||||
|
||||
共享面仅三处:让位信号(`preemptArmed`/`preemptLevel`)、中断队列、`critical` 原子标志。
|
||||
**帧的保存与恢复只能由调度器做。**
|
||||
|
||||
### 5.2 三种情形的统一
|
||||
|
||||
现状的三条降级路径在新模型里不再需要特殊分支:
|
||||
|
||||
| 情形 | 旧模型 | 新模型 |
|
||||
|---|---|---|---|
|
||||
| LLM 在跑,正常 | 真抢占(同轮 continue) | 真抢占:`S_LLM` 取消,任务 A **压入中断栈**,中断任务 B 从 `S_PREPARE` 启动 |
|
||||
| LLM 没在跑 | 降级为排队 | B 按其级别入中断队列(空闲时即被 `wake` 唤醒并选出) |
|
||||
| `_consolidation_` 中 | 降级为排队 | `_consolidation_` 是后台**临界区**(且它是排队任务)→ B 入中断队列,临界区结束后求值 |
|
||||
| `a.interceptCh` 满 | 降级为排队 | 不存在该队列;中断队列有界,满则丢最低级别里最老的一条 |
|
||||
|
||||
### 5.3 中断任务与被打断任务的关系(**已定:D1 = 方案 B**)
|
||||
|
||||
> 用户明确:
|
||||
> *“中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall),
|
||||
> 然后中断在**上个任务前的那个完整状态**上开始运行。中断运行结束,再把被挂起的
|
||||
> 任务与其上下文现场**加载回中断任务之上**,并继续运行。”*
|
||||
|
||||
因此语义是:
|
||||
|
||||
1. **被挂起任务的现场 = 它自到达以来累积的全部上下文(含 toolcall 结果)**,
|
||||
原样保存在 `TaskFrame` 里。
|
||||
2. **中断任务从「上一个任务之前的完整状态」开始运行**——它**看不到**被打断
|
||||
任务的任何部分进展。等价于:中断任务就是一个普通新任务,正常走 `S_PREPARE`
|
||||
(重建 system prompt + timeline + 自己的输入)。
|
||||
3. **中断结束后,把被挂起任务与其现场加载回「中断任务之上」再继续**:
|
||||
中断已提交的那段上下文留在**下面**(成为重建前缀的一部分),本任务自己的
|
||||
现场接回**其上**。
|
||||
|
||||
实现对应(`internal/agent/core/task.go`):
|
||||
|
||||
- `TaskFrame.PrefixLen` 记录 prepare 段构建的**基础前缀**长度
|
||||
(system + timeline + 用户输入);其后的 Stage 上下文与工具轮产物都是“自己的现场”。
|
||||
- `rebaseFramePrefix(f)`:恢复时重建基础前缀(因中断结束已把它的输入/输出提交进
|
||||
`a.context`,重建出的 timeline 已含中断效果),再把 `f.Msgs[PrefixLen:]` 原样接回;
|
||||
并补回 prepare 段的両处尾部改写(`IsInterrupt` 的 `[中断消息]` 标记、输入多模态块)。
|
||||
- 调用点:`resumeTask` 在 `runTaskSteps` **之前**调用它。
|
||||
|
||||
> 代价(已知且接受):中断看不到“进行到哪一步”,所以“别搜了改成 X”这类指令
|
||||
> 只能靠它自己重新理解;换来的是中断起点总是一个**一致的完整状态**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 保存现场与恢复
|
||||
|
||||
### 6.1 保存
|
||||
|
||||
在安全点被抢占时:
|
||||
|
||||
```
|
||||
suspendStack.push(Task{frame: running.frame, state: suspended,
|
||||
step: running.frame.step, enqueueAt: running.enqueueAt})
|
||||
running.state = done_for_now
|
||||
```
|
||||
|
||||
- **只保存数据帧**,不保存 goroutine 栈(这正是"单调度 + 隐式状态机"优于"park goroutine"的地方)。
|
||||
- `S_LLM` 被抢占时:**不完整的 LLM 请求直接丢弃**(LLM 调用幂等、无持久副作用);恢复时从 `S_LLM` **重发**,`msgs` 与抢占前一致(即"请求前"的状态)。
|
||||
- 已提交的副作用(已执行的工具、已 append 的 context)**不回滚**——帧里记录的 `toolResults` 会保留,恢复后继续。
|
||||
|
||||
### 6.2 恢复
|
||||
|
||||
从**中断栈栈顶**取出后:
|
||||
|
||||
1. **重建基础前缀**(`rebaseFramePrefix`)—— 此时中断任务已结束并提交,
|
||||
重建出的 timeline 包含中断的输入/输出,即“现场加载回中断任务之上”;
|
||||
2. 把本任务自己的尾部(Stage 上下文 + 工具轮产物 + 占位)原样接回;
|
||||
3. 从 `frame.Step` 继续执行。
|
||||
|
||||
被丢弃的只有那次**不完整的 LLM 请求**(幂等),已执行的工具与已累积的
|
||||
`toolResults` 全部保留。
|
||||
|
||||
### 6.3 嵌套
|
||||
|
||||
- 允许中断任务自身被更高级中断抢占(嵌套)。
|
||||
- **中断栈帧数上界 = 4,是结构推论而不是配置项**:
|
||||
链条 = `排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)`,
|
||||
被挂起 4 帧;L4 之上没有更高级别,链到此为止。
|
||||
(插件可达级别只到 L3,所以插件链最多挂起 3 帧 + 底层排队任务;
|
||||
第 4 帧只能由内核 L4 制造。)
|
||||
- 栈自底向上的**基础级**天然递增(能被抢占者必然级别更高),因此栈顶通常就是最高级任务。
|
||||
- 超限在正确模型下不可达:`susp` 处只做**防御性计数**(`Rejected++`),
|
||||
**不降级、不丢弃帧**——帧丢了会丢副作用记录。早期稿写的“超限转 pendingInterrupts”已删除。
|
||||
|
||||
## 7. 回执路由(任务级)
|
||||
|
||||
**必须改**:`ResponseCh` 从"全局 `emitResponse` 的对象"上升为 `TaskFrame.responseCh`。
|
||||
|
||||
```
|
||||
emitResponse(task, ...) // 写 task.frame.responseCh,而不是"当前全局通道"
|
||||
```
|
||||
|
||||
- 抢占场景下,中断任务 B 的 `S_FINISH` 只可能写 `B.responseCh`,绝不会写进被挂起的 `A.responseCh`。
|
||||
- **不变量**:**每个任务在 `S_FINISH` 必然产生且仅产生一个终态事件**(无论成功、失败、被跳过)。`processInput` 现有的三个提前 return(解析失败、去重、consolidation)在新模型里都必须转成"任务以 `skipped` 终态结束并回执"。
|
||||
- 这顺带修掉现有缺陷:`cli`(`internal/plugins/cli/plugin.go:242`)与 `clawhubadapter`(`internal/plugins/clawhubadapter/plugin.go:1045`)的同步注入在断链时会永久挂起。
|
||||
|
||||
---
|
||||
|
||||
## 8. 并发结构(两个 goroutine)
|
||||
|
||||
```
|
||||
schedulerLoop(唯一持有任务状态与帧)
|
||||
for {
|
||||
if 可切换 && preemptionRequest 有效 → 执行抢占(保存现场)
|
||||
if running == nil → pick from 三集合;无候选则等待 inbox
|
||||
runOneStep(running) // 可能是阻塞调用(见 §8.2)
|
||||
处理 step 结果 → 推进或结束任务
|
||||
}
|
||||
|
||||
interruptLoop(不持有任何帧)
|
||||
收 interruptCh → 定级 → 决策 → 置 preemptionRequest + cancel + wake scheduler
|
||||
```
|
||||
|
||||
### 8.1 不变量
|
||||
|
||||
| # | 不变量 |
|
||||
|---|---|
|
||||
| I1 | 任意时刻至多一个 `running` 任务("一个 running"约束的是**副作用**,不只是 CPU) |
|
||||
| I2 | 任务帧只由 `schedulerLoop` 读写;`interruptLoop` 只写 `preemptionRequest` / 读 `stepCancel` |
|
||||
| I3 | 任何跨挂起点的状态都是纯数据,不持有锁 |
|
||||
| I4 | 安全点只在 step 边界;工具执行中与 COMMIT 不是安全点 |
|
||||
| I5 | 每个任务恰好一次终态事件(含 `responseCh` 写入) |
|
||||
| I6 | 调度器本身永不退出(panic 只使当前任务失败) |
|
||||
|
||||
### 8.2 关于"调度器不被阻塞"(**待确认 D2**)
|
||||
|
||||
> **M3 拆分的理由**:真正的挂起要求帧跨越 `prepare → step… → finish` 全生命周期。
|
||||
> 若只把 `process()` 改成可挂起,`processInput` 会在挂起返回后继续执行
|
||||
> `context.Append` 与 `emitResponse`——造成重复提交。故 M3 分为 M3a(所有权重构,
|
||||
> 行为等价)与 M3b(抢占语义)两步。
|
||||
|
||||
v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 step 上会阻塞进插件 RPC / 原生调用。** 这是有意的取舍:
|
||||
|
||||
- 好处:与"两个 goroutine 就够"一致,实现简单,无临时 goroutine。
|
||||
- 代价:这些临界区期间**中断只能排队,不能抢占**。换言之,**中断的有效窗口 = `S_LLM`**(与今天的实际行为相同,但现在是显式声明而非隐式结果)。
|
||||
- 演进(v2):把 `S_TOOL_EXEC` 改成异步 step(临时 goroutine + 完成事件),并给插件协议加 `tool.cancel`。此路径在文档保留,不在 v1 实现。
|
||||
|
||||
### 8.3 panic 隔离与 panic 中断
|
||||
|
||||
- `runOneStep` 外包 `recover`:panic → 当前任务标记 `failed`,**调度器继续**。
|
||||
- panic 同时**产生一条内核 L4 中断**(`reportTaskPanic` → `raiseKernelInterrupt`):
|
||||
内核把自己发生了 panic 这件事作为最高级中断通知给调度器,让 agent 能知情/善后。
|
||||
- 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,不再产生新的 L4——
|
||||
否则同一个 panic 会自我放大成中断风暴。
|
||||
- 取代现有 `eventLoop`/`interceptLoop` 的 `recover → sleep 1s → go loop()` 无退避重启(`eventloop.go:19-22,38-42`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 失效模式与防御
|
||||
|
||||
| 失效 | 防御 |
|
||||
|---|---|
|
||||
| 饥饿(高优先级流反复抢占) | `preemptCount` 提升有效级:`effectiveLevel = min(4, baseLevel + min(preemptCount, 2))`;被抢占 +1。**只对中断生效**——排队任务无级别,按定义可被任何中断打断 |
|
||||
| 无界下潜 | 中断栈帧数上界 4(结构推论 = 中断级数);超限只做防御性计数,**不降级不丢帧** |
|
||||
| 中断请求堆积 | 中断队列合计有界,满则丢最低级别里最老的一条并计数 |
|
||||
| 就绪队列满 | 阻塞发送方 + 计数(不静默丢) |
|
||||
| 同一任务反复被打断 | `preemptCount` 达阈值后有效级提升;另设**抢占冷却**:刚被抢占的任务在 `cooldown` 内不再被同级/更低级抢占 |
|
||||
| 任务永不结束 | 每任务 `maxTurns`(主循环目前缺失,见审查 P0)+ 每步超时 |
|
||||
| 不可观测 | `Scheduler.Dump()` 原子快照 + 事件(切换原因、降级次数、丢弃次数) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 非目标(v1 明确不做)
|
||||
|
||||
1. 工具级取消 / 可抢占工具(`tool.cancel`)。
|
||||
2. 多 agent 并行(仍是单 agent 单调度器)。
|
||||
3. 公开 SDK 接口变更。
|
||||
4. 微抢占(任意指令级)。
|
||||
5. 跨进程恢复(帧不落盘)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试点、测试方式与预期结果
|
||||
|
||||
测试基础设施(先于 M1 落地):
|
||||
|
||||
- **假时钟** `Clock` 接口(`Now()` / `AfterFunc`),生产用真实实现,测试注入可控时钟。
|
||||
- **假 Provider**:实现 `agentAPI.Provider`,返回脚本化的 `tool_calls` 序列(支持"第 N 次调用时挂起直到放行")。
|
||||
- **假工具**:测试内 `StageHost.RegisterTool` 注册,可控制每次执行耗时、是否返回错误、是否触发中断注入。
|
||||
- **同步栅栏**:测试通过 `scheduler.Inbox` 注入中断并用 `runtime.Gosched` + 显式 `waitFor(state)` 断言,不用 sleep 猜时序。
|
||||
- **快照断言**:`scheduler.Dump()` 返回 `{running, queue, interruptQueues[1..4], immediate, suspendStack, counters}`,测试对纯数据断言。
|
||||
|
||||
### 11.1 优先级与抢占
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| P1 | 更高中断抢占中断 | running=L2 在 `S_LLM`;注入 L3 中断 | L2 压入中断栈(step=S_LLM);L3 进 `immediate` 并变 running |
|
||||
| P2 | 相等级别不抢占 | running=L2 中断在 `S_LLM`;注入 L2 | 不抢占;请求入 L2 中断队列;running 不变 |
|
||||
| P3 | 更低级别不抢占 | running=L3 中断;注入 L2 | 同上,不抢占 |
|
||||
| P4 | 逐级抢占嵌套 | 排队任务 → L1 → L2 → L3 → L4,均在 `S_LLM` | 中断栈深度依次 1/2/3/4;每层 step 均为 S_LLM |
|
||||
| P5 | 抢占后在安全点才生效 | running=排队任务在 `S_TOOL_EXEC`;注入 L4 | 抢占**不立即生效**;工具返回后才保存/切换;`deferredPreemptions==1` |
|
||||
| P6 | 临界区不可抢占 | running 声明临界区;注入 L4 | 同上;L4 请求留在中断队列,临界区结束立即被选中 |
|
||||
| **P7** | **排队任务被任何中断打断** | running=排队任务;注入 **L1** 中断 | L1 也抢占成功(排队任务有效级 0) |
|
||||
| **P8** | **排队输入永不抢占** | running=任意任务;注入排队输入 | 不抢占,入排队队列 |
|
||||
| **P9** | **外部插件不能声明 L4** | 外部来源声明 `Priority="L4"` | 被夹到 L3(proc 桥 + core 双重) |
|
||||
| **P11** | **内核级插件可用 L4** | 内置插件(如 webui)声明 `L4` | 得到 L4 并立即打断当前任务(终止按钮) |
|
||||
| **P10** | **panic 产生 L4 中断** | 任务 panic | 产生一条带 `kernel=true` 的 L4 中断;L4 自身 panic 不再递归 |
|
||||
|
||||
### 11.2 保存现场与恢复
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| R1 | 在 `S_LLM` 抢占后恢复 | 构造 A 在 `S_LLM` 被 B 抢占;B 结束 | A 恢复后**重新发起** LLM 请求;`msgs` 与 A 被抢占前**逐字节相同**;不重复执行已完成的工具 |
|
||||
| R2 | 在 `S_TOOL_BEGIN` 抢占后恢复 | A 完成 1 个工具批后于 `S_TOOL_BEGIN` 被抢占 | A 恢复后继续**下一批**工具;`toolResults` 长度不变 |
|
||||
| R3 | 恢复结果与不中断一致 | 同一脚本跑两次:一次中途注入中断,一次不注入 | 两次最终 `context` 事件序列**除"中断任务自身的事件"外一致**;A 的 `toolsUsed` 顺序相同 |
|
||||
| R4 | 嵌套恢复顺序 | L4→L3→L2 依次抢占后依次结束 | 按有效级/到达序恢复;每个任务的 `frame.step` 与其被挂起时一致 |
|
||||
| R5 | 不完整的 LLM 请求被丢弃 | 假 Provider 在流式途中触发中断 | 该次请求被 cancel;**不产生任何 `msgs` 追加、不产生 tool_call**;恢复后重发次数 = 1 |
|
||||
|
||||
### 11.3 回执路由
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| X1 | 任务级回执不误投 | A 为同步任务并已挂起;B 为同步中断 | B 的回执只到 `B.responseCh`;`A.responseCh` 在 A 恢复并结束后才收到自己的回执 |
|
||||
| X2 | 断链路径必有终态 | 分别构造:解析失败、10s 去重命中、`_consolidation_` | 三种都产生 `skipped` 终态事件并回执;同步调用方**不挂起** |
|
||||
| X3 | 每任务恰一次终态 | 统计 `S_FINISH` 次数 vs 任务数 | 相等(I5),无重复写入 |
|
||||
| X4 | 无超时同步注入不再永久挂起 | `cli` 路径(无超时)注入一条会被去重的输入 | 返回 `skipped` 回执而非永久阻塞 |
|
||||
|
||||
### 11.4 队列与选择
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| Q1 | 中断队列按级别扫 | 四条中断队列各放一个,入队顺序与级别相反 | 取出顺序 L4→L3→L2→L1;中断耗尽后才是排队任务(FIFO) |
|
||||
| Q2 | 挂起现场优先于新排队工作 | A 被抢占挂起 + B 为新排队输入 | A(栈顶)先被选中 |
|
||||
| Q3 | 栈顶 vs 中断队头 | 栈顶 L3 + 队头 L2 / 栈顶 L3 + 队头 L4 / 栈顶为排队任务 + 队头 L1 | 分别取 栈顶 / 队头 / 队头 |
|
||||
| Q4 | 就绪队列背压 | readyQueue 满后注入排队输入 | 发送方阻塞 + 计数 +1;不静默丢弃 |
|
||||
| Q5 | 中断队列溢出 | 中断队列合计满后注入更多 | 丢**最低级别里最老**的一条 + 计数;其余保持 |
|
||||
| **Q6** | **immediate 最优先** | `immediate` 非空且中断队列里有更高级别 | 取 `immediate`(抢占必须立即生效) |
|
||||
|
||||
### 11.5 深度、饥饿与并发
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| D1T | 下潜深度上界(结构推论) | 挂起 3 帧后继续注入;再挂起到 4 帧 | 3 帧时 `canSuspend()==true`;4 帧(全链:排队+L1+L2+L3,L4 运行中)时为 `false` |
|
||||
| G1 | 饥饿防护(抢占提升) | 对同一 **L1 中断**连续抢占 5 次(同级/高级交替) | `effectiveLevel` 提升至 `min(4, 1+2)=3`;第 3 次后不再被 L1/L2 抢占 |
|
||||
| G2 | 冷却生效 | 同一中断刚被抢占后立刻再注入同级中断 | 冷却期内不抢占,请求入中断队列 |
|
||||
| **G3** | **提升也必须只在中断间生效** | 排队任务被连续抢占 | 排队任务有效级恒 0(不被提升;它按定义可被任何中断打断) |
|
||||
| K1 | panic 隔离 | 假工具 panic | 只有该任务变 `failed`;调度器存活;后续任务正常执行 |
|
||||
| K2 | 竞态检查 | 全部调度用例加 `-race` | 无数据竞争报告 |
|
||||
| O1 | 快照一致性 | 在任意 step 边界调 `Dump()` | 返回的 `running/ready/pending/suspend` 三集合互不重叠且总数守恒 |
|
||||
| O2 | 切换可观测 | 每次抢占/恢复 | 产生一条事件(任务 id、原因、from→to、level) |
|
||||
|
||||
### 11.6 端到端
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| E1 | 真实 provider + 假长工具 | 启动内核,用一个会阻塞 5s 的假工具跑 L1 任务,途中经 `interceptCh` 注入 L4 中断 | 中断**在工具执行期间不被处理**;工具返回后立即抢占;中断任务先完成;原任务恢复并完成 |
|
||||
| E2 | LLM 流式中断 | 假 Provider 慢速流式返回 | 中断后当前流被 cancel,任务挂起,中断任务完成,原任务恢复并重新请求 |
|
||||
| E3 | 现有 e2e 回归 | 跑 `internal/plugins/integration_test.go`、`real_plugin_smoke_test.go` | 行为不变(除文档化的语义变化) |
|
||||
| **E4** | **优先级压力(用户指定形状)** | 固定内容假 provider(**记延迟,且被取消时立刻返回**),100 条排队输入 + 100 条中断(L1/L2/L3/L4 各 25)混合打入;每条中断都等到“该被它打断的受害者正在跑”时才注入 | 200 个任务全部到达终态;`Rejected=0`;各级登记数 = 25;**各级抢占数都 > 0**;排空后 `Suspended == Resumed`;每次“取消流式段”都换来一次挂起 |
|
||||
| **E5** | **嵌套到结构上限并 LIFO 展开** | 排队任务运行中依次注入 L1→L2→L3→L4(每级都等上一级在跑) | 栈深峰值恰好 **4**(= 结构上限,`canSuspend()==false`);恢复顺序严格 LIFO `[L3, L2, L1, 排队]`;`Suspended==Resumed==4` |
|
||||
|
||||
> E4/E5 的 provider 必须**感知 ctx 取消**:否则抢占只能等任务自然结束,
|
||||
> 测到的全是"步骤之间让位",流式段的取消路径(真正的现场保存/恢复)压不到。
|
||||
> 实测:不感知取消时 `LLM完成 == 任务数`、挂起接近 0;感知后取消次数与挂起次数一一对应。
|
||||
|
||||
---
|
||||
|
||||
## 12. 待确认决策(含默认取值)
|
||||
|
||||
> 未获异议时按"默认取值"实现;每项单独一个 commit,便于回退。
|
||||
|
||||
| 编号 | 问题 | 默认取值 |
|
||||
|---|---|---|
|
||||
| **D1** | 中断任务的上下文 | **方案 B(已定)**:中断从上一个任务之前的完整状态开始;恢复时把被挂起任务的现场加载回中断之上 |
|
||||
| **D2** | 阻塞 step 处置:v1 全部声明为临界区(调度器可被阻塞)还是引入异步 step | **v1 = 临界区**;异步 step 留到 v2 |
|
||||
| **D3** | 中断队列与排队队列是否合一 | **完全分离**:中断按级别分四条队列(L4→L1 扫描),排队队列纯 FIFO,两者不共用比较器 |
|
||||
| **D7** | 任务类别怎么定 | **由注入 API 决定**(`InjectInterrupt*` = 中断;`InjectText*`/`InjectInputSync*`/自循环 = 排队),**不按通道名推断** |
|
||||
| **D8** | L4 归谁 | **内核独占**。唯一入口 `(*Agent).raiseKernelInterrupt`(panic / selfip);`clampPluginLevel` 把插件声明夹到 L3 |
|
||||
| **D9** | L1–L3 归谁 | **插件在 `InjectOptions.Priority` 里声明**(纯追加字段);空/非法降级到 L1 |
|
||||
| **D10** | 抢占者进入队列还是立即运行 | **立即运行**(`immediate` 槽)。这消除“抢占者与被挂起者同级”的比较,删除了早期的“同级 pending 优先”补丁 |
|
||||
| **D11** | 中断栈帧数上界 | **结构推论 = 4**(排队 L0 + I1 + I2 + I3 挂起,I4 运行中),不是配置项;超限只计防御性计数 |
|
||||
| **D4** | readyQueue 满时:阻塞发送方 or 返回错误 | **阻塞发送方 + 计数**(与现状一致,避免丢用户输入) |
|
||||
| **D5** | 饥饿防护:抢占计数提升 or 时间老化 | **抢占计数提升**(确定性、易测);时间老化留待需要时 |
|
||||
| **D6** | 主循环 `max_tool_turns` 是否在本特性一并落地 | **是**(审查 P0,且调度器需要"任务可终止"这一前提) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 与发布纪律的关系
|
||||
|
||||
- 本特性在 `feature/input-semantics` 上开发,完成后合回 `main`,**不碰 `release/v1.2.x`**。
|
||||
- **公开 SDK 在本特性上有意新增**(feature 分支不受 rel 分支的接口冻结约束):
|
||||
`sdk.InjectOptions.Priority` 与 `sdk.PriorityL1/L2/L3`。这是为了让插件能声明
|
||||
自己中断的级别(§3.2)。
|
||||
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
|
||||
等价于旧行为(L1)。
|
||||
- 合回 `main` 前需完成的发布动作:
|
||||
1. 同步更新 `docs/zh/plugin-interface-matrix.md`;
|
||||
2. 与 SDK 仓协同升 SDK 中版本;
|
||||
3. 遵守“只增不减、签名不改”边界。
|
||||
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`)
|
||||
同步追加 `priority`,与公开 SDK 字段一一对应。
|
||||
|
||||
## 14. 实现里程碑(逐个实现,每个 = 一个可独立验收的提交)
|
||||
|
||||
| 里程碑 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **M0** | 测试基础设施:`Clock` 接口、假 Provider、假工具、`waitFor`、`Dump()` 骨架 | 新测试可运行;`go vet` 干净 |
|
||||
| **M1** | **纯重构**:把 `process()` 拆成显式 step 状态机 + `TaskFrame`;仍由现有 `eventLoop` 驱动,无优先级/无抢占 | R3、X3 通过;既有全部 agent 测试通过(行为等价) |
|
||||
| **M2** | 调度器骨架:单 `schedulerLoop` + `readyQueue`,取代 `eventLoop` 的输入处理;无优先级(全部 L1,纯 FIFO) | Q1/Q4 通过;integration 测试通过 |
|
||||
| **M3a** | **前置重构(本次拆分引入)**:把一轮对话的所有权从 `processInput` 移到调度器——帧覆盖 `prepare → step… → finish`;同时移除 `process()` 整轮持有的 `a.mu`(挂起不能持锁) | 既有全部 agent 测试 + 既有 e2e 通过(行为等价);`-race` 干净 |
|
||||
| **M3b** | `interruptLoop` 重写 + 四级优先级 + 严格大于抢占 + 中断栈 LIFO;只支持 `S_LLM` 抢占 | P1–P4、R1、R5、K1–K2 通过;嵌套 LIFO 判据通过 |
|
||||
| **M4** | 临界区 + `S_TOOL_EXEC` 声明 + 中断队列 + 深度上界(**后经模型更正重做,见下**) | P5–P6、D1T、Q3、Q5 通过 |
|
||||
| **M5** | 饥饿防护(抢占计数提升 + 冷却) | G1–G2 通过 |
|
||||
| **M6** | 任务级 `responseCh` + 断链点统一为终态事件 | X1–X4 通过;`cli`/`clawhub` 不再挂起 |
|
||||
| **M7** | 可观测性(`Dump()`/事件/状态页)+ 既有回归 | O1–O2、E1–E3 通过;`go test -race ./internal/agent/... ./internal/plugin/...` 全绿 |
|
||||
|
||||
每步收尾命令:
|
||||
|
||||
```bash
|
||||
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath
|
||||
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||
go build ./... && go vet ./...
|
||||
go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk/...
|
||||
```
|
||||
|
||||
### 实现状态(2026-09-13 完成)
|
||||
|
||||
| 里程碑 | 提交 | 验收结果 |
|
||||
|---|---|---|
|
||||
| M1 | `9a58878` | ✅ agent 全量 + `-race`;新增 `task_test.go` 4 项 |
|
||||
| M2 | `7082a50` | ✅ 新增 `scheduler_test.go` 6 组(含 O1/K1) |
|
||||
| M3a+M3b | `c69a1f1` | ✅ 新增 `task_lifecycle_test.go` 5 项、`scheduler_preempt_test.go` 5 项 |
|
||||
| M4 | `7565248` | ✅ 新增 `scheduler_critical_test.go` 3 项 |
|
||||
| M5 | `a971fc8` | ✅ 新增 `scheduler_starvation_test.go` 4 项 |
|
||||
| M6 | `4e4e0ad` | ✅ 新增 `task_terminal_test.go` 3 项 |
|
||||
| M7 | `f11de37` | ✅ 新增 `scheduler_e2e_test.go` 3 项(压力/可观测/端到端) |
|
||||
|
||||
#### 模型更正后的重构(2026-09-13,同一特性分支)
|
||||
|
||||
用户逐条澄清后重做调度核心(**行为有意的语义变化**,非等价重构):
|
||||
|
||||
| 项 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| 类别化 | `TaskClass{queued,interrupt}`;类别由注入 API 决定;`newInputTask`/`newSelfTask` 为 queued,`newInterruptTask` 为 interrupt | `scheduler_kernel_test.go` P7/P8 |
|
||||
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt, privileged)` 读 `payload["priority"]` | P9、P11、Q1 |
|
||||
| L4 内核独占 | `raiseKernelInterrupt`(panic/selfip);`requestKernelPreempt` 不夹取;panic 报告为 L4 且带递归保护 | P10、`TestKernel_PanicRaisesL4Interrupt` |
|
||||
| 选择结构 | `immediate` + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 `pickTaskIndex`/`taskBefore` 与“同级 pending 优先”补丁 | Q1–Q3、Q6 |
|
||||
| 栈上界 | `maxSuspendDepth`(配置语义)→ `maxInterruptFrames = int(LevelCritical)`(结构推论);删除“超限转 pending”降级 | D1T |
|
||||
| 公开 SDK | `InjectOptions.Priority` + `PriorityL1..L4`;io/proc 桥/插件模板同步透传;`example/qq` 声明 L1、`timer` 声明 L3、`webui` 终止按钮声明 L4 | `go test ./...` 全绿 |
|
||||
| 分级可观测 | `SchedulerStats.InterruptsByLevel[1..4]` / `PreemptsByLevel[1..4]`(按级别分桶,见 §11.6 E4) | 压力测试按级别断言 |
|
||||
| 计数修正 | `Resumed` 原本在 `nextRef` 与 `resumeTask` **各计一次**(双计),使"排空后 Suspended==Resumed"失真;现只在 `resumeTask` 计 | E4 断言 |
|
||||
| 压力测试 | `scheduler_stress_test.go`:100 排队 + 100 中断(各级 25)混合;另加嵌套到 4 帧上限并验证 LIFO | E4/E5 |
|
||||
|
||||
实现期与设计的差异(均已回写本文档):
|
||||
|
||||
1. **M3 拆为 M3a/M3b**:真正挂起要求帧跨 `prepare→run→finish`,否则 `processInput`
|
||||
会在挂起返回后继续提交。
|
||||
2. **`a.mu` 整体移除**:它原本只包住整轮 `process()`(同一 goroutine),
|
||||
移除后所有任务状态由 schedulerLoop 独占(不变量 I2/I3 可落地)。
|
||||
3. **`interceptCh` 被删除**:M3b 起中断一律走中断队列(当时叫 `pendingInterrupts`),旧的
|
||||
“同行注入 + 三处 drain + 批次放弃” 已无写入者,属死代码(M4 清理)。
|
||||
4. **v1 未做 M0 的伪时钟**:所有抢占测试用“单次调用阻塞到 ctx 取消”的
|
||||
provider 达到确定性,无需注入时钟。时序型判据(老化式提升)留待需要时。
|
||||
5. **工具执行中不可抢占是被结构保证的**:让位检查只在 step 之间;
|
||||
不需要在 step 内部再判一次。
|
||||
|
||||
---
|
||||
|
||||
## 15. 开放问题(后续版本)
|
||||
|
||||
1. 异步 step + `tool.cancel`(真正让工具可抢占)。
|
||||
2. 帧落盘(跨进程/崩溃恢复)。
|
||||
3. 多 agent 并行调度。
|
||||
4. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。
|
||||
|
||||
> **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”,
|
||||
> 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻:
|
||||
> **L1–L3 就是给插件声明使用的**。L4 的归属后来也明确了——不是“只有
|
||||
> panic/selfip”,而是**内核 + 内核级插件**(编译期内置)都能用,用于实现
|
||||
> “立即打断”(panic、内核事件、WebUI 终止按钮)。因此公开 SDK 同时导出了
|
||||
> `PriorityL4`(附“仅内核级插件”的说明)。
|
||||
>
|
||||
> 仍**不做**的是“运维可调的策略表”(`core.agent.priority.<channel>`)——
|
||||
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。
|
||||
@ -1,455 +0,0 @@
|
||||
# 统一多模态向量空间
|
||||
|
||||
核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个
|
||||
向量空间。仓库内自带两个:
|
||||
|
||||
| provider | 模态 | 维度 | 实测常驻 | 许可 | 适用 |
|
||||
|---|---|---|---|---|---|
|
||||
| `chineseclip` | text + image | 512 | **1.15 GB** | Apache-2.0 | 默认(内存受限 / 中文图文) |
|
||||
| `qwen3vl` | text + image(视频已实现未纳入契约) | 2048 | 9.4 GB | Apache-2.0 | 内存充足 / 需要更强文本语义或视频 |
|
||||
| `http` | 由外部服务决定 | 由外部服务决定 | 由外部服务决定 | — | 侧车部署(如 jina-v5-omni-nano,注意其 CC BY-NC 许可) |
|
||||
|
||||
下面第一节是 Qwen3-VL(2048 维,最强但最重),第二节是 Chinese-CLIP(512 维,
|
||||
默认推荐)。两者互斥启用,改配置后重启生效。
|
||||
|
||||
文本、图像、**视频帧** 在同一模型、同一维度、同一 fingerprint 空间里被编码。
|
||||
记忆系统用它做三件事:多模态图记忆的跨模态召回、multimodal doc 的向量融合、
|
||||
multimodal context 的相关性裁剪/淘汰。
|
||||
|
||||
统一空间取代了此前「把图片交给视觉模型生成文字描述、再按描述检索」的做法。
|
||||
那条链路有三个致命缺陷:描述是异步生成的(未生成前媒体等于不存在)、语义检索
|
||||
实际上只搜描述文字、图库里的「媒体节点」只是描述文本的投影而不是媒体本身。
|
||||
**不要再引入任何描述式索引。**
|
||||
|
||||
## 一、产物与获取
|
||||
|
||||
产物约 8 GB(含外部权重),**不进仓库**;用导出脚本自动拉取模型并导出:
|
||||
|
||||
```bash
|
||||
# 默认导出 图像 + 视频 G=2,3,4(即 4/6/8 帧)
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 只要 4 帧的视频档(省磁盘、省内存)
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ...
|
||||
|
||||
# 已下载过模型:跳过拉取
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--model-dir /path/to/Qwen3-VL-Embedding-2B \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 参考向量默认直接写进产物目录(<out>/qwen_reference.json),无需额外参数
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ...
|
||||
```
|
||||
|
||||
国内镜像:导出脚本沿用 `huggingface_hub` 的约定,直接 `export HF_ENDPOINT=https://hf-mirror.com` 即可。
|
||||
依赖:`torch`(CPU 版即可)、`transformers>=4.57`、`onnx`、`onnxruntime`、`pillow`、`numpy`,
|
||||
以及可选的 `huggingface_hub` / `modelscope`。显存不需要,内存建议 ≥ 16 GB(FP32 加载约 8 GB)。
|
||||
|
||||
导出脚本**会清空 --out 目录**后重写,避免旧图/旧外部权重污染 fingerprint
|
||||
(fingerprint 变化会触发一次无意义的全量向量重算)。因此不要直接覆盖线上正在使用的目录,
|
||||
先导出到新目录再切换。
|
||||
|
||||
### 产物契约(Go 侧按此读取)
|
||||
|
||||
| 文件 | 输入 | 输出 |
|
||||
|---|---|---|
|
||||
| `TokenEmbedding.onnx` | `input_ids` int64 `[1,seq]` | `hidden` float `[1,seq,2048]` |
|
||||
| `Transformer.onnx` | `hidden`、`deepstack_0/1/2` `[1,seq,2048]`、`rotary_cos/sin` `[1,seq,128]`、`causal_mask` `[1,1,seq,seq]` | `embedding` `[1,2048]` |
|
||||
| `Vision.onnx(+.data)` | `pixel_values` `[2304,1536]` | `deepstack_feature_0/1/2`、`vision_hidden_states` `[576,2048]` |
|
||||
| `Vision_g{N}.onnx` | `pixel_values` `[N×2304,1536]` | 同上,`[N×576,2048]` |
|
||||
|
||||
外加 `tokenizer.json`、`tokenizer_config.json`、`chat_template.jinja`、`embed_config.json`、
|
||||
`qwen_reference.json`。
|
||||
|
||||
`Vision.onnx` 是图像(单时间组);`Vision_g{N}.onnx` 是视频(N 个时间组 = 2N 帧)。
|
||||
**没有 `Vision_g1.onnx`**——单组就是图像那张。
|
||||
|
||||
三段只是部署形式,不是三个向量空间:图文共用同一 token embedding、同一 28 层
|
||||
Transformer、同一 last-token 池化。RoPE 与视觉特征散射故意留在 Go 计算,
|
||||
因为旧式 tracer 会把 `seq=598 / visual=576` 烘焙进图里——签名上写着 dynamic
|
||||
axis,实际却只能用导出的那个长度运行。
|
||||
|
||||
### ⚠️ max_length 必须按最大视频档推导
|
||||
|
||||
`embed_config.json` 的 `max_length` 是**整条序列**的上限,包含视觉占位符:
|
||||
图像只需 598 token(1×576 + 模板),而视频是 G×576——G=2 就要 1190,G=4 要 2342。
|
||||
沿用图像的 1024 会让处理器静默截断,然后在 transformers 内部报
|
||||
`Mismatch in video token count between text and input_ids`。
|
||||
导出脚本因此用 `max_length_for(video_groups) = max(1024, max(G)×576 + 256)` 自动推导,
|
||||
并在构造视觉输入后显式断言视觉 token 数,把错误提前到导出阶段。
|
||||
|
||||
### 导出脚本自检(不可省)
|
||||
|
||||
脚本内部跑两道校验,任一道 cos < 0.999999 就以非零码退出:
|
||||
|
||||
1. 分段 PyTorch(三段组合)对比完整模型前向;
|
||||
2. 用 onnxruntime 跑**导出后**的三段图,再对比完整模型前向。
|
||||
|
||||
「能加载」不等于「算得对」:形状错、输入名错、池化位置错的图都能正常 load。
|
||||
|
||||
## 一·补、text+image 默认空间:Chinese-CLIP ViT-B/16
|
||||
|
||||
**为什么它是默认**:text+image 只需要一个向量空间时,同时满足「小、可商用、中文原生」
|
||||
的选项只有一个。
|
||||
|
||||
| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|
||||
|---|---|---|---|
|
||||
| 参数量 | 188M | 1.04B | 2B |
|
||||
| 产物 / 实测常驻 | **721MB / 1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
|
||||
| 维度 | 512 | 768 | 2048 |
|
||||
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
|
||||
| 中文 | 原生(~2 亿中文图文对) | 多语言 | 多语言 |
|
||||
| 文本语义 | 弱(双塔对比) | 好 | 最好 |
|
||||
| 视频 | 无 | 有 | 有 |
|
||||
|
||||
**要诚实记录的代价**:CLIP 是双塔对比学习,text↔image 是强项,但**纯文本语义
|
||||
(text↔text)明显弱于 MLLM 型嵌入器**。文本检索仍由既有词向量/TF-IDF 路径兜底,
|
||||
本空间主要用于跨模态召回与相关性裁剪。需要更强文本语义或视频时切回 `qwen3vl`。
|
||||
|
||||
### 产物与获取
|
||||
|
||||
产物约 754MB,**不进仓库**;用导出脚本从官方权重导出(脚本入库,保证可复现):
|
||||
|
||||
```bash
|
||||
python3 scripts/export_chineseclip_onnx.py \
|
||||
--model-dir /path/to/chinese-clip-vit-base-patch16 \
|
||||
--out /home/newqqagent/models/chinese-clip-vit-b16-onnx
|
||||
```
|
||||
|
||||
国内下载:本机 `huggingface.co` 走代理会被 reset,用 `hf-mirror.com` 且**不设代理**:
|
||||
|
||||
```bash
|
||||
curl -4 -L --retry 3 -o vocab.txt \
|
||||
https://hf-mirror.com/OFA-Sys/chinese-clip-vit-base-patch16/resolve/main/vocab.txt
|
||||
```
|
||||
|
||||
### 产物契约(Go 侧按此读取)
|
||||
|
||||
| 文件 | 输入 | 输出 |
|
||||
|---|---|---|
|
||||
| `TextEncoder.onnx` | `input_ids` int64 `[B,52]`、`attention_mask` int64 `[B,52]` | `text_features` float `[B,512]` |
|
||||
| `VisionEncoder.onnx` | `pixel_values` float `[B,3,224,224]` | `image_features` float `[B,512]` |
|
||||
|
||||
外加 `embed_config.json`(维度/预处理/分词超参/文件名——provider 的唯一权威)、
|
||||
`vocab.txt`、`reference.json`(冻结参考:逐文本 token id + 逐样本向量)、`SHA256SUMS`。
|
||||
|
||||
图像预处理:缩放到 224×224(双三次,复刻 PIL 系数)→ `(x/255 - mean) / std`,
|
||||
不裁剪。文本:BERT WordPiece,`max_length=52`,补 `[PAD]`,超长截断尾部。
|
||||
两个塔的输出**都没有在图中归一化**,归一化由 provider 负责(检索按余弦)。
|
||||
|
||||
### 启用
|
||||
|
||||
```bash
|
||||
core.memory.multimodal_space.provider = chineseclip
|
||||
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/chinese-clip-vit-b16-onnx
|
||||
```
|
||||
|
||||
**新装默认就是这个**(`SeedDefaults` 写入 `chineseclip` + `<dataDir>/models/chinese-clip-vit-b16-onnx`),
|
||||
发行版构建也默认带 `onnxruntime` 标签(`deploy/packaging/build.sh` 的 `HOMED_TAGS`,
|
||||
需要极简构建时显式 `HOMED_TAGS=` 关闭)。
|
||||
|
||||
**老安装不会自动拿到**:播种判据是显式标记 `core.internal.seed_version`。
|
||||
老安装(已播种过)下次启动只会被补上标记,**不会**被注入新默认值——
|
||||
升级就静默加载 1.8GB 模型不是无副作用的事。要启用请显式写上面两个键。
|
||||
|
||||
> 这个判据曾经是「`config` 表为空才播种」。而发行包的 postinst 会先跑
|
||||
> `initconfig`,它写一行 `webui.listen_addr` ——于是**全新安装**被误判为
|
||||
> "已有配置",整个播种被跳过:没有 `core.plugin.dir`(装完 0 个插件)、
|
||||
> 也没有多模态 provider(随包的模型与运行库成了死重量)。回归测试
|
||||
> `TestSeedDefaultsAfterInitconfigPrepopulate` 与
|
||||
> `TestSeedDefaultsDoesNotInjectIntoLegacyInstall` 钉住了这两种情形。
|
||||
|
||||
同样要求 `homed` 带 `onnxruntime` build tag。
|
||||
|
||||
### 随包分发(server / full 包自带模型与运行库)
|
||||
|
||||
模型与运行库是发行版能力的一部分,不做成「可选下载」:
|
||||
|
||||
| 内容 | 包内路径 |
|
||||
|---|---|
|
||||
| Chinese-CLIP 产物(754MB) | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(24MB) | `/usr/lib/homeagent/onnxruntime/libonnxruntime.so` |
|
||||
| 许可证 | `/usr/share/doc/homeagent/licenses/`(Apache-2.0、MIT、ThirdPartyNotices、模型来源) |
|
||||
|
||||
- `deploy/packaging/package-linux.sh` 的 `stage_multimodal_assets()` 在打 server/full 前
|
||||
会校验产物 `SHA256SUMS`、逐文件非空、运行库架构与目标一致;**缺一即失败**,
|
||||
不生成「默认启用但装完不能用」的假包。`client` 包不含(它不跑 homed)。
|
||||
- 安装时 `setup.sh` 把包内模型目录软链到 `<dataDir>/models/chinese-clip-vit-b16-onnx`
|
||||
(既不复制 754MB,也保持 dataDir 可迁移;已存在的自定义目录绝不覆盖)。
|
||||
- 服务单元设 `Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime`;
|
||||
provider 的查找顺序是 `ONNXRUNTIME_DIR` → `ONNX_ML_DIR` → 包内路径 →
|
||||
`/opt/onnxruntime` → `/usr/local/lib` → `/usr/lib`。
|
||||
- 构建机需自备产物:`build/model-assets/chinese-clip-vit-b16-onnx/` 与
|
||||
`build/runtime-assets/<arch>/{libonnxruntime.so,LICENSE,ThirdPartyNotices.txt}`
|
||||
(可用 `CHINESECLIP_BUNDLE_DIR` / `ONNXRUNTIME_ASSET_DIR` 覆盖)。
|
||||
|
||||
实测(从真实 deb 解包、按 postinst 顺序跑 `setup.sh`、再冷启动包内 homed):
|
||||
`multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990 modalities=[text image]`,
|
||||
并完成一次真实对话;`homeagent-server` 包 722MB(旧版 17MB),差额即模型与运行库。
|
||||
|
||||
#### ORT 环境是进程级单例(单主不析构)
|
||||
|
||||
进程内可能有多个 ORT 消费者(本 provider、`qwen3vl`、`internal/nlp` 的依存解析器)。
|
||||
`onnxruntime_go` 的行为是:第二次 `InitializeEnvironment` 报错,而
|
||||
`DestroyEnvironment` 会把别人正在用的环境一起拆掉。约定:
|
||||
|
||||
- 初始化前先 `IsInitialized()`,只有未初始化时才初始化;
|
||||
- **任何消费者都不销毁环境**(环境随进程存活),只销毁自己的会话。
|
||||
|
||||
这个缺陷是「发行版默认带 onnxruntime 标签」后才暴露的:不带标签时多个消费者不会
|
||||
同时存在(此前 `internal/nlp` 会重复初始化并降级,失败路径还会误销毁环境)。
|
||||
|
||||
### 模态范围
|
||||
|
||||
只声明 `text` 与 `image`。`audio`/`video` **明确返回 `ErrUnsupportedModality`**——
|
||||
本空间没有它们的原生编码器,用别的模型向量冒充会污染整个向量空间
|
||||
(这正是「音频明确 unsupported」那条纪律的落地)。
|
||||
|
||||
### 验证
|
||||
|
||||
Go 侧回归对着官方 PyTorch 参考(`reference.json`),模型目录由
|
||||
`CHINESECLIP_MODEL_DIR` 指定,缺失时 skip:
|
||||
|
||||
```bash
|
||||
CHINESECLIP_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \
|
||||
go test -tags onnxruntime ./providers/chineseclip/ -v
|
||||
```
|
||||
|
||||
实测结果:文本 5 个用例 `cos = 1.000000000000`(与官方逐位一致);
|
||||
图像 4 个纯色用例 `cos = 1.000000`(自写 bicubic 与 PIL 在 6 位小数内一致);
|
||||
另有跨模态判别、模态拒绝、指纹稳定性、产物缺失报错等用例。
|
||||
|
||||
### 两个已踩过的坑(都在测试里钉住了)
|
||||
|
||||
1. **分词器不能自己拼**。第一版探针用 `BertTokenizer(vocab_file=..., do_lower_case=True)`
|
||||
手工分词,中文被整体切成 `[UNK]`,三个不同句子产出几乎相同的向量(余弦 0.98),
|
||||
差点把「模型坏了」当成结论。官方配置是 `do_lower_case=true` + **删音标生效** +
|
||||
**中文逐字切分**;Go 侧实现必须与官方**逐 token** 对齐(`TestTokenizerMatchesOfficialReference`)。
|
||||
2. **参考向量是未归一化的原始输出**(模长 10~36)。用「点积当余弦 + 单侧下界」判定
|
||||
会得到 13.6 而「通过」——测试里因此改成真余弦 + 双侧容差。
|
||||
|
||||
## 二、启用
|
||||
|
||||
核心不识别任何具体模型:它只按配置里的 **provider 名**从公共注册表
|
||||
(`pkg/embedding`)打开一个 provider,并把 `options.*` 原样交给它。
|
||||
模型文件布局、预处理、媒体解码、运行时都在 provider 内部。
|
||||
|
||||
```bash
|
||||
# 配置库(config.db)或 WebUI 设置页
|
||||
core.memory.multimodal_space.provider = qwen3vl
|
||||
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 或换成一个外部向量服务(任何语言写的都行)
|
||||
core.memory.multimodal_space.provider = http
|
||||
core.memory.multimodal_space.options.endpoint = http://127.0.0.1:18999/embed
|
||||
core.memory.multimodal_space.options.dimension = 2048
|
||||
```
|
||||
|
||||
`options.*` 是 provider 自己的命名空间,核心不做任何解释(对 `qwen3vl` 是
|
||||
`model_dir`,对 `http` 是 `endpoint`/`dimension`/`api_key`/…)。第三方 provider
|
||||
可以定义自己的选项,无需改核心。
|
||||
|
||||
注意事项:
|
||||
|
||||
- 内置 provider `qwen3vl` 要求 `homed` 带 `onnxruntime` build tag 构建,且
|
||||
`libonnxruntime.so` 可被找到(`/opt/onnxruntime/libonnxruntime.so` 等)。
|
||||
未带 tag 时该 provider 会注册但打开时报「requires build tag」,而不是静默降级。
|
||||
- `provider` 为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
- 改配置后需重启进程生效。
|
||||
- 未配置时优雅降级:文档层退到 TF-IDF 稀疏检索,媒体块仍按结构边关联,只是没有跨模态召回。
|
||||
|
||||
## 二·补、给核心接自己的模型
|
||||
|
||||
核心只依赖一个很小的公共接口(`pkg/embedding`):
|
||||
|
||||
```go
|
||||
// 输入对核心是不透明字节:modality 决定语义,Data+MIME 由 provider 解释。
|
||||
type Input struct {
|
||||
Modality Modality // text / image / audio / video / …
|
||||
Purpose Purpose // query / document
|
||||
Text string
|
||||
Data []byte
|
||||
MIME string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Embed(ctx context.Context, in Input) ([]float64, error)
|
||||
Info() Info // Dimension, Fingerprint, Modalities
|
||||
Close()
|
||||
}
|
||||
```
|
||||
|
||||
接入步骤:新建一个包,在 `init()` 里 `embedding.Register("your-model", factory)`,
|
||||
再把这个包空白导入你的发行版 `main`(或替换内置 provider 的导入行)。
|
||||
分词、预处理、解码、显存/内存管理、模型文件命名全部由你的 provider 决定。
|
||||
|
||||
两条原则值得强调:
|
||||
|
||||
- **能力是数据,不是接口方法**:支持哪些模态写在 `Info().Modalities` 里。
|
||||
这样新增模态不需要改核心接口,核心也不需要为每个新模态做类型断言。
|
||||
- **不支持的模态返回 `embedding.ErrUnsupportedModality`**,而不要拿别的模型顶替,
|
||||
也不要降级成一个普通错误——调用方靠它区分「永远不会有向量」与「本次失败可重试」。
|
||||
|
||||
## 三、模态覆盖范围
|
||||
|
||||
### Qwen3-VL-Embedding-2B(本空间,2048 维)
|
||||
|
||||
模型卡明载支持 **Text / images / screenshots / videos**;`config.json` 有
|
||||
`image_token_id` 与 `video_token_id`,**没有 `audio_token_id`/`audio_config`**。
|
||||
|
||||
| 模态 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 文本 | ✅ 原生 | `VectorizeDense` |
|
||||
| 图像 | ✅ 原生 | `EmbedImageDense`,`Vision.onnx`,固定 768×768 |
|
||||
| 视频 | ⚠️ 视觉侧已导出并校验,**Go 模板未完成** | `EmbedVideoDense` + `Vision_g{N}.onnx`;见下节 |
|
||||
| 音频 | ❌ 本轮明确不做 | 决策结果;该模型也不具备(无 `audio_token_id`) |
|
||||
|
||||
### 视频:帧 → 时间组 → M-RoPE(均已实测对齐)
|
||||
|
||||
| 项 | 值 | 验证方式 |
|
||||
|---|---|---|
|
||||
| 占位符 | `<|video_pad|>` = **151656**(图像是 `<|image_pad|>` = 151655) | 处理器实测 |
|
||||
| 模板 | 与图像同构,只换占位符 | `apply_chat_template` repr 逐字符比对 |
|
||||
| 帧→槽位 | 组 g 的 tp0←帧2g、tp1←帧2g+1 | PyTorch `torch.equal == True`,maxdiff=0;反向对照 False |
|
||||
| patch 布局 | `[G,24,24,2,2,3,2,16,16]`,即图像排列以 grid_t 为最外层堆叠 | 纯色视频于图像张量 `torch.equal == True` |
|
||||
| 视觉 token | `G×576` | 处理器实测(G=2 → 1152) |
|
||||
| M-RoPE | 每组独立:`base=start+24g`;`t=base`、`h=base+j/24`、`w=base+j%24` | 对应 `get_rope_index` 把 video grid 展开成 G 个 `t=1` 项 |
|
||||
| 用错档 | onnxruntime 报 `InvalidArgument`(维度不符) | 实验实测,**不会静默算错** |
|
||||
|
||||
同步注意事项:
|
||||
|
||||
- **帧数必须恰好是 `2×G`**(G 取已导出的档)。奇数帧时只用得上前 `2×floor(n/2)` 帧,
|
||||
多出的丢弃——不补重复帧,那会改变跳帧注意力看到的运动。
|
||||
- **`video/*`(视频文件)不能直接喂给图像入口**:Go 侧没有视频解码器,
|
||||
`EmbedImageDense(raw, "video/mp4")` 返回 `ErrModalityUnsupported`。调用方必须先抽帧。
|
||||
- 视觉图按需懒加载(每张约 1.6GB),未用到的档位不占内存。
|
||||
|
||||
### 导出视频时踩过的两个坑(都已加断言)
|
||||
|
||||
两个坑都会让产物「看起来正常、实际是错的」,且都不会在导出时报错:
|
||||
|
||||
1. **处理器会静默重采样帧**。不给 `video_metadata` 时它回落到 `fps=24`,
|
||||
把**任何**帧数都改成 `grid_t=2`:实测 4/6/8 帧全部得到 1152 个视觉 token。
|
||||
修法:`processor(..., videos=[frames], do_sample_frames=False)`。
|
||||
2. **`max_length` 只按图像算是不够的**。它是整条序列(含视觉占位符)的上限:
|
||||
图像只需 598 token,而视频是 `G×576`——G=2 要 1190、G=4 要 2342。
|
||||
沿用 1024 会截断并报
|
||||
`Mismatch in video token count between text and input_ids`。
|
||||
修法:`max_length_for(G) = max(1024, max(G)×576 + 256)`。
|
||||
|
||||
两个坑都会在导出脚本里显式断言(视觉 token 数、`video_grid_thw` 的组数),
|
||||
把错误提前到导出阶段而不是留给运行时。
|
||||
|
||||
### 音频(本轮决策:不加)
|
||||
|
||||
**Qwen3-VL 不支持音频**,由模型卡与 `config.json` 双重确认:
|
||||
|
||||
```
|
||||
模型卡:Supported Input Modalities: Text, images, screenshots, videos, and …
|
||||
config:image_token_id ✓ / video_token_id ✓ / audio_token_id ✗ / audio_config ✗
|
||||
```
|
||||
|
||||
本机有音频能力的是另一个模型(**jina-v5-omni-nano**,768 维,含
|
||||
`modeling_llava_eurobert_audio.py` 与 `audio_token_id=128256`),与 Qwen 空间
|
||||
**不同维度、不同坐标系,绝不可互相比较**。决定:**本轮不接入**;
|
||||
其侧车(`scripts/embed_sidecar.py`)也仍只实现 `text`/`image`,`audio` 返回 400。
|
||||
|
||||
无论何时接入,都**不允许**:拿视觉塔去编码音频字节、或用另一个模型的向量
|
||||
冒充某空间的音频向量——那会把两套坐标系混进同一空间,且错误是静默的。
|
||||
音频在原空间返回 `vector.ErrModalityUnsupported`,使调用方区分
|
||||
「永远不会有向量」与「本次失败可重试」。
|
||||
|
||||
Qwen3-VL 视觉塔把 `grid_thw` 当 Python 值消费(源码里是 `grid_thw.tolist()`),
|
||||
legacy tracer(`dynamo=False`)会把它固化成常量:实测把 `grid_thw` 声明为图输入后,
|
||||
导出的 ONNX 图里**根本没有该输入**,换帧数调用直接报 `Invalid input name: grid_thw`;
|
||||
导出时的 TracerWarning 明确提示
|
||||
`Converting a tensor to a Python list might cause the trace to be incorrect`。
|
||||
|
||||
因此视频的可行做法是:**在导出时固定时间组数 G,每个 G 一张 Vision 图**
|
||||
(grid = `[G, 48, 48]`),Go 侧按实际帧数选用匹配的图;用 G=2 的图去喂 G=3 的
|
||||
数据属于未定义行为。视频文件本身不能直接喂进本空间(`video/*` 返回
|
||||
`ErrModalityUnsupported`),必须由上层先抽帧。
|
||||
|
||||
## 四、验证
|
||||
|
||||
```bash
|
||||
# Go 侧:ONNX 路径(模型目录缺失时自动 skip)
|
||||
QWEN_ONNX_MODEL_DIR=/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx \
|
||||
go test -tags onnxruntime ./internal/memory/qwen/ -v
|
||||
|
||||
# 排除二进制交付问题的替代:先单独验证模型与 CSV 无关的 ONNX 图
|
||||
go vet -tags onnxruntime ./...
|
||||
```
|
||||
|
||||
Go 测试覆盖:冻结参考向量(文本/图像各 12 维)、同输入确定性、不同输入敏感性、
|
||||
图像与文本向量必须不同、以及音频/视频必须返回 `ErrModalityUnsupported`。
|
||||
|
||||
冻结参考向量由导出脚本写入**产物目录本身**(`<out>/qwen_reference.json`),
|
||||
来源可追溯:同一脚本既产出模型,也产出「这个模型对固定输入应有的输出」。
|
||||
重新导出后若参考值变化,说明权重或图结构变了,必须显式更新参考而不是放宽阈值。
|
||||
|
||||
> **参考向量是 L2 归一化后的值。** ONNX 图返回的是 final norm 之后的原始
|
||||
> last hidden(量级约 100),而 Go 侧 `VectorizeDense` / `EmbedImageDense`
|
||||
> 返回归一化向量。写参考时忘归一化,Go 测试会全线不匹配,而现象看起来
|
||||
> 像“模型不对”,实际只是两边对“向量”的定义不同。
|
||||
|
||||
验证既有产物(不重新导出):
|
||||
|
||||
```bash
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --verify-only --model-dir <model> \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
```
|
||||
|
||||
脚本会顺便把归一化后的参考向量写入该目录。
|
||||
|
||||
### 与现有部署产物的等价性
|
||||
|
||||
本仓库脚本对同一源模型导出时,`TokenEmbedding.onnx` 与 `Transformer.onnx` 与
|
||||
线上在用的产物**逐字节相同**(sha256 一致);`Vision.onnx` 差异仅在打包形式:
|
||||
旧产物把权重量到外部 `Vision.onnx.data`,新脚本内联在图里。两者数值等价。
|
||||
|
||||
注意这会带来一个**操作性**差异:Go 的结构指纹(`computeFingerprint`)把
|
||||
`*.onnx.data` 的文件名与大小算在内,因此「外部权重版 ↔ 内联版」互换会让
|
||||
fingerprint 变化,从而触发一次全量向量重算。重算不会**算错**(数值等价),
|
||||
只是白花一次 CPU;若不想触发,就保持产物打包形式不变。
|
||||
|
||||
## 五、资源成本
|
||||
|
||||
- 产物磁盘约 8 GB;导出过程峰值内存约 10–12 GB(FP32 加载)。
|
||||
- 单次 CPU 推理:文本约几十毫秒量级,图像(2304 patch 过 24 层视觉塔 + 28 层语言模型)
|
||||
明显更重,因此入库时不阻塞对话,靠 `reembedStaleMedia` 在启动时并发迁移
|
||||
(ONNX 路径 4 worker)。
|
||||
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定;
|
||||
换模型或重新导出都会让它变化,从而触发历史向量重算——这是预期行为。
|
||||
|
||||
## 视频:当前状态(未完成,不得当作已验证)
|
||||
|
||||
**视觉侧**:`Vision_g2/g3/g4.onnx` 已导出,且每一档都与完整 PyTorch 模型逐档对过
|
||||
(`cos` 分别为 1.000000119 / 1.000000119 / 1.000000000,覆盖度断言通过)。
|
||||
|
||||
**Go 侧模板**:与 HuggingFace processor 产出**不相等**,因此冻结回归
|
||||
(`TestEmbedderVideoMatchesONNXReference`)当前**显式跳过**并注明原因,不算通过。
|
||||
|
||||
已定位的差异:processor 会按时间组插入字面时间戳文本。逐 token 实测:
|
||||
|
||||
```
|
||||
<|vision_start|> <0.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
|
||||
<1.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
|
||||
```
|
||||
|
||||
而 Go 侧只生成 `<|vision_start|>{G×576 pads}<|vision_end|>`。同一输入下
|
||||
Python `seq=1190`(1152 视觉 + **38** 文本),Go 侧只有 **22** 个文本 token。
|
||||
|
||||
注意两点:
|
||||
|
||||
- 时间戳文本**也占用 M-RoPE 位置**,所以 `TestVideoModelInputMRope` 的自洽断言
|
||||
通过**不能**证明与官方实现一致(它是拿自己算的序列验自己算的位置)。
|
||||
- 修复位置在 provider 内部(模型专属模板本就属于 provider),不是核心。
|
||||
|
||||
另外,公共 provider 契约把 `Data+MIME` 交给 provider 自行解码;本 provider
|
||||
没有视频解码器(Go 标准库不含 H.264/MP4),因此 `Info().Modalities` **不声明 video**,
|
||||
`Embed(video)` 返回 `ErrUnsupportedModality`。视频走 provider 自己的
|
||||
`EmbedVideoDense`(接收已解码帧)。待核心有了对 provider 不透明的多帧容器后,
|
||||
再把视频纳入公共契约。
|
||||
@ -1,17 +1,13 @@
|
||||
# 外部插件接口不变矩阵(多进程化整改基线)
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 状态:**完成 v2**(2026-09-03)——迁移已落地并上生产,内核 v1.0.0。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(plugindev)**,外部插件业务代码零改动,只需用新 plugindev 重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
>
|
||||
> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的:
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
@ -21,9 +17,9 @@
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──plugindev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──plugindev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
@ -33,8 +29,8 @@
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| bridge 模板 `tools/plugindev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
@ -71,7 +67,7 @@ type Plugin interface {
|
||||
|---|---|---|
|
||||
| `Settings()` | `SettingsAPI` | **17 插件全部使用**(Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins) |
|
||||
| `Memory()` | `MemoryAPI`(Recall/Commit/Introspect/MergeEntities/Purge) | 低(controllable) |
|
||||
| `DocMemory()` | `DocMemoryAPI`(Query/Insert/**InsertWithMedia**/Remove/Stats) | 低(`InsertWithMedia` v1.1.0 新增) |
|
||||
| `DocMemory()` | `DocMemoryAPI`(Query/Insert/Remove/Stats) | 低 |
|
||||
| `TextMemory()` | `TextMemoryAPI`(Append) | 0 当前 |
|
||||
| `Knowledge()` | `KnowledgeAPI`(Search/Add/List) | 2 |
|
||||
| `LLM()` | `LLMAPI`(ListSources/SetSource/CurrentSource) | 0 当前 |
|
||||
@ -89,14 +85,7 @@ type Plugin interface {
|
||||
| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 |
|
||||
| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 |
|
||||
| `InjectInputSync` | `(source, channel, text string) string` | → case 47(例:qq 闭环) |
|
||||
| `SetToolBlocks` | `(blocks []ContentBlock)` | ✅ **v1.1.1 已落地**(`io.setToolBlocks`);同版补上 `PluginSDK` 侧一直缺失的便捷包装——接口里有、便捷方法里没有,插件此前只能自己去拿 injector |
|
||||
| `InjectInputMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectMedia`。与 `SetToolBlocks` 的区别见下方说明 |
|
||||
| `InjectInputMediaSync` | `(source, channel, text string, blocks []ContentBlock) string` | **v1.1.0 新增** → `io.injectMediaSync` |
|
||||
| `InjectInterruptMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectInterruptMedia` |
|
||||
|
||||
**为何媒体注入不能搭 `SetToolBlocks` 的车**:后者只在**工具处理函数内部**可用,且媒体要等
|
||||
**下一条 tool message** 才到模型手上。插件主动发起一轮带媒体的对话、以及中断注入,
|
||||
需要各自的签名,且媒体在**本轮**就随消息发出,并自动落进 CAS、挂上媒体记忆引用。
|
||||
| `SetToolBlocks` | `(blocks []ContentBlock)` | **当前空实现**(C ABI 无对应),迁移后经 arena 二进制注入可实现 |
|
||||
| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有(qq 等 1 次) |
|
||||
| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 |
|
||||
| `Set*`(SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI) | — | 供 bridge/核心启动时接线,插件不直接调 |
|
||||
@ -110,12 +99,8 @@ type Plugin interface {
|
||||
| `ChannelDef` | NoMemory/Cleaner(func) | 同上 |
|
||||
| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/Arguments;CallID/Name/Plugin/Success/Result;Role/Content/Score | 全部纯 JSON 可序列化 |
|
||||
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURL;URL/Detail;URL | 全部可偏移化(迁移评估 3.3 已核实) |
|
||||
| `MediaAttachment`(**v1.1.0 新增**) | Digest/MIME/Data/Name/Description | 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节去重),只给 `Digest` 是引用已有内容。**读路径不回 `Data`**——一次检索可能命中几十份媒体,全塞回去会撑爆跨进程消息 |
|
||||
| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 |
|
||||
| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 |
|
||||
| `Triple`(**v1.1.0 扩展**) | += `SentenceText` / `MediaDigests` | 媒体引用挂在**句子**上(`SentenceText` → `sentences` → `sentence_id` → `media_refs`),所以 `MediaDigests` 非空而 `SentenceText` 为空时内核会用媒体标记本身充当句子 |
|
||||
| `Doc`(**v1.1.0 扩展**) | += `MediaDigests` / `Attachments` | `Query` 返回时由内核填充(仅元数据,不带字节) |
|
||||
| `TextEvent`(**v1.1.0 扩展**) | += `Attachments` | 写入时内核把标记并进正文;`RecentEvents` 读回时从标记反解 |
|
||||
|
||||
**函数类型字段盘点(唯一无法跨进程序列化的东西)**:
|
||||
- `ToolDef.Cleaner func(string) string`
|
||||
@ -267,9 +252,7 @@ Part 0.2 先做了过渡补丁(只回传真正变更的字段);Part 4 的
|
||||
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|
||||
|---|---|---|---|
|
||||
| 事件订阅 `Events().Subscribe`(case 23/24) | ❌ 空实现 | ✅ 事件环(EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
|
||||
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ `io.setToolBlocks` | ✅ **v1.1.1 已落地**(走 JSON 而非共享段二进制通道,理由见 §九) |
|
||||
| 媒体入记忆(`InsertWithMedia`、`Triple.MediaDigests`) | ❌ 不存在 | ✅ CAS + 引用计数 GC | ✅ **v1.1.0 类型 / v1.1.1 内核实现** |
|
||||
| 插件主动发起带媒体的一轮对话(`InjectInputMedia*`) | ❌ 不存在 | ✅ 媒体在本轮就到模型手上 | ✅ **v1.1.1** |
|
||||
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ 二进制落 arena,Slice 描述符回传 | ⚠️ method 已定义,内核侧仍未实现 |
|
||||
| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 |
|
||||
| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 |
|
||||
| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 |
|
||||
@ -300,7 +283,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
@ -308,8 +291,6 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
|
||||
3. ✅ **阶段 5**:17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行);
|
||||
`go build ./...` 与全仓 `go test ./...` 均通过。
|
||||
4. ✅ **全程**:`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。
|
||||
5. ⚠️ **v1.1.x 起该检查项不再适用**:冻结是迁移期的约束,迁移完成即到期(见 §九)。
|
||||
取代它的门禁是「存量插件零改动零重编」——见 §九的验证方式。
|
||||
|
||||
生产端到端(2026-09-03,真实 QQ 消息):
|
||||
|
||||
@ -323,100 +304,6 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
|
||||
---
|
||||
|
||||
## 九、v1.1.x 的接口扩展规则(冻结解除后的替代约束)
|
||||
|
||||
冻结约束是为**迁移期**设的:它要保的是「换运行模型不动业务代码」。迁移完成后继续冻结,
|
||||
等于让 SDK 永远停在迁移那天的能力面——多模态这类功能永远到不了插件手上。
|
||||
|
||||
取代它的是三条更弱但仍然硬的约束:
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增三个方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
因此 `SDKCompatibleVersion` 与 SDK 的 `CoreVersion` 都不必随之跃迁:
|
||||
1.1.0 的 SDK 配 1.0.0 编的插件仍然成立。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,所以漂移不会被编译器抓到
|
||||
(v1.1.1 修的时候发现它的 `Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation`)。
|
||||
|
||||
### 验证方式(取代「diff 为零」)
|
||||
|
||||
| 检查 | 命令 | v1.1.1 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
|
||||
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**:
|
||||
|
||||
| 新增 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
|
||||
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 |
|
||||
| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
|
||||
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
|
||||
|
||||
签名层面零变更(六个方法全是新增),满足第 1、2 条。
|
||||
|
||||
**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2
|
||||
(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
|
||||
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
|
||||
|
||||
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
|
||||
|
||||
1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
|
||||
每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
|
||||
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
|
||||
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
|
||||
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
|
||||
退化成「永久豁免」的垃圾抽屉)。
|
||||
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
|
||||
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成
|
||||
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**,
|
||||
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
|
||||
|
||||
#### 验证(1.2.0,本机实测)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
|
||||
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
|
||||
| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 |
|
||||
|
||||
### 为何媒体块走 JSON 而不是共享段二进制通道
|
||||
|
||||
`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON:
|
||||
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要让这四个 method 跟其余
|
||||
51 个分道扬镳。共享段的价值在于**并发改写同一份状态**(StageContext 的 lost update),
|
||||
而媒体块是单向传递的不可变数据,没有这个问题。
|
||||
|
||||
---
|
||||
|
||||
## 八、关联文档
|
||||
|
||||
- `docs/zh/架构迁移评估.md` — 完整论证(§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
|
||||
@ -426,5 +313,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
@ -1,626 +0,0 @@
|
||||
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
|
||||
|
||||
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
|
||||
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
|
||||
> 现场保存/恢复、中断栈(LIFO,结构上界 4 帧)、任务级回执、panic→L4。
|
||||
> 本文只描述**驻留式子 agent** 这一新能力,以及它对既有实现的改动。
|
||||
>
|
||||
> 标记:**[已定]**= 用户明确拍板;**[默认]**= 本文给出的可逆默认取值,实现时在提交信息里标注。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状的子 agent 是**工作式轻量子**:`spawn_child` 起一个一次性 goroutine(临时 `msgs`),
|
||||
跑完把结果经 `selfInputCh` 回投父,然后销毁。它**没有**自己的中断机制、没有通道分配、
|
||||
没有可查询的状态面,父也无法在它运行途中干预它。
|
||||
|
||||
要让父 agent 能**长期派驻**一个下属去持续处理某类工作(一个 inputch 上的来源、一段长期目标),
|
||||
就需要一种新的子 agent:**驻留子**。它必须满足:
|
||||
|
||||
- 有自己的**轻量内核**(自己的调度器、中断机制、上下文),所以父能"打断它"、"查它"、"回收它";
|
||||
- 有自己的**记忆空间**(可写),但**改不了父的主记忆** —— 记忆入库的决策权留在父手里;
|
||||
- 与父之间有**双向、可寻址**的通道(子→父、父→指定子),且父对子的消息是**最高级中断**;
|
||||
- 有一张**可被父查询的状态面**(inputch 处理表),使父不打断它也能知道它做到哪;
|
||||
- 生命周期由父掌握:父可随时**销毁**它,父退出时**必须**销毁全部。
|
||||
|
||||
**两类子 agent 并存**[已定]:工作式轻量子**原样保留**(它是"内核自循环"的一部分,
|
||||
不是对等 agent);驻留子是新增的第二类。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| **根 agent** | 进程级主 agent,拥有**完整内核**(含记忆读写、全部通道) |
|
||||
| **驻留子** | 父创建、长期驻留的子 agent,拥有**轻量内核**与**临时记忆空间** |
|
||||
| **工作式轻量子** | 现状 `spawn_child` 的一次性子任务(**不是内核实例**) |
|
||||
| **inputch** | **最基本的输入路由单位**:对"中断输入 / 排队输入"两者的高层抽象,是**路由与分配**的单位;**由插件注册,一个插件可注册多个** |
|
||||
| **outputch** | 输出通道;输出是 agent 的**主动调用**,并**可寻址到具体 agent** |
|
||||
| **main 空间** | 主记忆空间:父读写、所有子只读、全部子共享 |
|
||||
| **temp 空间** | 子临时记忆空间:该子读写、子之间互不可见 |
|
||||
| **状态面** | 子对外(对父)可查询的状态:含 **inputch 处理表**与产出 |
|
||||
| **处理表** | 按 inputch 记录每轮处理信息的状态表(子持有,父 pull) |
|
||||
| **登记表** | 父持有的全部子 agent 名录(id / 状态 / 通道 / 状态面句柄) |
|
||||
| **contextfull** | 子的上下文窗口满,产生 L4 中断通知父 |
|
||||
| **压缩 / 回收 / 销毁** | 父对 contextfull 的三种处置(见 §9) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色与内核形态
|
||||
|
||||
| | **根 agent** | **驻留子** | **工作式轻量子**(保留不动) |
|
||||
|---|---|---|---|
|
||||
| 内核 | 完整 | **轻量** | 无(不是内核实例) |
|
||||
| 调度器 / 四级中断 / 中断栈 | ✅ | ✅(**L4 只来自父**) | ❌ |
|
||||
| 上下文 | 完整(记忆介导) | 传统上下文(消息序列) | 临时 `msgs` |
|
||||
| 记忆 | main 读写 | **读 temp ∪ main,写 temp** | 无 |
|
||||
| 通道 | 全部 | 父**划入**的输入通道 + **授权**的输出通道 | 无(结果回投父) |
|
||||
| 插件与工具 | 全部 | **父授权,默认完整授权**[已定] | 现有剔除规则不变 |
|
||||
| 状态面 / 处理表 | — | ✅(父 pull) | ❌ |
|
||||
| 子→父 | — | 主动消息 = **L3 中断** | `injectSelfChannel` → `selfInputCh`(排队) |
|
||||
| 主→子 | — | **L4 中断**(取消当前状态 + 插入新消息) | 无 |
|
||||
| 生命周期 | 进程级 | 父持登记表;父可随时销毁;**父退出必须全部销毁** | 跑完即销毁 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 通道模型
|
||||
|
||||
### 4.1 `inputch` 的定义 [已定]
|
||||
|
||||
`inputch` **不是**通道名标签,也**不是**与 outputch 配对的东西。它是:
|
||||
|
||||
1. **最基本的输入路由单位** —— 路由粒度到此为止:比"插件"细、比"通道名字符串"实;
|
||||
2. **由插件注册,且一个插件可注册多个** —— 同一个插件的多个 inputch 是**彼此独立**的
|
||||
路由单位(可以绑给不同 agent、可以分别授权)。登记接口即现有的
|
||||
`RegisterInputChannel(name, def)`,调 N 次就是 N 个 inputch;
|
||||
3. **对"中断输入"与"排队输入"两者的高层抽象** —— 两类输入都从 inputch 进出;
|
||||
4. **路由与分配的单位**:
|
||||
- **路由**:一条输入投给哪个 inputch,就是"该由谁处理"的**既定事实**
|
||||
⇒ **路由发生在进内核之前**;
|
||||
- **分配**:inputch 是**可分配资源**(父把输入通道划给子)。
|
||||
|
||||
「中断 vs 排队」是**每条输入自己的类别**(由注入 API 决定),级别(L1–L4)也是
|
||||
**每条输入的属性**,都不是 inputch 的属性。
|
||||
|
||||
### 4.2 三个入场(沿用现有实现)
|
||||
|
||||
| 入口 | 承载 |
|
||||
|---|---|
|
||||
| `inputCh` | 外部客户端 / 插件的**排队与中断**输入(已预寻址) |
|
||||
| `interruptCh` | 中断入站(外部 / 插件 / 内核自身) |
|
||||
| `selfInputCh` | **内核自循环**:consolidation、工作式轻量子回投 |
|
||||
|
||||
### 4.3 输出是**主动调用** [已定]
|
||||
|
||||
- 异步通道(qq / 微信 / 群聊…):必须显式调用 `output_send__{通道名}` 才真正送达;
|
||||
- 同步通道(webui / cli / 终端):返回纯文本,内核把文本交给等待方 ——
|
||||
走输入事件自带的 `ResponseCh`,这是**事前定好的回程**。
|
||||
|
||||
⇒ **内核不持有"当前通道"可变状态**,**提示词也不预设 outputch**。
|
||||
(要删 `Agent.currentOutputChannel`;要删 `tooldefs.go` 里"当前输入来源通道是 X,
|
||||
对应输出门工具是 output_send__X"那两行。)
|
||||
|
||||
> 之前把这件事说成"内核路由"是错的:内核只负责**投递到既定的回程**与**执行显式的输出调用**。
|
||||
|
||||
### 4.4 通道分配:不对称 [已定]
|
||||
|
||||
- **不按对划分**。父为子:**划入若干 inputch**(单位是 inputch,可以来自同一个插件的不同 inputch)
|
||||
+ 授权**一组可用输出通道**(授权集合,不是一对一)。
|
||||
- **输出通道可寻址到具体 agent**:
|
||||
- **子 → 主**:子直接打到主(经输出通道投进主的 inputch);
|
||||
- **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。
|
||||
|
||||
### 4.5 已经落地/待落地的两件事
|
||||
|
||||
**已落地(N1a)**:inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表
|
||||
+ 单工具多视图总览(`input_channels`,见 §4.6)。
|
||||
|
||||
**已落地(N1b)**:
|
||||
- **输出通道授权集合**[已定:默认完整授权,父可收窄]:
|
||||
`AgentConfig.AllowedOutputs`(nil/空 = 全部)。三处过滤点必须一致,
|
||||
否则会出现"列表里看不到、按名字还能调"的裂缝:
|
||||
1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调);
|
||||
2. **列表工具**:`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch");
|
||||
3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。
|
||||
- **输出通道 → 目标 agent 的 inputch 解析**:`ChannelRegistry.BindOutputTarget` /
|
||||
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。
|
||||
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
|
||||
|
||||
**已落地(N3):子的 io 通道视图 = 对父的**实时回退**
|
||||
|
||||
输出通道在 io 层就是 `Device`,而它们由插件登记在**父**的 `IOManager` 上。驻留子确实该有
|
||||
自己的输入入口与 `outputCh`(所以子是 `NewIOManager()`),但**必须继承父的设备视图** ——
|
||||
否则上面 §4.4 的授权集合形同虚设:`output_list_channels` 为空、`output_send__X`
|
||||
连工具都不生成、按名字直调也会被判「通道不存在或不可用」。
|
||||
|
||||
落地方式:`IOManager.SetParentIO(父)`(子创建时挂上)。
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `GetDevice` / `GetChannelCapabilities` | 自己设备表没有时回退到父 |
|
||||
| `ListChannels` | 自己的登记优先(可覆盖/屏蔽同名通道),其次并入父的可见通道,按**名字**去重 |
|
||||
| `ExecuteTool` | 自己的设备工具没有时回退到父 |
|
||||
|
||||
- **为什么是实时回退、不是创建时复制快照**:设备随资源生灭(远程设备上线/掉线以分钟计,
|
||||
现场日志 60 秒一个来回),快照转瞬即过期;回退永远与父一致(父注销后子立刻看不见)。
|
||||
- **回退只解决"看得见",不解决"能不能用"**:§4.4 的三处过滤点仍然在白名单之后,
|
||||
授权收窄不受影响(判据:白名单外的通道既不出现在工具表里,直调也被拒且不送达)。
|
||||
- 现场缺陷(2026-09-13 用户线上联调实录):**父侧通道装载完整、子侧 `childIO` 空壳**,
|
||||
子调 `output_send__cli` 被判「通道 [cli] 不存在或不可用」。修复后线上实测:子调
|
||||
`output_send__cli` → `result: ok`,并经 `notify_parent` 回报。
|
||||
|
||||
### 4.5.1 通道的一等化(后续要求)
|
||||
|
||||
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**,
|
||||
`inputChannels` 只是策略表(`ChannelDef`:NoMemory / Cleaner / ContextPolicy),
|
||||
**没有归属、没有绑定、没有容量**。要实现 §4.4 需要新增:
|
||||
|
||||
- **通道注册层**:`inputch` 成为一等对象。它已经是**最基本的输入路由单位**,
|
||||
所以注册表以 **inputch 为键**(一个插件 → N 个 inputch),并给每个 inputch 带上:
|
||||
**归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析**;
|
||||
⇒ 同一个插件的两个 inputch 可以**分别划给不同 agent**、**分别限额**;
|
||||
- **输出通道 → 目标 agent 的 inputch** 的解析;
|
||||
- **授权过滤**:`output_list_channels` 只列该 agent 被授权的通道。
|
||||
|
||||
### 4.6 inputch 总览:**单工具多视图** [已定]
|
||||
|
||||
父 agent 必须能看清两件事:**有哪些 inputch 已注册(谁注册的)**、**它们是怎么划分的**。
|
||||
按用户要求构筑为**单工具多视图**(一个工具 + 一个 `view` 参数),而不是一堆小工具 ——
|
||||
视图切换比工具增殖更好用,也更省提示词预算。
|
||||
|
||||
工具:**`input_channels`**
|
||||
|
||||
| view | 内容 |
|
||||
|---|---|
|
||||
| `all`(默认) | 全部已注册 inputch:名字 / **归属插件** / 归属 agent / 容量 / 记忆策略标记 |
|
||||
| `mine` | 划给**本 agent** 的 |
|
||||
| `unassigned` | **尚未划出**的(可按需分配) |
|
||||
| `by_agent` | **划分情况总览**:按归属 agent 分组列出各自拥有哪些 inputch |
|
||||
| `detail`(需 `name`) | 单个 inputch 的全字段(注册插件 / 归属 / 容量 / 默认回程 / 记忆策略) |
|
||||
|
||||
- 未知 `view` **必须报错并列出可用值**(拼错不得被静默当成默认视图)。
|
||||
- 登记表是**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||||
这样"划入/授权"才有意义(默认每个 agent 自带一份,向后兼容)。
|
||||
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
|
||||
保留已有的 Owner / Capacity / Output。
|
||||
|
||||
---
|
||||
|
||||
## 5. 记忆模型:两级空间 [已定]
|
||||
|
||||
> **适用范围**:这套"两级空间"是针对**图记忆**的。子 agent 的记忆面是
|
||||
> **传统上下文 + 图记忆**;**doc 记忆**与 **context 动态上下文**是内核独立设计的
|
||||
> 记忆能力,**只有根 agent 有**(子不可见、不可用,见 §5.5)。
|
||||
|
||||
```
|
||||
主记忆空间(main) ← 子【只读,可看到全部】;父【读写】;所有子共享
|
||||
子临时记忆空间(temp) ← 该子【读写】;每个子独立、互不可见
|
||||
|
||||
子的记忆查询 = temp ∪ main 子的记忆写入 → 只落 temp
|
||||
```
|
||||
|
||||
| | 根 agent | 驻留子 |
|
||||
|---|---|---|
|
||||
| main | **读写** | **只读**(可见全部) |
|
||||
| temp | —(它自己就是 main 的所有者) | **读写**(自己的空间) |
|
||||
| 查询范围 | main | **temp ∪ main** |
|
||||
|
||||
### 5.1 "轻量内核"的真正理由
|
||||
|
||||
不是砍功能,而是**记忆层被作用域化**:
|
||||
|
||||
- **写目标**被限定到 `temp`(子自己的命名空间);
|
||||
- **读视图**被扩成 `temp ∪ main`(两个空间的并集)。
|
||||
|
||||
完整内核的记忆层**硬绑定在单一 main 空间**上。要让每个 agent 都有自己的空间 +
|
||||
并集读视图,记忆层就必须接受**每个 agent 一份的作用域参数** —— 这就是轻量内核存在的理由。
|
||||
|
||||
### 5.2 推论:记忆写工具不禁用,而是**重定向**
|
||||
|
||||
子是**能写**的(写自己的 temp)。因此:
|
||||
|
||||
- 记忆写工具(图记忆写、文本记忆写、**文档记忆写**、**向量索引写**、**媒体落盘**)
|
||||
**不禁用,而是重定向到 temp 命名空间**;
|
||||
- 检索注入算"读",范围 `temp ∪ main`;
|
||||
- 子**不能**写 main ⇒ "哪些内容进入主记忆"的决策**结构上**只在父手里(§9.2)。
|
||||
|
||||
### 5.3 三种处置对记忆的作用
|
||||
|
||||
| 动作 | temp | main | 子 |
|
||||
|---|---|---|---|
|
||||
| **压缩** | 保留 | 不写 | 继续 |
|
||||
| **回收** | 父读 → **选中的 promote 进 main** → 丢弃 temp | 父写 | 取消 |
|
||||
| **销毁** | 直接丢弃 | 不写 | 立刻移除 |
|
||||
|
||||
### 5.5 子的记忆面:**传统上下文 + 图记忆** [已定]
|
||||
|
||||
| 记忆能力 | 根 agent | 驻留子 |
|
||||
|---|---|---|
|
||||
| **图记忆**(实体/关系/句子,含其向量检索) | ✅ main 读写 | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||
| **doc 记忆**(文档记忆) | ✅ | ❌ **不可用**(内核独立设计的记忆能力,父专属) |
|
||||
| **context 动态上下文**(动态上下文装配/裁剪) | ✅ | ❌ **不可用**;子用**传统上下文**(纯消息序列) |
|
||||
| 蒸馏 / 归档 / consolidation | ✅ | ❌(属上述父专属能力) |
|
||||
| 文本记忆 / 知识库 / 媒体 / 社交 | ✅ | ❌(同上) |
|
||||
|
||||
⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** ——
|
||||
不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。
|
||||
|
||||
### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定]
|
||||
|
||||
轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**:
|
||||
|
||||
```
|
||||
子的轻量内核
|
||||
├─ temp 图记忆实例(独立存储,**读写**) ← 子的一切图记忆写入落这里,与子同生共死
|
||||
└─ 主图记忆的**受限句柄**(只读) ← 子只能读
|
||||
· OpenGraphDBReadOnly:连接可读/可恢复 WAL,但 SQLite 层 `PRAGMA query_only=1`
|
||||
把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉
|
||||
子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集)
|
||||
```
|
||||
|
||||
- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。
|
||||
- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。
|
||||
- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。
|
||||
- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关
|
||||
(`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**,
|
||||
没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。
|
||||
|
||||
### 5.4 待钉的边界 [默认]
|
||||
|
||||
- temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度),
|
||||
不是独立存储 ⇒ "并集读"就是一次查询里的两个 space 条件;
|
||||
- 父**可读**子的 temp(要决定 promote 什么),属"查看状态面"的一部分;
|
||||
- temp **与子同生共死**;子之间 temp **互不可见**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 中断模型:两条独立阶梯 [已定]
|
||||
|
||||
```
|
||||
在【父的】中断阶梯上:
|
||||
子的主动消息 = L3 中断 (子主动汇报,带子标识)
|
||||
子的 contextfull = L4 中断 (资源耗尽,需父立即决策,带子标识)
|
||||
|
||||
在【子的】中断阶梯上:
|
||||
父的消息(发送消息) = L4 中断 ← 子的 L4 归父独占
|
||||
```
|
||||
|
||||
### 6.1 L4 归属通则
|
||||
|
||||
> **某个 agent 的 L4 只属于它的"内核"。**
|
||||
|
||||
- 根 agent 的内核 = 内核自身(panic / 内核事件 selfip)+ 内核级插件(WebUI 终止按钮);
|
||||
- 驻留子的内核 = **父 agent**。
|
||||
|
||||
⇒ 现有 `isKernelLevelSource`(只认编译期内置插件)**泛化为"该 agent 的上级"**,不为子开特例。
|
||||
|
||||
### 6.1.1 **父消息 = 子的 L4**(落地机制,钉死)
|
||||
|
||||
父 → 子的"发送消息"是一条 **L4 中断**,它是**子的阶梯上唯一的 L4 来源**。具体落地:
|
||||
|
||||
```
|
||||
父【发送消息】到指定子
|
||||
└─ 经输出通道寻址到该子的某个 inputch
|
||||
└─ 在该子的调度器里按 L4 登记(子的内核级来源 = 父)
|
||||
└─ 子的 L4 > 子内部一切 ⇒ 立即打断子的当前任务
|
||||
```
|
||||
|
||||
- **子内部任何来源都够不到 L4**:子自己的输入注入、工具、定时器、插件……
|
||||
一律被夹到 **≤ L3**(与根 agent 里"外部插件被夹到 L3"完全同一条规则,
|
||||
只是"内核级来源"从"编译期内置插件"变成了"父 agent")。
|
||||
- 后果一:**子内部任何东西都压不过父**(父的话是子的最高级输入)。
|
||||
- 后果二:父的"取消当前状态 + 插入新消息"因此是**确定能生效**的
|
||||
—— 不会因子内部正在跑什么而被挡住(除非子处于不可抢占临界区,
|
||||
此时按调度器既有规则在安全点生效)。
|
||||
|
||||
**子侧内核级事件如何上报**(推论,待确认):子的 panic / contextfull 属于**子侧的内核级事件**,
|
||||
但在**子的**阶梯上 L4 归父独占,所以它们不能作为"子自己的 L4";应当**上报给父**,
|
||||
在**父的阶梯上以 L4(带子标识)**出现 —— 与 §9 的 contextfull 同一条通路。
|
||||
(即:父侧 L4 = 子侧内核级事件的接收位;子侧 L4 = 父控制语的发射位。)
|
||||
|
||||
### 6.2 为什么 contextfull 是 L4 而不是 L3
|
||||
|
||||
它是**必须由父立刻决策**的场合(三个处置都与子的存续有关),且决策要读**一整个状态面** ⇒
|
||||
只能"**推信号 + 拉状态**",不能把状态塞进中断消息里。
|
||||
|
||||
---
|
||||
|
||||
## 7. 父对子的控制面(6 个动作)[已定:原语在内核,决策在父的模型]
|
||||
|
||||
| 动作 | 语义 | 子是否继续存在 | 走哪条路 |
|
||||
|---|---|---|---|
|
||||
| **创建** | 划入输入通道 + 授权输出通道 + 授权插件/工具(默认完整)+ 在固定提示词之上注入任务提示词 | — | 内核原语 |
|
||||
| **发送消息** | 经输出通道寻址到子的 inputch:**取消当前状态 + 插入新消息** | 是 | 对子 = **L4 中断** |
|
||||
| **查看** | **pull** 子的状态面(处理表 + temp 产出),**不打断**子 | 是 | 纯查询,**不走中断** |
|
||||
| **压缩** | **保留语义**:压上下文 + **清理处理表** | **是**(同一驻留子) | 见 §9 |
|
||||
| **回收** | **取消语义**:父看状态面 → 选择哪些 **promote 进 main** → **取消**该驻留子 | 否 | 见 §9 |
|
||||
| **销毁** | **立刻销毁并从登记表移除**(不收割) | 否 | 立即 |
|
||||
|
||||
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)**;**决策**(压还是收、收哪些)
|
||||
在父的模型手里 —— 内核不替父决定。
|
||||
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
|
||||
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
|
||||
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
|
||||
|
||||
---
|
||||
|
||||
## 8. inputch 处理表
|
||||
|
||||
### 8.1 归属与方向
|
||||
|
||||
- **子是持有者**;父**主动查看(pull)**,**不是**推给父。
|
||||
- **内容**:按 inputch 记录**每一轮**子对该 inputch 的处理信息。
|
||||
- **存在意义**:长期驻留子的**进度可见性** —— 父不必打断它就能知道它做到哪。
|
||||
|
||||
### 8.2 写入规则
|
||||
|
||||
- 子**主动写入**时,系统**不**自动写;
|
||||
- 子**未主动写入**时,系统**自动**把该轮 inputch 对应的信息写进去;
|
||||
- ⇒ **每一轮必有记录**,父不会看到空洞。
|
||||
|
||||
[默认]"主动写入"的动作形态 = 子调用一个 `inputch_note` 类**工具**;
|
||||
自动写入在轮次结束时由内核兜底。
|
||||
|
||||
### 8.3 生命周期 = 上下文窗口
|
||||
|
||||
处理表记的是"**当前这段上下文窗口**里每轮 inputch 做了什么"。
|
||||
因此:
|
||||
|
||||
- **压缩必须清表**(窗口被压成摘要后,逐轮记录被摘要取代;留着会让父看到与当前窗口
|
||||
不对应的陈旧状态);
|
||||
- **回收不必清表**(表就是父刚读过的收割材料,子都没了,表自然作废);
|
||||
- ⇒ 处理表天然有**大小上界**(窗口多大、表最长多长),不需要额外容量策略。
|
||||
|
||||
---
|
||||
|
||||
## 9. contextfull 的处置
|
||||
|
||||
```
|
||||
子的上下文窗口满
|
||||
└─ 产生 contextfull → **L4 中断**通知父(中断信息里标明是哪个子)—— 只推信号
|
||||
└─ 父【查看】子的状态面(处理表 + temp 产出)
|
||||
├─ 【压缩】压成摘要 → 清处理表 → 子续用 (保留语义)
|
||||
├─ 【回收】选择 temp 中哪些 promote 进 main (取消语义)
|
||||
│ → 丢弃 temp → 取消该驻留子
|
||||
└─ 【销毁】立刻销毁并移除 (不收割)
|
||||
```
|
||||
|
||||
| 动作 | 语义 | 子上下文/成果 | 子 agent | 处理表 |
|
||||
|---|---|---|---|---|
|
||||
| **压缩** | **保留** | 压成摘要 | **继续存在** | **必须清理** |
|
||||
| **回收** | **取消** | 选中的 promote 进 main | **取消** | 不必清 |
|
||||
| **销毁** | 立刻销毁并移除 | 不收割 | 立刻销毁 + 出登记表 | 无关 |
|
||||
|
||||
[默认]压缩由**子的轻量内核自己执行**(它拥有自己的上下文与 LLM)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 登记表与生命周期硬约束
|
||||
|
||||
- 父持 **agent 登记表**,记录全部子:`id / 状态 / 划入的输入通道 / 授权的输出通道 /
|
||||
授权的插件与工具 / 状态面句柄`。
|
||||
- 它是**查看 · 发送 · 压缩 · 回收 · 销毁**的寻址依据。
|
||||
- **硬约束(必须写成测试)**:
|
||||
1. 父 `Stop()` ⇒ 销毁全部子(取消运行中的任务、停轻量内核、释放其通道),
|
||||
**登记表清空、不留孤儿**;
|
||||
2. **子不得比父活得久**(无孤儿 goroutine / 无悬空通道 / 无残留 temp)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 并发与不变量
|
||||
|
||||
沿用输入调度器的并发模型,并按多 agent 扩展:
|
||||
|
||||
- **每个 agent 一个调度器 goroutine**(根 agent 与每个驻留子各一个),
|
||||
它**独占**自己的队列 / running / 中断栈 / 帧。
|
||||
- 跨 agent 投递只经**通道**(值传递),**不共享帧**;父**永远不能**直接改子的帧。
|
||||
- **子不得比父活得久**(§10)。
|
||||
- 父的"查看"是**只读快照**,不阻塞子、不参与子的调度决策。
|
||||
- **L4 独占**:子的调度器只接受来自父的 L4(§6.1)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 与现有实现的接合点(差距清单)
|
||||
|
||||
| 设计项 | 现状 | 要做 |
|
||||
|---|---|---|
|
||||
| 两类别 + 四级中断 + 抢占/挂起/恢复/中断栈 | ✅ 已落地(见 input-scheduler-design.md) | 复用 |
|
||||
| inputch 一等化(归属/容量/授权) | ❌ `inputCh` 是全局单 channel;`ChannelDef` 只是策略表 | **新增通道注册与分配层** |
|
||||
| 输出通道可寻址到 agent | ❌ 只有字符串标签;`output_list_channels` 列全部 | 通道解析表 + 授权过滤 |
|
||||
| 子的 L4 = 父 | ⚠️ `isKernelLevelSource` 只认内置插件 | 泛化为"该 agent 的上级"(分层) |
|
||||
| 轻量内核(记忆作用域化) | ❌ 记忆层绑定单一 main 空间 | 记忆子系统加 **space 维度**;`AgentConfig` 加**作用域参数** |
|
||||
| 记忆写路径重定向到 temp | ❌ 写路径无空间概念 | 所有写入口带 space;子的一切写 → temp |
|
||||
| 驻留子生命周期 + 登记表 | ❌ 只有一次性 `runChildTask` | 驻留子 + 父的登记表 + 退出清理 |
|
||||
| 跨 agent 投递(子→父 L3 / 父→子 L4) | ❌ 无 | 投递原语(复用注入层 + 通道寻址) |
|
||||
| inputch 处理表 | ❌ 无 | 新数据结构 + 主动写入工具 + 自动写兜底 |
|
||||
| contextfull 检测 | ❌ **完全没有** | 检测 + L4 通知(带子标识)+ 三处置 |
|
||||
| 内核不持有"当前通道" | ❌ `currentOutputChannel` + 提示词预设 | 删字段、删预设 |
|
||||
| 工作式轻量子 | ✅ | **不动** |
|
||||
|
||||
### 16.0 N2c 施工方案(轻量内核接线)[已定方案:**窄接口 + nil 即禁用**]
|
||||
|
||||
**先按"谁在调"把 `a.memory` 的 42 处使用分类**(`grep` 实测,非估计):
|
||||
|
||||
| 分组 | 位置 | 方法 | 谁用 |
|
||||
|---|---|---|---|
|
||||
| **A 记忆整理流水线** | `distill.go`(10):`archiveLoop` / `reviewLoop` / `mergeLoop` / `detectEntityMerge` / `reviewRelations` / `archiveColdDocs` | `Recall`, `ClearSentenceID`, `CleanupOrphanedSentences` | **root-only**(后台定时器) |
|
||||
| **B 记忆块 + 媒体桥** | `graphmedia.go`(18)、`medialoop.go`(4) | `PutMemoryBlocks`, `AddMemoryBlockEdge`, `BlocksForNode`, `PutDocumentNode`, `MemoryBlocks`, `MigrateLegacyMediaEntities`, `mediaContextFor*` | **root-only** |
|
||||
| **C 记忆整理工具** | `toolcall.go::executeMemoryTool`(8) | `Introspect`, `MergeEntities`, `DeleteEntity`, `Purge`, `Commit` | **root-only**(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`) |
|
||||
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go:130`(`memory_recall`) | **只有 `Recall` + `Commit`** | 根与子都要 |
|
||||
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
|
||||
|
||||
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
|
||||
(用户指出的关键点),**子根本不该有那些代码路径**。
|
||||
|
||||
#### 设计:窄接口 + nil 即禁用(不写"18 个方法返回错误"的受限包装)
|
||||
|
||||
```go
|
||||
// core 内部:共同面(根与子都要)
|
||||
type GraphMemory interface {
|
||||
Recall(keywords, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||||
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||||
}
|
||||
```
|
||||
|
||||
| | `a.graph`(共同面) | `a.memory`(整理面:块/媒体/流水线/整理工具) |
|
||||
|---|---|---|
|
||||
| **根 agent** | 同一个 `*GraphDB` | `*GraphDB` |
|
||||
| **驻留子** | `*LightMemory` | **`nil`** |
|
||||
|
||||
- 子把 `a.memory` 设为 `nil` ⇒ **既有的 22 处 nil 关卡自动禁掉全部 root-only 路径**
|
||||
(`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
|
||||
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`:
|
||||
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
|
||||
- 工具表:`memory_recall`(读)对子开放;整理类
|
||||
(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`)
|
||||
**不进子的工具表**(而不是让它们进去再报"不可用")。
|
||||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
||||
|
||||
### 16.0.0 传统上下文的实现口径(子 vs 父)
|
||||
|
||||
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
|
||||
|
||||
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|
||||
|---|---|---|---|
|
||||
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) |
|
||||
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
|
||||
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
|
||||
|
||||
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
|
||||
**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ——
|
||||
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
|
||||
|
||||
**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2,
|
||||
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
|
||||
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的
|
||||
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
|
||||
|
||||
### 16.0.1 工具面(已实现)
|
||||
|
||||
| 工具 | 谁用 | 作用 |
|
||||
|---|---|---|
|
||||
| `resident_agents` | 父 | **单工具多动作**:`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4)/ `inspect`(pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` |
|
||||
| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断**) |
|
||||
| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) |
|
||||
|
||||
> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。
|
||||
|
||||
### 16.1 N2 的记忆面清单(现状)
|
||||
|
||||
`internal/memory/` 下需要加 space 维度的面:
|
||||
|
||||
| 面 | 载体 | 表 | 子 agent 可用? |
|
||||
|---|---|---|---|
|
||||
| **图记忆** | `memory.GraphDB` | `entities` / `sentences` / `relations` | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||
| **图记忆的向量检索** | `memory.Indexer` | (索引侧,与图记忆同步) | ✅ 同图记忆(需按 space 过滤) |
|
||||
| 文档记忆(doc 记忆) | `document.Store` | `documents` | ❌ 父专属 |
|
||||
| context 动态上下文 | 内核上下文装配/裁剪(`pruneOnInput` 等) | — | ❌ 父专属;子用传统上下文 |
|
||||
| 知识库 | `knowledge.Store` | 各自表 | ❌ |
|
||||
| 文本记忆 | `text.Memory` | 各自表 | ❌ |
|
||||
| 媒体 | `media.Store` | 落盘 + 索引 | ❌ |
|
||||
| 社交 | `social` | 各自表 | ❌ |
|
||||
|
||||
⇒ N2a 做**图记忆 + 其向量检索**这一条纵切(子唯一可用的记忆面,
|
||||
也是"子写 temp / 父 promote 进 main"的主战场);其余面在 v1 **不加 space 维度**
|
||||
(子根本够不到,加了是白工)——若将来子扩展记忆面再逐面补。
|
||||
|
||||
---
|
||||
|
||||
## 13. 非目标(本文明确不做)
|
||||
|
||||
1. 跨进程 / 跨主机的驻留子(v1 只在同进程内)。
|
||||
2. 子的**子**(驻留子再创建驻留子)——先不做,保留扩展位。
|
||||
3. main 空间的**多写者**(父是唯一写者,不做并发合并)。
|
||||
4. temp 空间的持久化(跟子同生共死,不落盘)。
|
||||
5. 工作式轻量子的任何行为变更。
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试点、方式与预期
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期 |
|
||||
|---|---|---|---|
|
||||
| S1 | 创建:划入输入通道 + 授权输出通道 | 创建子,向划入的 inputch 投输入 | 子处理它;未划入的 inputch 投不进(或报错) |
|
||||
| S2 | 授权收窄 | 创建时只授权部分工具/插件 | 子的工具表恰为该子集;`output_list_channels` 只列授权的 |
|
||||
| S3 | 默认完整授权 | 不传授权参数创建 | 子拿到全部插件/工具 |
|
||||
| S4 | 子→父 L3 | 子在工作中主动发消息 | 父侧收到 **L3 中断**且带子标识;父可被打断(非临界区时) |
|
||||
| S5 | contextfull → L4 | 灌满子的上下文 | 父侧收到 **L4 中断**、带子标识,且**只推信号** |
|
||||
| S6 | 父→子 L4 | 父"发送消息"到指定子 | 子在收到时被打断(L4 > 子内部一切),按 §15 的默认挂起/恢复 |
|
||||
| S7 | 查看(pull 不打断) | 子在跑长任务时父"查看" | 返回状态面快照;**子的 step/帧不变**、未被抢占 |
|
||||
| S8 | 处理表:自动写兜底 | 子一轮不主动写 | 该轮仍有记录(系统自动写) |
|
||||
| S9 | 处理表:主动写优先 | 子主动写 `inputch_note` | 该轮只有主动写的内容,无自动写 |
|
||||
| S10 | 压缩(保留语义) | 父选压缩 | 上下文变短;**处理表被清空**;**子继续存在**且能继续干活 |
|
||||
| S11 | 回收(取消语义) | 父选回收并挑若干条 promote | 选中内容进 **main**、其余丢弃;**temp 被丢弃**;**子被取消** |
|
||||
| S12 | 销毁(立刻) | 父销毁(含子正在跑工具/LLM 时) | 子立刻消失、出登记表、其 temp 丢弃、通道释放 |
|
||||
| S13 | 子不得写 main | 子调记忆写工具 | 落在 **temp**;main 无新增 |
|
||||
| S14 | 子查询范围 = temp ∪ main | 子查只在 main 里的内容 / 只在 temp 里的内容 | 两者都能查到 |
|
||||
| S15 | 子之间 temp 隔离 | 两个子各写 temp,互相查 | 查不到对方的 temp |
|
||||
| S16 | 父退出清理 | 父 `Stop()`(多个子、有子在工作中) | 全部子被销毁;登记表空;**无孤儿 goroutine / 无悬空通道 / 无残留 temp** |
|
||||
| S17 | L4 独占 | 子内部(子自己的输入/工具/定时器)试图产生 L4 | 被夹到 **≤L3**;只有父的消息是 L4 |
|
||||
| S21 | 一个插件多个 inputch 可分别路由 | 同一插件注册 2 个 inputch,分别划给父与子后各投一条输入 | 各自只到被划给的 agent,互不串台 |
|
||||
| S22 | inputch 总览(单工具多视图) | 一个插件注册 2 个 inputch、另一插件 1 个;把其中若干划给本 agent | `view=all` 列出全部(带归属插件);`mine`/`unassigned` 各自正确;`by_agent` 给出划分总览;`detail` 给出单条全字段;未知 view 报错并列出可用值 |
|
||||
| S23 | 插件重载不抹划分 | 先划分 inputch,再重复登记(模拟插件重载) | Owner/Capacity 保留,仅策略被更新 |
|
||||
| S19 | 父消息必能打断子 | 子在长任务中(LLM 流式段)时父发送消息 | 子按 L4 被打断;若子在不可抢占临界区,则在安全点生效 |
|
||||
| S20 | 子的内核级事件上报 | 子 panic / 子 contextfull | 在**父的阶梯上以 L4(带子标识)**出现;子侧不自己产生 L4 |
|
||||
| S18 | 内核不持有"当前通道" | 抢占/中断后被打断任务恢复并发响应 | 提示词与事件标签都**只来自输入事件**(不再有被覆盖的字段) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 待确认决策(含默认取值)
|
||||
|
||||
| 编号 | 问题 | 取值 |
|
||||
|---|---|---|
|
||||
| **R1** | 驻留子的内核形态 | **[已定]独立轻量内核**(自己的调度器/中断栈/上下文/记忆作用域) |
|
||||
| **R2** | 插件与工具 | **[已定]父授权,默认完整授权**(可收窄) |
|
||||
| **R3** | 记忆模型 | **[已定]两级空间**:读 temp∪main,写 temp(**范围 = 图记忆**) |
|
||||
| **R3b** | 子的记忆面 | **[已定]传统上下文 + 图记忆**;doc 记忆与 context 动态上下文是**父专属**(内核独立设计的记忆能力) |
|
||||
| **R13** | inputch 总览的形态 | **[已定]单工具多视图**(`input_channels` + `view`) |
|
||||
| **R11** | 父消息的级别 | **[已定]对子 = L4**(子的阶梯上唯一 L4 来源;子内部一律 ≤L3) |
|
||||
| **R12** | 子的内核级事件(panic / contextfull)上报级别 | **[默认/推论]在父的阶梯上以 L4(带子标识)上报** |
|
||||
| **R4** | 父→子消息落地 | **[默认]挂起/恢复**(现场不丢);一处开关可改"直接丢弃" |
|
||||
| **R5** | "主动写入处理表"的形态 | **[默认]子调用 `inputch_note` 类工具**;未调用则轮末自动写 |
|
||||
| **R6** | 输入通道"划入"的语义与容量 | **[默认]读写授权(不转移所有权)+ 创建时给定容量**;**划入单位 = inputch**(不是插件、不是通道组) |
|
||||
| **R7** | 压缩由谁执行 | **[默认]子的轻量内核自己压**(它有自己的上下文与 LLM),压缩后清表 |
|
||||
| **R8** | 压缩前父是否"查看后决定" | **[默认]纯机械压缩**(父只在选"压缩 vs 回收"时决策) |
|
||||
| **R9** | 回收时处理表 | **[默认]不必清**(子都没了);若日后要留作审计需单独策略 |
|
||||
| **R10** | 默认授权是否含输出门 | **[默认]含**("默认完整授权"的字面含义);若嫌宽,改默认即可 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 实现里程碑(每步 = 一个可独立验收的提交)
|
||||
|
||||
| 里程碑 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18;既有全部测试通过(这是纯收敛,不含新能力) |
|
||||
| **N1a** | **通道登记层**:inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21–S23 |
|
||||
| **N1b** | 输出通道授权过滤 + 目标解析(outputch → 目标 agent 的 inputch) | S1–S3 |
|
||||
| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**:`OpenGraphDBReadOnly`(query_only 受限句柄)+ `LightMemory`(temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13–S15 ✅ |
|
||||
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
|
||||
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
||||
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
||||
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
||||
| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
|
||||
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/<id>` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
|
||||
| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
|
||||
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ |
|
||||
| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ |
|
||||
|
||||
每步收尾命令:
|
||||
|
||||
```bash
|
||||
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
|
||||
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||
go build ./... && go vet ./...
|
||||
go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal/plugin/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 与发布纪律的关系
|
||||
|
||||
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||||
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||||
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|
||||
3
go.mod
3
go.mod
@ -18,7 +18,6 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.3.8
|
||||
)
|
||||
|
||||
require (
|
||||
@ -41,6 +40,8 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk
|
||||
|
||||
@ -17,7 +17,6 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
@ -26,47 +25,19 @@ import (
|
||||
// ContextEvent 和 RelevanceContext 定义在 context.go
|
||||
|
||||
// Agent — 单 agent,不区分会话/实例
|
||||
//
|
||||
// 并发现状(M3a 起):所有任务状态只由 **schedulerLoop goroutine** 独占读写,
|
||||
// 因此不再有保护整轮执行的互斥量——挂起不能持锁(见 docs/zh/input-scheduler-design.md §8.1 I3)。
|
||||
// 仍需跨 goroutine 保护的是:childMu/llmMu/lastInputMu/noMergeMu 与各子系统自己的锁;
|
||||
// interceptLoop 只允许触碰 preemptionRequest 与 cancelLLM(经 llmMu)。
|
||||
type Agent struct {
|
||||
mu sync.Mutex
|
||||
id types.AgentID
|
||||
provider agentAPI.Provider
|
||||
providerManager *agentAPI.ProviderManager
|
||||
io *agentIO.IOManager
|
||||
memory *memory.GraphDB
|
||||
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
||||
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
||||
graph GraphMemory
|
||||
|
||||
// kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。
|
||||
kernelSource string
|
||||
parentID string
|
||||
taskPrompt string
|
||||
dataDir string
|
||||
|
||||
// 驻留子(父侧):登记表 + 子侧钩子。
|
||||
residentMu sync.Mutex
|
||||
residents map[string]*residentChild
|
||||
|
||||
// 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。
|
||||
notifyParent func(text string)
|
||||
onContextFull func()
|
||||
ctxFullSignaled bool
|
||||
|
||||
// 子侧:inputch 处理表(子持有,父 pull)。
|
||||
tableMu sync.Mutex
|
||||
inputchTable []InputchRecord
|
||||
inputchPending *InputchRecord
|
||||
currentInputch string
|
||||
indexer *memory.Indexer
|
||||
tracker *tracker.Tracker
|
||||
context *RelevanceContext
|
||||
systemPrompt string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
indexer *memory.Indexer
|
||||
tracker *tracker.Tracker
|
||||
context *RelevanceContext
|
||||
systemPrompt string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// 文档记忆(第二层)
|
||||
docStore *document.Store
|
||||
@ -80,21 +51,20 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
|
||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||||
mediaStore *media.Store
|
||||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
||||
mediaGCInterval time.Duration
|
||||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
||||
mediaGCMinAge time.Duration
|
||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||||
mediaDescribe bool
|
||||
|
||||
// 人格设定(内容来自启动时载入的人格文件/配置项)
|
||||
// 人格设定
|
||||
personality *agentPkg.Personality
|
||||
|
||||
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go)。
|
||||
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
||||
personaStore PersonaStore
|
||||
|
||||
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。
|
||||
allowedOutputs []string
|
||||
|
||||
// 插件注册表(用于 plgreload)
|
||||
pluginReg *plugin.Registry
|
||||
pluginDir string
|
||||
@ -111,6 +81,7 @@ type Agent struct {
|
||||
maxContextSize int
|
||||
|
||||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||||
currentOutputChannel string
|
||||
|
||||
// 阶段管道:插件消息流编辑
|
||||
stageHost *StageHost
|
||||
@ -124,24 +95,13 @@ type Agent struct {
|
||||
selfInputCh chan selfInputMsg
|
||||
|
||||
// 子任务异步执行
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
|
||||
//
|
||||
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
|
||||
// (formatMergedTimeline 每轮都重新注入),模型之后还会再查。若读到即删,
|
||||
// 第二次查询就得到“不存在或已过期”这个**永久失败信号**——模型据此认为
|
||||
// 任务未完成,会无限重试/汇报(实测单轮 35 次工具调用、持续 514 秒)。
|
||||
childTasks map[string]*childTaskState
|
||||
// childSeq 给完成的任务排个序,用于有界淘汰。
|
||||
childSeq int64
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
childResults map[string]string
|
||||
childRunning map[string]bool // 运行中的子任务(child_result 查询时区分'运行中'与'不存在')
|
||||
|
||||
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go)。
|
||||
// M2 起取代 eventLoop 的隐式 channel 排队。
|
||||
sched *scheduler
|
||||
|
||||
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
|
||||
maxToolTurns int
|
||||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||||
interceptCh chan *agentIO.InputEvent
|
||||
|
||||
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||||
cancelLLM context.CancelFunc
|
||||
@ -162,7 +122,7 @@ type Agent struct {
|
||||
//
|
||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 由 schedulerLoop goroutine 独占读写。
|
||||
// 与 pendingMedia 同受 a.mu 保护。
|
||||
pendingMediaDigests []string
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
@ -184,19 +144,6 @@ type Agent struct {
|
||||
// 词嵌入模型,用于实体语义相似度计算
|
||||
embedder *memory.StaticEmbedder
|
||||
|
||||
// multimodalSpace 是统一多模态向量空间(可选)。实现可以是内嵌 ONNX,
|
||||
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
|
||||
multimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
|
||||
// embeddingError 是打开/适配失败的原因(成功时为空)。
|
||||
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
|
||||
embeddingProvider string
|
||||
embeddingError string
|
||||
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||||
skillIndex SkillIndexProvider
|
||||
}
|
||||
@ -216,45 +163,19 @@ type AgentConfig struct {
|
||||
ProviderManager *agentAPI.ProviderManager
|
||||
IO *agentIO.IOManager
|
||||
Memory *memory.GraphDB
|
||||
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。
|
||||
//
|
||||
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil,
|
||||
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go)。
|
||||
LightMemory *memory.LightMemory
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||
EmbeddingProvider string
|
||||
EmbeddingError string
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||
PluginReg *plugin.Registry
|
||||
// KernelSource 是本 agent 的"上级"(驻留子的父)。
|
||||
//
|
||||
// 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与
|
||||
// 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父
|
||||
// 能在子的阶梯上产生 L4(父的"发送消息")。
|
||||
KernelSource string
|
||||
// ParentID 是父 agent 的 id(空 = 根 agent)。子用它判断自己是不是驻留子。
|
||||
ParentID string
|
||||
// DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。
|
||||
DataDir string
|
||||
// TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。
|
||||
TaskPrompt string
|
||||
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。
|
||||
//
|
||||
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||
// 父 agent 创建驻留子时用它收窄子的输出能力。
|
||||
AllowedOutputs []string
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MediaGCInterval time.Duration
|
||||
MediaGCMinAge time.Duration
|
||||
MediaDescribe bool
|
||||
Personality *agentPkg.Personality
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
@ -271,10 +192,6 @@ type AgentConfig struct {
|
||||
SkillIndexProvider SkillIndexProvider
|
||||
|
||||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||||
|
||||
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
|
||||
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
|
||||
MaxToolTurns int
|
||||
}
|
||||
|
||||
func New(cfg AgentConfig) *Agent {
|
||||
@ -300,7 +217,8 @@ func New(cfg AgentConfig) *Agent {
|
||||
embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
|
||||
}
|
||||
if cfg.DocStore != nil {
|
||||
// TF-IDF 内置为 fallback,无需外部注入
|
||||
cfg.DocStore.SetVectorizer(embedder)
|
||||
cfg.DocStore.ReindexWithVectorizer(embedder)
|
||||
}
|
||||
if cfg.Knowledge != nil {
|
||||
cfg.Knowledge.SetVectorizer(embedder)
|
||||
@ -314,177 +232,79 @@ func New(cfg AgentConfig) *Agent {
|
||||
if cfg.IO != nil {
|
||||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||||
}
|
||||
// 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、
|
||||
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
|
||||
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
|
||||
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
|
||||
rc.SetDenseSpace(cfg.MultimodalSpace)
|
||||
if cfg.DocStore != nil {
|
||||
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
|
||||
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
|
||||
}
|
||||
}
|
||||
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
|
||||
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
|
||||
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
|
||||
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
|
||||
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
|
||||
rc.SetMediaStore(cfg.MediaStore)
|
||||
|
||||
a := &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
personaStore: cfg.PersonaStore,
|
||||
allowedOutputs: cfg.AllowedOutputs,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
sched: newScheduler(256),
|
||||
maxToolTurns: cfg.MaxToolTurns,
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
embeddingProvider: cfg.EmbeddingProvider,
|
||||
embeddingError: cfg.EmbeddingError,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
mediaGCInterval: cfg.MediaGCInterval,
|
||||
mediaGCMinAge: cfg.MediaGCMinAge,
|
||||
mediaDescribe: cfg.MediaDescribe,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childResults: make(map[string]string),
|
||||
childRunning: make(map[string]bool),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
|
||||
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
|
||||
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
|
||||
if a.io != nil {
|
||||
a.io.SetInputRouter(a.routeInputByOwner)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
|
||||
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||||
|
||||
func (a *Agent) Start() {
|
||||
go a.schedulerLoop()
|
||||
go a.eventLoop()
|
||||
go a.interceptLoop()
|
||||
go a.distillLoop()
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
a.reembedStaleMedia()
|
||||
a.migrateLegacyGraphMedia()
|
||||
go a.mediaGCLoop()
|
||||
go a.mediaDescribeLoop()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
// (设计 §7 I5、§11.3 X2/X4)。必须在 cancel 之前做:cancel 会让调度器直接 return。
|
||||
a.drainPendingInterrupts("agent_stopped")
|
||||
a.cancel()
|
||||
}
|
||||
|
||||
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
|
||||
// skipped 终态(复用 emitSkippedReply:非阻塞写,不对外发 agent_output 事件)。
|
||||
func (a *Agent) drainPendingInterrupts(reason string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
pending := a.sched.pendingEvents()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, evt := range pending {
|
||||
a.emitSkippedReply(evt, reason)
|
||||
}
|
||||
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
|
||||
}
|
||||
|
||||
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||
//
|
||||
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory)。
|
||||
func graphMemoryOf(cfg AgentConfig) GraphMemory {
|
||||
if cfg.LightMemory != nil {
|
||||
return cfg.LightMemory
|
||||
}
|
||||
if cfg.Memory == nil {
|
||||
return nil
|
||||
}
|
||||
return cfg.Memory
|
||||
}
|
||||
|
||||
// graphMem 返回本 agent 的图记忆**共同面**。
|
||||
//
|
||||
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
|
||||
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
|
||||
// 轻量内核则显式设 graph=LightMemory 且 memory=nil:共同面走 LightMemory,
|
||||
// 整理面因 memory==nil 而全部不可达。
|
||||
func (a *Agent) graphMem() GraphMemory {
|
||||
if a.graph != nil {
|
||||
return a.graph
|
||||
}
|
||||
if a.memory == nil {
|
||||
return nil
|
||||
}
|
||||
return a.memory
|
||||
}
|
||||
|
||||
func (a *Agent) ID() types.AgentID { return a.id }
|
||||
|
||||
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
|
||||
//
|
||||
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
|
||||
func (a *Agent) IsOutputAllowed(channel string) bool {
|
||||
if len(a.allowedOutputs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range a.allowedOutputs {
|
||||
if c == channel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveOutputTarget 解析输出通道的投递目标(agent + inputch)。
|
||||
//
|
||||
// ok=false 表示该输出通道由传输层(device 通道,如 qq/webui)自行处理。
|
||||
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
|
||||
if a.io == nil || a.io.ChannelRegistry() == nil {
|
||||
return agentIO.OutputTarget{}, false
|
||||
}
|
||||
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
|
||||
}
|
||||
|
||||
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
|
||||
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
|
||||
const duplicateInputWindow = 10 * time.Second
|
||||
|
||||
@ -15,14 +15,14 @@ type mockOutputDevice struct {
|
||||
toolFn func(string, map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.toolFn != nil {
|
||||
return d.toolFn(tool, args)
|
||||
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
|
||||
"type": "text",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "ok" {
|
||||
t.Errorf("expected ok, got: %s", result)
|
||||
if !strings.Contains(result, "screen") {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -12,6 +13,7 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
@ -22,8 +24,9 @@ type ToolResultItem struct {
|
||||
}
|
||||
|
||||
type ContextEvent struct {
|
||||
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
|
||||
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。
|
||||
//
|
||||
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
|
||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||||
ID string `json:"id,omitempty"`
|
||||
@ -33,12 +36,13 @@ type ContextEvent struct {
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
// --- 原生多模态记忆 ---
|
||||
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
|
||||
// Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。
|
||||
//
|
||||
// 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的
|
||||
// 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS
|
||||
// 永远能取回原始字节——只要它还没被容量 GC 淘汰。
|
||||
Media []string `json:"media,omitempty"`
|
||||
Vector vector.Vector `json:"-"`
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -47,12 +51,46 @@ type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
embedder *memory.StaticEmbedder
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
savePath string
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
|
||||
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
|
||||
mediaStore *media.Store
|
||||
}
|
||||
|
||||
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
|
||||
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mediaStore = s
|
||||
}
|
||||
|
||||
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。
|
||||
//
|
||||
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
|
||||
// 就会把仍被记忆引用的内容当孤儿清掉。
|
||||
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
|
||||
if c.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
for _, s := range archive {
|
||||
evt := s.event
|
||||
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, d := range evt.Media {
|
||||
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
}
|
||||
}
|
||||
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||
@ -66,14 +104,6 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
|
||||
return rc
|
||||
}
|
||||
|
||||
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
|
||||
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
|
||||
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.denseSpace = ds
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@ -96,7 +126,7 @@ func (c *RelevanceContext) load() {
|
||||
return
|
||||
}
|
||||
for _, evt := range events {
|
||||
c.computeVector(evt)
|
||||
evt.Vector = c.computeVector(evt)
|
||||
}
|
||||
c.events = events
|
||||
}
|
||||
@ -195,32 +225,12 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
|
||||
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
||||
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
|
||||
if text != "" {
|
||||
evt.Vector = c.embedder.Vectorize(text)
|
||||
}
|
||||
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
|
||||
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
|
||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||
fp := c.denseSpace.Fingerprint()
|
||||
var parts [][]float64
|
||||
if text != "" {
|
||||
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
|
||||
parts = append(parts, dv)
|
||||
}
|
||||
}
|
||||
for _, b := range evt.Blocks {
|
||||
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
|
||||
// 两边都不像的方向。
|
||||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
evt.DenseVec = vector.FuseVectors(parts...)
|
||||
evt.DenseFP = fp
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return c.embedder.Vectorize(text)
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Save() error {
|
||||
@ -241,7 +251,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.computeVector(&evt)
|
||||
evt.Vector = c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
c.save()
|
||||
@ -251,7 +261,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.computeVector(&evt)
|
||||
evt.Vector = c.computeVector(&evt)
|
||||
|
||||
idx := sort.Search(len(c.events), func(i int) bool {
|
||||
return c.events[i].Timestamp.After(evt.Timestamp)
|
||||
@ -297,7 +307,8 @@ func (c *RelevanceContext) flush() {
|
||||
|
||||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
||||
//
|
||||
// 提为包级类型:Prune 需要把待归档列表传给后续处理。
|
||||
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要
|
||||
// 把待归档列表传进去,局部类型无法出现在方法签名上。
|
||||
type scoredEvent struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
@ -323,29 +334,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return 0
|
||||
}
|
||||
|
||||
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
|
||||
var queryDense []float64
|
||||
useDense := false
|
||||
queryFP := ""
|
||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
||||
queryDense = dv
|
||||
queryFP = c.denseSpace.Fingerprint()
|
||||
useDense = true
|
||||
}
|
||||
}
|
||||
queryVec := c.embedder.VectorizeClean(currentInput)
|
||||
|
||||
scoredEvents := make([]scoredEvent, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
var score float64
|
||||
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
|
||||
// 属于另一个坐标系,拿来比会得到无意义的分数。
|
||||
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
|
||||
score = vector.DenseCosine(queryDense, evt.DenseVec)
|
||||
} else {
|
||||
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
}
|
||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
|
||||
}
|
||||
|
||||
@ -383,20 +376,16 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
|
||||
// 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用;
|
||||
// 因此归档后清空源事件的块,确保同一块不同时留在两层。
|
||||
for _, s := range archive {
|
||||
if s.event != nil {
|
||||
s.event.Blocks = nil
|
||||
}
|
||||
}
|
||||
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上,
|
||||
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
|
||||
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
|
||||
// 内容当孤儿清掉。
|
||||
c.transferMediaRefs(archive, doc.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -439,41 +428,6 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent {
|
||||
return result
|
||||
}
|
||||
|
||||
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
|
||||
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
|
||||
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []memory.MemoryBlock
|
||||
for _, e := range c.events {
|
||||
out = append(out, e.Blocks...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。
|
||||
//
|
||||
// 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。
|
||||
// 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。
|
||||
func (c *RelevanceContext) TrimKeepRecent(n int) int {
|
||||
c.mu.Lock()
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if len(c.events) <= n {
|
||||
c.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
dropped := len(c.events) - n
|
||||
kept := make([]*ContextEvent, n)
|
||||
copy(kept, c.events[dropped:])
|
||||
c.events = kept
|
||||
c.dirty = true
|
||||
c.mu.Unlock()
|
||||
c.save()
|
||||
return dropped
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@ -1,268 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
|
||||
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
}
|
||||
|
||||
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
|
||||
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
|
||||
type CrossModalFusionConfig struct {
|
||||
WeightText float64 // 文本路融合权重(默认 0.5)
|
||||
WeightVisual float64 // 视觉路融合权重(默认 0.5)
|
||||
DoubleHitBonus float64 // 双命中额外加分(默认 0.15)
|
||||
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12)
|
||||
}
|
||||
|
||||
var defaultFusionConfig = CrossModalFusionConfig{
|
||||
WeightText: 0.5,
|
||||
WeightVisual: 0.5,
|
||||
DoubleHitBonus: 0.15,
|
||||
MinMaxEps: 1e-12,
|
||||
}
|
||||
|
||||
func (c CrossModalFusionConfig) textWeight() float64 {
|
||||
if c.WeightText <= 0 {
|
||||
return defaultFusionConfig.WeightText
|
||||
}
|
||||
return c.WeightText
|
||||
}
|
||||
func (c CrossModalFusionConfig) visualWeight() float64 {
|
||||
if c.WeightVisual <= 0 {
|
||||
return defaultFusionConfig.WeightVisual
|
||||
}
|
||||
return c.WeightVisual
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
|
||||
return c.DoubleHitBonus
|
||||
}
|
||||
func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
if c.MinMaxEps <= 0 {
|
||||
return defaultFusionConfig.MinMaxEps
|
||||
}
|
||||
return c.MinMaxEps
|
||||
}
|
||||
|
||||
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
||||
//
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||
// 命中文档若持有媒体块,直接带上该块。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||
//
|
||||
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。
|
||||
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
|
||||
per := topK * 2
|
||||
if per < 8 {
|
||||
per = 8
|
||||
}
|
||||
|
||||
// ---- 文本路 ----
|
||||
var textHits []CrossModalHit
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
|
||||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
||||
hit.Media = it
|
||||
}
|
||||
}
|
||||
textHits = append(textHits, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
|
||||
var visualHits []CrossModalHit
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
|
||||
qv, err := a.multimodalSpace.VectorizeDense(query)
|
||||
if err != nil {
|
||||
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
|
||||
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||
} else {
|
||||
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||
held := a.heldMediaDigests()
|
||||
for _, h := range mh {
|
||||
if h.Item == nil || !held[h.Item.Digest] {
|
||||
continue
|
||||
}
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fuseCrossModal(textHits, visualHits, topK, cfg)
|
||||
}
|
||||
|
||||
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
|
||||
//
|
||||
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]:
|
||||
//
|
||||
// norm(x) = (x - min) / (max - min),max==min 时置 1
|
||||
//
|
||||
// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
|
||||
// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。
|
||||
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
|
||||
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
|
||||
out := make([]float64, len(hits))
|
||||
if len(hits) == 0 {
|
||||
return out
|
||||
}
|
||||
maxV, minV := pick(hits[0]), pick(hits[0])
|
||||
for _, h := range hits[1:] {
|
||||
v := pick(h)
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
if v < minV {
|
||||
minV = v
|
||||
}
|
||||
}
|
||||
for i, h := range hits {
|
||||
v := pick(h)
|
||||
if maxV-minV < cfg.minMaxEps() {
|
||||
out[i] = 1
|
||||
continue
|
||||
}
|
||||
out[i] = (v - minV) / (maxV - minV)
|
||||
}
|
||||
return out
|
||||
}
|
||||
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
|
||||
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
|
||||
|
||||
byKey := make(map[string]*CrossModalHit)
|
||||
var keys []string
|
||||
key := func(h CrossModalHit) string {
|
||||
if h.Doc != nil {
|
||||
return "doc:" + h.Doc.ID
|
||||
}
|
||||
if h.Media != nil {
|
||||
return "media:" + h.Media.Digest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先并入视觉路(视觉媒体是独立实体)
|
||||
for i, h := range visualHits {
|
||||
k := key(h)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.visualWeight() * visualN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
|
||||
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
|
||||
for i, h := range textHits {
|
||||
if h.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if h.Media != nil {
|
||||
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
|
||||
ex.DoubleHit = true
|
||||
ex.Doc = h.Doc
|
||||
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
|
||||
continue
|
||||
}
|
||||
}
|
||||
k := "doc:" + h.Doc.ID
|
||||
if ex, ok := byKey[k]; ok {
|
||||
ex.Doc = h.Doc
|
||||
ex.DoubleHit = false
|
||||
ex.Fused += cfg.textWeight() * textN[i]
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.textWeight() * textN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
var merged []CrossModalHit
|
||||
for _, k := range keys {
|
||||
if c := byKey[k]; c != nil {
|
||||
merged = append(merged, *c)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
if merged[i].DoubleHit != merged[j].DoubleHit {
|
||||
return merged[i].DoubleHit
|
||||
}
|
||||
return merged[i].Fused > merged[j].Fused
|
||||
})
|
||||
if len(merged) > topK {
|
||||
merged = merged[:topK]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for i, h := range hits {
|
||||
marker := ""
|
||||
switch {
|
||||
case h.DoubleHit:
|
||||
marker = "(图文双命中)"
|
||||
case h.Doc != nil:
|
||||
marker = "(文本命中)"
|
||||
case h.Media != nil:
|
||||
marker = "(视觉命中)"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("[%d]", i+1)}
|
||||
if h.Doc != nil {
|
||||
parts = append(parts, h.Doc.Summary)
|
||||
if h.Doc.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := mediaLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
}
|
||||
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
package core
|
||||
|
||||
// 设备类工具的**授权闸**:设备指令类工具走的是工具面,而 AllowedOutputs 只作用于
|
||||
// output_send__<通道> —— 不补闸的话"授权"对指令类完全无效(驻留子拿到
|
||||
// device_ctl_cmdrun 就能指挥任意设备)。这里按目标设备的通道名 device/<id> 查同一道闸。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// registerFakeDevice 注册一台假设备,带一个"需要 device_id 的指令类工具"和一个无参枚举工具。
|
||||
func registerFakeDevice(t *testing.T, a *Agent, name string, called *[]string) {
|
||||
t.Helper()
|
||||
dev := &mockOutputDevice{
|
||||
name: name,
|
||||
caps: agentIO.CapStructured,
|
||||
tools: []agentIO.ToolDef{
|
||||
{Name: "device_ctl_cmdrun", Description: "在设备上执行命令"},
|
||||
{Name: "devicedetect", Description: "枚举设备"},
|
||||
},
|
||||
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if called != nil {
|
||||
*called = append(*called, tool)
|
||||
}
|
||||
return "ok:" + tool, nil
|
||||
},
|
||||
}
|
||||
if err := a.io.RegisterDevice(dev); err != nil {
|
||||
t.Fatalf("注册测试设备 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceToolCall(name string, args map[string]interface{}) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{ID: "call_1", Name: name, Arguments: args}
|
||||
}
|
||||
|
||||
// 完整授权(根 agent 默认):设备指令工具照常可用。
|
||||
func TestDeviceToolAuth_RootHasFullGrant(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "pc-1", "command": "ls",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||
t.Fatalf("根 agent 应可指挥任意设备,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 收窄授权(驻留子):只授权了 device/ok-1,指挥别的设备必须被拒,且**不落到设备**。
|
||||
func TestDeviceToolAuth_NarrowedGrantRefusesOtherDevice(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"device/ok-1"}
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "other-2", "command": "rm -rf /",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "未授权") {
|
||||
t.Fatalf("未授权设备应被拒,实际: %s", got)
|
||||
}
|
||||
if len(called) != 0 {
|
||||
t.Fatalf("被拒的调用不得落到设备,实际执行了 %v", called)
|
||||
}
|
||||
|
||||
// 已授权的设备照常可用
|
||||
got = a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "ok-1", "command": "ls",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||
t.Fatalf("已授权设备应可用,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无参枚举类(devicedetect)不受闸门影响:它不指向具体设备。
|
||||
func TestDeviceToolAuth_EnumerationNotGated(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"device/ok-1"}
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("devicedetect", map[string]interface{}{}), "cli")
|
||||
if !strings.Contains(got, "ok:devicedetect") {
|
||||
t.Fatalf("枚举类工具不应被设备授权闸拦,实际: %s", got)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import (
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
@ -183,7 +184,7 @@ func (a *Agent) archiveColdDocs() {
|
||||
if len(triples) == 0 {
|
||||
continue
|
||||
}
|
||||
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
|
||||
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
@ -202,22 +203,64 @@ func (a *Agent) archiveColdDocs() {
|
||||
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
|
||||
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
|
||||
if len(doc.Blocks) > 0 {
|
||||
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
|
||||
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
|
||||
doc.ID, bound, len(doc.Blocks))
|
||||
continue
|
||||
}
|
||||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
|
||||
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录
|
||||
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
|
||||
//
|
||||
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
|
||||
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
|
||||
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
|
||||
// 后续一致性检查清理),也不能丢内容。
|
||||
refs, refErr := a.docMediaRefs(doc.ID)
|
||||
switch {
|
||||
case refErr != nil:
|
||||
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
|
||||
case len(refs) == 0:
|
||||
// 该文档本就没有媒体引用,无需释放。
|
||||
case mediaBound == 0:
|
||||
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
|
||||
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)",
|
||||
doc.ID, len(refs))
|
||||
default:
|
||||
a.releaseDocMedia(doc.ID)
|
||||
}
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。
|
||||
//
|
||||
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
|
||||
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
|
||||
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
}
|
||||
|
||||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||||
//
|
||||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||||
func (a *Agent) releaseDocMedia(docID string) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 实体合并检测:GraphDB → LLM 裁决
|
||||
// ──────────────────────────────────────────────
|
||||
@ -437,9 +480,14 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
|
||||
// 写入 L3 并以 document --contains--> block 边关联,
|
||||
// 不经过文本描述与 NLP 提取器。
|
||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
||||
//
|
||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
@ -495,6 +543,7 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
|
||||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
start := time.Now()
|
||||
a.currentOutputChannel = "_consolidation_"
|
||||
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
|
||||
@ -13,11 +13,25 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代(M2)。
|
||||
//
|
||||
// 原实现直接在 select 里处理 inputCh/selfInputCh,没有任何可枚举的队列、
|
||||
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
|
||||
// 兼容性说明:M2 全部任务为 LevelBackground,因此行为等价于原先的 FIFO。
|
||||
func (a *Agent) eventLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.eventLoop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.handleInput(evt)
|
||||
case msg := <-a.selfInputCh:
|
||||
a.handleSelfInput(msg)
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) interceptLoop() {
|
||||
defer func() {
|
||||
@ -50,16 +64,43 @@ func (a *Agent) interceptLoop() {
|
||||
clone.Payload["interrupt_source"] = evt.Source
|
||||
clone.Payload["interrupt_channel"] = evt.OutputChannel
|
||||
|
||||
// 决策交给调度器:requestPreempt 总会登记中断(进中断队列或 immediate,
|
||||
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
|
||||
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
|
||||
//
|
||||
// 级别由来源声明(InjectOptions.Priority → payload["priority"]);
|
||||
// 未声明一律 L1。L4(“立即打断”)只有内核级插件能声明,
|
||||
// 外部插件即便报了 L4 也会被夹到 L3;内核自身另有 raiseKernelInterrupt。
|
||||
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
|
||||
if a.sched.requestPreempt(clone, level) {
|
||||
a.cancelCurrentLLM()
|
||||
a.llmMu.Lock()
|
||||
hasActiveLLM := a.cancelLLM != nil
|
||||
if hasActiveLLM {
|
||||
a.cancelLLM()
|
||||
log.Printf("[agent] LLM request cancelled by interrupt")
|
||||
}
|
||||
a.llmMu.Unlock()
|
||||
|
||||
if hasActiveLLM {
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
} else {
|
||||
select {
|
||||
case a.interceptCh <- clone:
|
||||
default:
|
||||
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
}
|
||||
|
||||
case <-a.ctx.Done():
|
||||
@ -68,20 +109,6 @@ func (a *Agent) interceptLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// cancelCurrentLLM 取消正在进行的 LLM 请求(若有)。
|
||||
//
|
||||
// 只有 LLM 流式步骤是可取消的;工具 RPC / ONNX / CAS 在 v1 是临界区,
|
||||
// 取消对它们无效——让位信号会等它们自然结束后的安全点(设计文档 D2)。
|
||||
func (a *Agent) cancelCurrentLLM() {
|
||||
a.llmMu.Lock()
|
||||
cancel := a.cancelLLM
|
||||
a.llmMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
log.Printf("[agent] LLM request cancelled by preemption")
|
||||
}
|
||||
}
|
||||
|
||||
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
|
||||
// 不写入对话上下文、不向任何输出通道 emit 响应。
|
||||
const channelConsolidation = "_consolidation_"
|
||||
@ -95,27 +122,22 @@ type selfInputMsg struct {
|
||||
channel string
|
||||
}
|
||||
|
||||
// selfEvent 把内核自循环消息归一成输入事件。
|
||||
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
|
||||
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
if msg.channel == "" {
|
||||
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
||||
}
|
||||
return &agentIO.InputEvent{
|
||||
a.processInput(&agentIO.InputEvent{
|
||||
Source: "system",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": msg.text},
|
||||
OutputChannel: msg.channel,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
_, _ = a.runInputTask(selfEvent(msg))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||
switch evt.Type {
|
||||
case "text", "image", "audio":
|
||||
_, _ = a.runInputTask(evt)
|
||||
a.processInput(evt)
|
||||
|
||||
case "event":
|
||||
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
||||
@ -275,51 +297,174 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
return blocks, alt
|
||||
}
|
||||
|
||||
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
|
||||
// processInput 是全部模态输入的唯一主干。
|
||||
//
|
||||
// 为什么要单独一条路径而不是复用 emitResponse:跳过意味着“我们没有处理这条输入”,
|
||||
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
|
||||
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
|
||||
//
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
|
||||
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
|
||||
if evt == nil || evt.ResponseCh == nil {
|
||||
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
|
||||
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
|
||||
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
|
||||
func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
start := time.Now()
|
||||
|
||||
in, ok := a.resolveInput(evt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch := evt.OutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.Source
|
||||
|
||||
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
||||
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
||||
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
||||
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"content": "",
|
||||
"request_id": evt.RequestID,
|
||||
"skipped": true,
|
||||
"reason": reason,
|
||||
|
||||
a.currentOutputChannel = evt.OutputChannel
|
||||
if a.currentOutputChannel == "" {
|
||||
a.currentOutputChannel = evt.Source
|
||||
}
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
|
||||
if evt.OutputChannel == "_consolidation_" {
|
||||
a.processConsolidation(evt, in.text)
|
||||
return
|
||||
}
|
||||
|
||||
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
||||
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
||||
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
||||
if evt.Type == "image" || evt.Type == "audio" {
|
||||
a.pendingMedia = evt.Payload
|
||||
defer func() { a.pendingMedia = nil }()
|
||||
}
|
||||
|
||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
if len(in.blocks) > 0 {
|
||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
}
|
||||
|
||||
noMemory := false
|
||||
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||
noMemory = v
|
||||
}
|
||||
if !noMemory && a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
|
||||
noMemory = true
|
||||
}
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
a.mu.Lock()
|
||||
a.interruptInput = isInterrupt
|
||||
a.mu.Unlock()
|
||||
if isInterrupt {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
if len(in.blocks) > 0 {
|
||||
stageCtx.Extra["media_blocks"] = in.blocks
|
||||
stageCtx.Extra["media_type"] = in.mediaType
|
||||
}
|
||||
if noMemory {
|
||||
stageCtx.NoMemory = true
|
||||
}
|
||||
a.injectSourceContext(stageCtx, evt)
|
||||
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return
|
||||
}
|
||||
|
||||
input := stageCtx.RawMessage
|
||||
|
||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||
cleanInput := input
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
cleanInput = chDef.Cleaner(input)
|
||||
}
|
||||
}
|
||||
|
||||
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
||||
// 媒体路径此前把整个 payload 塞进 content(一个 map),订阅方按 string 断言
|
||||
// 直接失败 → 用户发的图从不出现在聊天记录里。
|
||||
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
||||
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
rawPayload[k] = v
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
if !isInterrupt {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process %s error: %v", evt.Type, err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
|
||||
// process() 里被捕获,纯文本输入也会有)。
|
||||
turnEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: cleanInput,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" {
|
||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
// 通道一律从**输入事件**推导(内核不持有"当前通道")。
|
||||
ch := outputChannelOf(evt)
|
||||
stageCtx := &sdk.StageContext{
|
||||
FinalText: response,
|
||||
Phase: sdk.StageBeforeOutput,
|
||||
Extra: map[string]interface{}{"output_channel": ch},
|
||||
}
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
response = stageCtx.FinalText
|
||||
|
||||
ch := a.currentOutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.OutputChannel
|
||||
}
|
||||
if ch == "" {
|
||||
ch = evt.Source
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"content": response,
|
||||
"request_id": evt.RequestID,
|
||||
@ -331,20 +476,13 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
if evt.ResponseCh != nil {
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次
|
||||
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
|
||||
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
log.Printf("[agent] ResponseCh 已满,终态回执被丢弃(request=%s,可能违反不变量 I5)", evt.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,86 +499,29 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
||||
//
|
||||
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
||||
// 或通道定义(ChannelDef.ContextPolicy)上显式声明为 prune 才会裁剪。
|
||||
//
|
||||
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
|
||||
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
|
||||
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
|
||||
// 是可枚举、可审计的。
|
||||
//
|
||||
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
|
||||
//
|
||||
// 实际执行交由 memoryPass(与召回共用入口、query、预算与审计)。
|
||||
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false).Archived
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
//
|
||||
// 优先级:注入点声明的(payload)> 通道声明的(ChannelDef)> 默认不裁剪。
|
||||
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
|
||||
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
|
||||
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
|
||||
return p == pubsdk.ContextPolicyPrune
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
|
||||
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
|
||||
func (a *Agent) drainInterrupts() []string {
|
||||
var out []string
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.interceptCh:
|
||||
if evt == nil {
|
||||
continue
|
||||
}
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
source := evt.Source
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
channel := evt.OutputChannel
|
||||
if channel == "" {
|
||||
channel = source
|
||||
}
|
||||
out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
|
||||
default:
|
||||
return out
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recallDeclared 判定这次输入是否要触发记忆召回(注入)。
|
||||
//
|
||||
// 与 pruneDeclared **正交**:prune 管“踢出去”(归档低相关 L0 事件),
|
||||
// recall 管“取进来”(把 L2/L3 相关记忆注入本轮)。
|
||||
//
|
||||
// 默认值与 prune 刻意相反:召回是只读增量、日常对话本就需要,所以**默认 auto**;
|
||||
// 只有显式声明 recall_policy=none(如中断通知的 meta 文本)才关闭。
|
||||
// 优先级同 prune:注入点(payload)> 通道(ChannelDef)> 默认 auto。
|
||||
func (a *Agent) recallDeclared(evt *agentIO.InputEvent) bool {
|
||||
if evt == nil {
|
||||
return true
|
||||
}
|
||||
if p, ok := evt.Payload["recall_policy"].(string); ok && p != "" {
|
||||
return p != pubsdk.RecallPolicyNone
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.RecallPolicy != "" {
|
||||
return chDef.RecallPolicy != pubsdk.RecallPolicyNone
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
|
||||
//
|
||||
// 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道
|
||||
// cleaner)> 按 source 查到的通道 cleaner > 原文。
|
||||
//
|
||||
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
|
||||
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
|
||||
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
|
||||
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
|
||||
if a.io == nil {
|
||||
return input
|
||||
}
|
||||
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
|
||||
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
log.Printf("[agent] 注入声明了 cleaner_name=%q 但没有注册过该通道的 Cleaner,已回退", name)
|
||||
}
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user