mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e671a8c082 | |||
| dcaea64439 |
18
.gitignore
vendored
18
.gitignore
vendored
@ -26,23 +26,14 @@ cmd/gui/dist/
|
|||||||
#
|
#
|
||||||
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
||||||
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
||||||
# example/ 下已跟踪的 21 个文件(10 个示例的 plg.json + plugin.go,
|
# example/ 下已跟踪的 20 个文件(plg.json + plugin.go)早于本规则,
|
||||||
# 加 qq/plugin_test.go)早于本规则,
|
|
||||||
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
||||||
third_party/homeagent-sdk/bin/
|
third_party/homeagent-sdk/bin/
|
||||||
third_party/homeagent-sdk/tools/
|
third_party/homeagent-sdk/tools/
|
||||||
third_party/homeagent-sdk/package/
|
third_party/homeagent-sdk/package/
|
||||||
third_party/homeagent-sdk/scripts/
|
|
||||||
third_party/homeagent-sdk/.gitignore
|
third_party/homeagent-sdk/.gitignore
|
||||||
third_party/homeagent-sdk/README*
|
third_party/homeagent-sdk/README*
|
||||||
third_party/homeagent-sdk/example/
|
third_party/homeagent-sdk/example/
|
||||||
# 文档站(mkdocs.yml + docs/ + 生成器)属于 SDK 仓,与 README* / tools/ 同理。
|
|
||||||
# 注意 docs/ 带前导路径限定,够精确:本仓自己的 docs/ 不受影响。
|
|
||||||
third_party/homeagent-sdk/docs/
|
|
||||||
third_party/homeagent-sdk/mkdocs.yml
|
|
||||||
third_party/homeagent-sdk/site_build/
|
|
||||||
# 主题覆盖目录(只覆盖 footer.html,补备案号)——同属文档站。
|
|
||||||
third_party/homeagent-sdk/overrides/
|
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
|
||||||
# codegraph 本地索引配置(含嵌套 SDK 仓放行,仅本地生效)
|
# codegraph 本地索引配置(含嵌套 SDK 仓放行,仅本地生效)
|
||||||
@ -57,12 +48,7 @@ codegraph.json
|
|||||||
/adapters/
|
/adapters/
|
||||||
/knowledge/
|
/knowledge/
|
||||||
/memory/
|
/memory/
|
||||||
# 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
|
/scripts/
|
||||||
# 不是运行期产物:deploy/systemd/embed-sidecar.service 直接引用
|
|
||||||
# scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向不存在的文件。
|
|
||||||
# 只忽略其中的缓存。
|
|
||||||
/scripts/__pycache__/
|
|
||||||
__pycache__/
|
|
||||||
terminal_locked_log.txt
|
terminal_locked_log.txt
|
||||||
|
|
||||||
dist/
|
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/>.
|
|
||||||
322
Makefile
322
Makefile
@ -1,22 +1,4 @@
|
|||||||
.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full
|
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
|
||||||
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与
|
|
||||||
# deploy/packaging/build.sh 保持一致)。
|
|
||||||
#
|
|
||||||
# 曾经这里是空 tags,实测的后果(2026-09-15 热部署):`make build` 产出的
|
|
||||||
# homed 只有 33MB,而 onnxruntime 版是 84MB;启动日志里
|
|
||||||
# 「multimodal space active: provider=chineseclip」整行消失,少加载一个插件,
|
|
||||||
# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制
|
|
||||||
# (package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」
|
|
||||||
# 与「发行构建」不是同一个东西,部署时无从察觉。
|
|
||||||
# 需要极简构建时显式 HOMED_TAGS= 关掉。
|
|
||||||
#
|
|
||||||
# 注意:内核 C 编解码层(internal/agent/api/ha_codec.c)**不靠 tag 开关**,
|
|
||||||
# 而是由 cgo 本身决定(`//go:build cgo` / `!cgo`)。原因:它是零依赖纯 C99
|
|
||||||
# 源码内联编译,不需要任何外部库/工具链前提;而 homed 本就强制 cgo
|
|
||||||
# (sqlite3 + gojieba),所以 C 路径自然生效,无需额外开关。
|
|
||||||
# 对比 onnxruntime:那个需要运行期的 libonnxruntime.so,所以必须显式 tag。
|
|
||||||
HOMED_TAGS ?= onnxruntime
|
|
||||||
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
|
|
||||||
|
|
||||||
BINARY=homed
|
BINARY=homed
|
||||||
CLI_BINARY=waiter
|
CLI_BINARY=waiter
|
||||||
@ -33,230 +15,15 @@ LDFLAGS = -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=$(VERSION) -
|
|||||||
|
|
||||||
all: build build-cli
|
all: build build-cli
|
||||||
|
|
||||||
# csrc:C 源码的**独立**产物(静态库 + 契约测试),供 C 侧复用(鸿蒙/嵌入式/C SDK)。
|
|
||||||
#
|
|
||||||
# ⚠️ Go 构建**不依赖**它:internal/agent/api/ha_codec.{c,h} 是指向 csrc/ 的
|
|
||||||
# **符号链接**,cgo 直接编这份源码,而不是链接预构建的 .a。
|
|
||||||
#
|
|
||||||
# 为什么是「包内符号链接」而不是别的(历史教训 + 实测,勿回退):
|
|
||||||
# 1. 不能链静态库:.a 是构建产物、不入库,而发布脚本原先不产出它
|
|
||||||
# ⇒「不入库 + 不生成」两头空(实测 cannot find csrc/build/libha_codec.a);
|
|
||||||
# 且交叉编译 linux/arm64 时宿主 x86-64 的 .a 被链进目标产物,
|
|
||||||
# 报 `file in wrong format`。
|
|
||||||
# 2. 不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含):
|
|
||||||
# ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**,改了 C 源码但缓存命中时
|
|
||||||
# 会静默沿用旧代码(实测:变异 C 源码后 go test 仍报 ok)。
|
|
||||||
# 这对「逐步推进 C 化」是致命的——改动无效却无人察觉。
|
|
||||||
# 3. 包内符号链接:文件在包目录内 ⇒ 缓存按内容哈希正确跟踪
|
|
||||||
# (实测:改 csrc/ 源文件后 go test 立即判红);
|
|
||||||
# 同时只有一份权威源(csrc/),无副本漂移、无需同步目标。
|
|
||||||
#
|
|
||||||
# csrc 目标本身只服务「C 侧独立使用 + ctest」,不参与 Go 构建链路。
|
|
||||||
CSRC_DIR=csrc
|
|
||||||
CSRC_BUILD=$(CSRC_DIR)/build
|
|
||||||
CSRC_LIB=$(CSRC_BUILD)/libha_codec.a
|
|
||||||
|
|
||||||
# ============================ C 编译告警门禁 ============================
|
|
||||||
#
|
|
||||||
# 为什么要「零告警」而不是「有告警就看看」:
|
|
||||||
# 1. 本仓 C 代码量还小(ha_codec.c 约 340 行),任何告警都值得当场修;
|
|
||||||
# 门禁零成本维持(本地实测 4 个编译器×标准组合全 0 告警)。
|
|
||||||
# 2. C 侧没有 Go 那套 vet 等价物,告警是**唯一的**静态信号。
|
|
||||||
# 若是「先攒着」,C 侧会慢慢退化成一堆没人看的噪声,然后没人看。
|
|
||||||
# 3. -Wconversion 特意包含在内:C→Go 经 cgo 时隐式窄化(如 size_t→int)
|
|
||||||
# 是真实事故来源(长度字段截断),而它在默认档下是静默的。
|
|
||||||
#
|
|
||||||
# -Wpedantic 尤其重要:它抓出「用了 C11 特性但 CFLAGS 写 -std=c99」这类
|
|
||||||
# 跨工具链不一致(本轮就当场抓到 _Static_assert 一例,见 ha_abi.h)。
|
|
||||||
CSRC_STD ?= c99
|
|
||||||
CSRC_WARN_FLAGS = -Wall -Wextra -Wpedantic -Wshadow -Wconversion
|
|
||||||
CSRC_CFLAGS = -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$(CSRC_DIR)/include
|
|
||||||
CSRC_SRCS = $(wildcard $(CSRC_DIR)/src/*.c)
|
|
||||||
CSRC_HDRS = $(wildcard $(CSRC_DIR)/include/*.h)
|
|
||||||
|
|
||||||
# 可选的第二编译器:只有一份编译器通过 ≠ C 写法可移植
|
|
||||||
# (GCC 扩展在 clang 下报错、或反之,都是真实的发布事故)。
|
|
||||||
CSRC_CC2 ?= clang
|
|
||||||
|
|
||||||
csrc: $(CSRC_LIB)
|
|
||||||
|
|
||||||
$(CSRC_LIB): $(wildcard $(CSRC_DIR)/src/*.c) $(wildcard $(CSRC_DIR)/include/*.h) $(CSRC_DIR)/CMakeLists.txt
|
|
||||||
@cmake -S $(CSRC_DIR) -B $(CSRC_BUILD) -DBUILD_TESTS=ON >/dev/null
|
|
||||||
@cmake --build $(CSRC_BUILD) -j >/dev/null
|
|
||||||
@echo "Built: $(CSRC_LIB)(C 侧复用,不参与 Go 构建)"
|
|
||||||
|
|
||||||
# csrc-test:C 侧契约测试(黄金对照的另一半,见 docs/zh/c-core/llm-orchestration-c.md §五)
|
|
||||||
csrc-test: csrc
|
|
||||||
@cd $(CSRC_BUILD) && ctest --output-on-failure
|
|
||||||
|
|
||||||
# csrc-lint:C 侧告警门禁(主编译器 + 第二编译器交叉,零告警)
|
|
||||||
#
|
|
||||||
# 用 \`-Werror\` 而不是只看输出:只有「告警即失败」才是门禁,
|
|
||||||
# 否则它只是打印给人看,而人会累。
|
|
||||||
.PHONY: csrc-lint
|
|
||||||
csrc-lint:
|
|
||||||
@echo "== C 告警门禁($(CSRC_STD),$(CSRC_WARN_FLAGS))=="
|
|
||||||
@for cc in $(CC) $(CSRC_CC2); do \
|
|
||||||
command -v $$cc >/dev/null 2>&1 || { echo " [SKIP] $$cc 不存在"; continue; }; \
|
|
||||||
out=$$($$cc $(CSRC_CFLAGS) -Werror -fsyntax-only $(CSRC_SRCS) 2>&1); \
|
|
||||||
if [ -n "$$out" ]; then \
|
|
||||||
echo " [FAIL] $$cc 有告警:"; echo "$$out" | head -20; exit 1; \
|
|
||||||
else \
|
|
||||||
echo " $$cc: 0 告警 ✓"; \
|
|
||||||
fi; \
|
|
||||||
done
|
|
||||||
|
|
||||||
# csrc-abi:C 侧 ABI 版本自洽性(编译期断言已在 ha_abi.h 内,这里做运行期核对)
|
|
||||||
.PHONY: csrc-abi
|
|
||||||
csrc-abi:
|
|
||||||
@echo "== C ABI 版本自述 =="
|
|
||||||
@printf '#include <stdio.h>\n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c 2>/dev/null || mkdir -p $(CSRC_BUILD) && printf '#include <stdio.h>\n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c
|
|
||||||
@$(CC) $(CSRC_CFLAGS) $(CSRC_BUILD)/abi_probe.c -o $(CSRC_BUILD)/abi_probe $(CSRC_SRCS) 2>/dev/null
|
|
||||||
@v=$$($(CSRC_BUILD)/abi_probe); \
|
|
||||||
if [ "$$v" -ge 1000 ] && [ "$$v" -le 99999 ]; then \
|
|
||||||
echo " ha_codec ABI_VERSION = $$v (major=$$((v/1000)) minor=$$((v%1000))): OK"; \
|
|
||||||
else \
|
|
||||||
echo " [FAIL] ABI 版本荒谬:$$v"; exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# csrc-sanitize:ASan + UBSan 跑 C 契约测试
|
|
||||||
#
|
|
||||||
# 目的:内存错误与未定义行为在 C 侧默认是**静默的**(不崩、结果看起来对),
|
|
||||||
# 而内核 L1 路径零 malloc 的设计依赖「没有越界写」这一前提。
|
|
||||||
# C 侧没有 Go 的 -race 等价物,sanitizer 就是这里的关等物。
|
|
||||||
# 若本机无 libasan/libubsan(交叉工具链常见),明确 SKIP 而非静默跳过。
|
|
||||||
.PHONY: csrc-sanitize
|
|
||||||
# ★ 每个测试文件**各自**链接成独立二进制:契约测试每个都带 main,
|
|
||||||
# 合在一起会「multiple definition of main」——而报错被 2>/dev/null
|
|
||||||
# 吞掉后会被误报成「本机无 sanitizer」,是个假的 SKIP。
|
|
||||||
# 故这里逐个构建、逐个跑,任何一个失败都判红。
|
|
||||||
csrc-sanitize:
|
|
||||||
@echo "== C 侧 ASan+UBSan =="
|
|
||||||
@tmp=$$(mktemp -d); \
|
|
||||||
built=0; \
|
|
||||||
for t in $(CSRC_DIR)/test/test_*.c; do \
|
|
||||||
case "$$t" in *fuzz*) continue ;; esac; \
|
|
||||||
base=$$(basename $$t .c); \
|
|
||||||
if ! $(CC) $(CSRC_CFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer \
|
|
||||||
-o $$tmp/$$base $(CSRC_SRCS) $$t 2>$$tmp/build.log; then \
|
|
||||||
if grep -qi 'sanitize\|asan\|ubsan' $$tmp/build.log; then \
|
|
||||||
echo " [SKIP] 本机无 ASan/UBSan 运行库,已跳过"; rm -rf $$tmp; exit 0; \
|
|
||||||
fi; \
|
|
||||||
echo " [FAIL] 构建失败 ($$base):" ; head -10 $$tmp/build.log; rm -rf $$tmp; exit 1; \
|
|
||||||
fi; \
|
|
||||||
built=1; \
|
|
||||||
if ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
|
|
||||||
$$tmp/$$base > $$tmp/$$base.out 2>&1; then \
|
|
||||||
echo " ASan+UBSan $$base: PASS"; \
|
|
||||||
else \
|
|
||||||
echo " [FAIL] sanitizer 报告 ($$base):"; head -30 $$tmp/$$base.out; rm -rf $$tmp; exit 1; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
rm -rf $$tmp; \
|
|
||||||
if [ "$$built" = "0" ]; then echo " [FAIL] 没找到任何契约测试"; exit 1; fi
|
|
||||||
|
|
||||||
# csrc-headers:头文件自包含性(每个 .h 都能单独编过)
|
|
||||||
#
|
|
||||||
# 为什么需要:ha_codec.h 头写了「本头文件是对外契约,签名冻结」,
|
|
||||||
# 而**头文件能不能自己编过**是另一件事。若头里用到了自己没包含的东西
|
|
||||||
# (比如用了 int32_t 却没 <stdint.h>),后果是:
|
|
||||||
# - 在某个翻译单元里恰好被别的头预先包含了 → 静默编过
|
|
||||||
# - 在别处(鸿蒙/嵌入式/C SDK 直接包含它)→ 报一堆无关的错
|
|
||||||
# 本轮就靠它抓出 ha_abi.h 的静态断言垫片缺 <assert 类依赖> 类问题。
|
|
||||||
# 判据:每个头单独编 -fsyntax-only 必须为 0 告警 0 错。
|
|
||||||
.PHONY: csrc-headers
|
|
||||||
csrc-headers:
|
|
||||||
@echo "== 头文件自包含性 =="
|
|
||||||
@ok=1; \
|
|
||||||
for h in $(CSRC_HDRS); do \
|
|
||||||
base=$$(basename $$h); \
|
|
||||||
inc=$$(dirname $$h); \
|
|
||||||
out=$$(echo "$$cc" | tr -d '-'; ); \
|
|
||||||
for cc in $(CC) $(CSRC_CC2); do \
|
|
||||||
command -v $$cc >/dev/null 2>&1 || continue; \
|
|
||||||
printf '#include "%s"\nint main(void){return 0;}\n' "$$base" > $(CSRC_BUILD)/hdr_probe.c; \
|
|
||||||
res=$$($$cc -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$$inc -I$(CSRC_DIR)/include -Werror \
|
|
||||||
-fsyntax-only $(CSRC_BUILD)/hdr_probe.c 2>&1); \
|
|
||||||
if [ -n "$$res" ]; then \
|
|
||||||
echo " [FAIL] $$base 单独包含时失败($$cc):"; echo "$$res" | head -10; ok=0; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
done; \
|
|
||||||
if [ "$$ok" = "1" ]; then echo " $(words $(CSRC_HDRS)) 个头文件:自包含 OK ✓"; else exit 1; fi
|
|
||||||
|
|
||||||
# csrc-fuzz:libFuzzer 跑不变式 + 内存安全(需 clang,无则明确 SKIP)
|
|
||||||
#
|
|
||||||
# 这是 C 侧唯一能「持续」而非「等下一次手写用例」的检验。
|
|
||||||
# ha_codec 的等价契约(与 Go 的 utf8.DecodeRuneInString 一致)在正常输入下
|
|
||||||
# 永远测不到,只有随机字节能覆盖截断序列/过长编码/代理对/超 U+10FFFF。
|
|
||||||
# 门禁不能假装通过:无 clang 或无 libFuzzer 时显式 SKIP 并说明。
|
|
||||||
.PHONY: csrc-fuzz
|
|
||||||
csrc-fuzz:
|
|
||||||
@echo "== C 侧 libFuzzer(clang)=="
|
|
||||||
@if ! command -v $(CSRC_CC2) >/dev/null 2>&1; then \
|
|
||||||
echo " [SKIP] $(CSRC_CC2) 不存在,无法跑 libFuzzer"; exit 0; \
|
|
||||||
fi; \
|
|
||||||
tmp=$$(mktemp -d); \
|
|
||||||
if ! $(CSRC_CC2) $(CSRC_CFLAGS) -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \
|
|
||||||
-o $$tmp/fz $(CSRC_SRCS) $(CSRC_DIR)/test/test_fuzz_ha_codec.c 2>/dev/null; then \
|
|
||||||
echo " [SKIP] 无 libFuzzer 运行库(需要 clang 自带),已跳过"; rm -rf $$tmp; exit 0; \
|
|
||||||
fi; \
|
|
||||||
SECS=$${FUZZ_SECS:-20}; \
|
|
||||||
if $$tmp/fz -max_total_time=$$SECS -rss_limit_mb=4096 > $$tmp/fz.log 2>&1; then \
|
|
||||||
runs=$$(grep -oE 'Done [0-9]+ runs' $$tmp/fz.log | tail -1); \
|
|
||||||
echo " libFuzzer: PASS($${runs:-完成},$${SECS}s)"; rm -rf $$tmp; \
|
|
||||||
else \
|
|
||||||
echo " [FAIL] 模糊测试崩溃:"; tail -30 $$tmp/fz.log; rm -rf $$tmp; exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# csrc-cross:交叉编译 C 侧(arm64 是 homed 的真实发布目标之一)
|
|
||||||
#
|
|
||||||
# 为什么要单独门禁:Go 侧的 `go build` 不等于 C 代码在该架构上能编。
|
|
||||||
# C 侧的架构相关问题(endianness 假设、指针宽度、size_t vs int 宽度、
|
|
||||||
# -fsanitize 不可用)只有真的用目标编译器编一遍才会暴露。
|
|
||||||
# 与 deploy/packaging/build.sh 的 arm64 目标共用同一套 CC 变量。
|
|
||||||
.PHONY: csrc-cross
|
|
||||||
csrc-cross:
|
|
||||||
@echo "== C 侧交叉编译(linux/arm64)=="
|
|
||||||
@CC_ARM64=$${CC_ARM64:-aarch64-linux-gnu-gcc}; \
|
|
||||||
if ! command -v $$CC_ARM64 >/dev/null 2>&1; then \
|
|
||||||
echo " [SKIP] $$CC_ARM64 不存在(未装交叉工具链)"; exit 0; \
|
|
||||||
fi; \
|
|
||||||
ok=1; \
|
|
||||||
for src in $(CSRC_SRCS); do \
|
|
||||||
if ! $$CC_ARM64 $(CSRC_CFLAGS) -Werror -fsyntax-only $$src 2>&1 | head -20; then \
|
|
||||||
ok=0; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
if [ "$$ok" = "1" ]; then \
|
|
||||||
echo " $$CC_ARM64: 0 告警、编译通过 ✓($(words $(CSRC_SRCS)) 个源文件)"; \
|
|
||||||
else \
|
|
||||||
echo " [FAIL] arm64 交叉编译失败"; exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# check-csrc:C 侧全部门禁的聚合入口(接进 make test 与 CI)
|
|
||||||
.PHONY: check-csrc
|
|
||||||
check-csrc: csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross
|
|
||||||
@echo "== C 基础设施门禁:全部通过 =="
|
|
||||||
|
|
||||||
# check-csrc-full:在 check-csrc 基础上加模糊测试(耗时,故分开)
|
|
||||||
.PHONY: check-csrc-full
|
|
||||||
check-csrc-full: check-csrc csrc-fuzz
|
|
||||||
@echo "== C 基础设施门禁(含模糊测试):全部通过 =="
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
|
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||||
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \
|
|
||||||
|| echo "WARN: 本次构建不含 onnxruntime,本地向量空间不可用(HOMED_TAGS= 显式关掉时才符合预期)"
|
|
||||||
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'CGO_ENABLED=1' \
|
|
||||||
|| echo "WARN: 本次构建未启用 cgo,编解码走纯 Go 回退(不应发生)"
|
|
||||||
|
|
||||||
build-cli:
|
build-cli:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
|
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
|
||||||
|
|
||||||
build-gui:
|
build-gui:
|
||||||
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
||||||
@ -267,36 +34,13 @@ build-static:
|
|||||||
CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
||||||
@echo "Built (static): $(BUILD_DIR)/$(BINARY)-static"
|
@echo "Built (static): $(BUILD_DIR)/$(BINARY)-static"
|
||||||
|
|
||||||
# build-linux-arm64:交叉编译 homed(真实发布目标之一)。
|
|
||||||
#
|
|
||||||
# 两处必须显式给定,否则必然失败(都不是 C 化引入的,但都长期缺覆盖):
|
|
||||||
# 1. CC/CXX 交叉工具链。缺 CXX 时 cgo 回退到宿主 g++,而宿主编译器不认
|
|
||||||
# aarch64 汇编,报 `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`。
|
|
||||||
# deploy/packaging/build.sh:49 一直是对的,此处此前漏了。
|
|
||||||
# 2. .syso 隔离。cmd/{homed,waiter}/*.syso 是 Windows COFF 资源对象,
|
|
||||||
# Go 会把同目录 .syso **无条件**链进任何目标;交叉到非 Windows 平台报
|
|
||||||
# `file format not recognized`。build.sh 有 hide_syso_for_target,此处同样漏了。
|
|
||||||
CC_ARM64 ?= aarch64-linux-gnu-gcc
|
|
||||||
CXX_ARM64 ?= aarch64-linux-gnu-g++
|
|
||||||
|
|
||||||
build-linux-arm64:
|
build-linux-arm64:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@for f in cmd/homed/*.syso cmd/waiter/*.syso; do \
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/
|
||||||
[ -f "$$f" ] || continue; \
|
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64"
|
||||||
mv "$$f" "$$f.hidden"; \
|
|
||||||
done; \
|
|
||||||
trap 'for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \
|
|
||||||
[ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done' EXIT; \
|
|
||||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=$(CC_ARM64) CXX=$(CXX_ARM64) \
|
|
||||||
$(GO) build $(TAG_ARGS) -installsuffix dynlink -ldflags '$(LDFLAGS)' \
|
|
||||||
-o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/; \
|
|
||||||
for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \
|
|
||||||
[ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done; \
|
|
||||||
trap - EXIT
|
|
||||||
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64 ($$(file $(BUILD_DIR)/$(BINARY)-arm64 | sed 's/.*: //'))"
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR) $(BINARY) $(CSRC_BUILD)
|
rm -rf $(BUILD_DIR) $(BINARY)
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
-systemctl stop homeagent 2>/dev/null
|
-systemctl stop homeagent 2>/dev/null
|
||||||
@ -308,37 +52,6 @@ install: build
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
@$(MAKE) csrc-test
|
|
||||||
@$(MAKE) check-csrc
|
|
||||||
@$(MAKE) check-codec-cgo-only
|
|
||||||
|
|
||||||
# check-codec-cgo-only:钉死「编解码层完全 C 化」这一决定。
|
|
||||||
#
|
|
||||||
# 两条断言,缺一不可:
|
|
||||||
# ① CGO_ENABLED=1 下测试全绿(含黄金对照:C 与纯 Go 参考实现逐值相等)
|
|
||||||
# ② CGO_ENABLED=0 下**构建必须失败**
|
|
||||||
#
|
|
||||||
# 为什么②要断言「失败」而不是「也能编过」:内核已完全 C 化,C 是唯一实现。
|
|
||||||
# 若有人在 CGO_ENABLED=0 下让整包静默编过(例如加回一个纯 Go 回退),
|
|
||||||
# 就会同时存在两份语义可能分叉的实现 —— 而 C 侧对畸形 UTF-8 的解码边界
|
|
||||||
# 一旦与 Go 分叉,只表现为 rune 计数偏差(进而 token 预算与截断点偏移),
|
|
||||||
# **不会立刻暴露**。所以这里把「不许有第二条路」变成可执行的断言。
|
|
||||||
#
|
|
||||||
# 注:这不影响任何现有构建 —— waiter/initconfig/memgc 均不依赖本包
|
|
||||||
# (go list -deps 实测);homed 本就强制 cgo。
|
|
||||||
.PHONY: check-codec-cgo-only
|
|
||||||
check-codec-cgo-only:
|
|
||||||
@echo "== 编解码层:完全 C 化检查 =="
|
|
||||||
@CGO_ENABLED=1 $(GO) test -count=1 ./internal/agent/api/ \
|
|
||||||
&& echo " ① cgo 下测试全绿(含黄金对照): OK"
|
|
||||||
@if CGO_ENABLED=0 $(GO) build ./internal/agent/api/ 2>/dev/null; then \
|
|
||||||
echo " [FAIL] CGO_ENABLED=0 下本包竟然构建成功——"; \
|
|
||||||
echo " 编解码层已完全 C 化,不该存在第二条实现路径。"; \
|
|
||||||
echo " 若是有意引入回退,请同时更新本检查与 codec_cgo.go 的说明。"; \
|
|
||||||
exit 1; \
|
|
||||||
else \
|
|
||||||
echo " ② CGO_ENABLED=0 下响亮失败(防静默回退): OK"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
|
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
|
||||||
@ -348,22 +61,3 @@ fmt:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
|
|
||||||
# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version)。
|
|
||||||
# GUI/鸿蒙各有自版本字段,手工改必漂——用脚本拉齐,check 版给门禁用。
|
|
||||||
sync-client-versions:
|
|
||||||
@bash deploy/scripts/sync-client-versions.sh
|
|
||||||
|
|
||||||
check-client-versions:
|
|
||||||
@bash deploy/scripts/sync-client-versions.sh --check
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
137
README.md
137
README.md
@ -12,8 +12,6 @@
|
|||||||
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||||
```
|
```
|
||||||
|
|
||||||
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia`、`InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆,digest 是回到字节的钥匙。
|
|
||||||
|
|
||||||
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
||||||
|
|
||||||
## 设计要点
|
## 设计要点
|
||||||
@ -26,16 +24,6 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
|||||||
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||||
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
||||||
|
|
||||||
**输入调度:两类别 + 四级中断** — 输入不直接进 LLM,先进调度器。
|
|
||||||
排队(待办工作)与中断(按"有多不能等"分 L1~L4)两类;高级可抢占低级并保存现场
|
|
||||||
(中断栈),同级不抢占。L4 只归内核与内核级插件(如 WebUI 终止按钮)。
|
|
||||||
见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「输入调度器与中断机制」。
|
|
||||||
|
|
||||||
**驻留式子 agent** — 内核可派驻轻量内核的子 agent(自己的调度器与 temp 图记忆,
|
|
||||||
共享通道登记表),把长任务/积压交出去并行做。主 agent 忙久了,内核还会把排队输入
|
|
||||||
交给临时**分诊助手**:简单的直接处理,需要主 agent 的立刻回「忙碌中,请稍候」,
|
|
||||||
用户不再干等。见 [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)。
|
|
||||||
|
|
||||||
## 架构图
|
## 架构图
|
||||||
|
|
||||||
### 一、消息处理时序
|
### 一、消息处理时序
|
||||||
@ -44,7 +32,6 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as 用户/插件
|
participant U as 用户/插件
|
||||||
participant IO as IOManager
|
participant IO as IOManager
|
||||||
participant SCH as 输入调度器
|
|
||||||
participant EV as eventLoop
|
participant EV as eventLoop
|
||||||
participant CTX as RelevanceContext
|
participant CTX as RelevanceContext
|
||||||
participant LLM as LLM+工具循环
|
participant LLM as LLM+工具循环
|
||||||
@ -52,14 +39,7 @@ sequenceDiagram
|
|||||||
participant MEM as 三层记忆
|
participant MEM as 三层记忆
|
||||||
|
|
||||||
U->>IO: InjectInput(type, payload)
|
U->>IO: InjectInput(type, payload)
|
||||||
IO->>SCH: inputCh
|
IO->>EV: inputCh
|
||||||
rect lavender
|
|
||||||
Note over SCH: 两类别 + 四级中断(L1~L4)
|
|
||||||
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
|
|
||||||
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
|
|
||||||
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
|
|
||||||
end
|
|
||||||
SCH->>EV: 选中一个任务开始跑
|
|
||||||
rect lavender
|
rect lavender
|
||||||
Note over EV: processTextInput
|
Note over EV: processTextInput
|
||||||
EV->>ST: StageOnInput 插件可改写/短路
|
EV->>ST: StageOnInput 插件可改写/短路
|
||||||
@ -73,7 +53,7 @@ sequenceDiagram
|
|||||||
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
||||||
EV->>ST: StagePreAction 插件可预拦截
|
EV->>ST: StagePreAction 插件可预拦截
|
||||||
loop 工具循环
|
loop 工具循环
|
||||||
LLM->>LLM: 安全点:中断求值/让位
|
LLM->>LLM: drainInterrupts
|
||||||
LLM->>LLM: LLM Chat
|
LLM->>LLM: LLM Chat
|
||||||
LLM->>ST: StagePostAction 插件可修改/短路
|
LLM->>ST: StagePostAction 插件可修改/短路
|
||||||
alt 无tool call
|
alt 无tool call
|
||||||
@ -128,7 +108,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
subgraph D[② Document 文件记忆]
|
subgraph D[② Document 文件记忆]
|
||||||
DS[DocStore JSON+TF-IDF]
|
DS[DocStore JSON+TF-IDF]
|
||||||
Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
|
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP
|
||||||
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
||||||
Q2 -->|原始时间戳写入上下文| RC
|
Q2 -->|原始时间戳写入上下文| RC
|
||||||
CD[FindColdDocs 72h] -->|docToTriples| G
|
CD[FindColdDocs 72h] -->|docToTriples| G
|
||||||
@ -198,83 +178,24 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
|
|||||||
cmd/homed/ 守护进程入口,组装所有子系统
|
cmd/homed/ 守护进程入口,组装所有子系统
|
||||||
cmd/waiter/ CLI 客户端(Unix socket)
|
cmd/waiter/ CLI 客户端(Unix socket)
|
||||||
internal/
|
internal/
|
||||||
├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
|
├── agent/core/ Agent 核心:事件循环、LLM 工具循环、7 阶段管道
|
||||||
├── agent/api/ LLM Provider(Lua 适配层:provider.go 调 vm)
|
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||||
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
||||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||||
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
||||||
├── plugins/ 内置 18 个插件(webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等)
|
├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||||
├── config/ SQLite 配置中心
|
├── config/ SQLite 配置中心
|
||||||
├── events/ 事件总线
|
├── events/ 事件总线
|
||||||
└── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
|
└── 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.3.x 线**(v1.3.1–v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**。
|
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变,1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷:(1)**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容(实测同一张图:tool message 0/3 读到、独立 user message 3/3),改为另起一条紧随其后的 user message 承载,落实插件文案一直在说的「注入后续对话」;(2)**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200,带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项;(3)**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量,20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
|
||||||
|
|
||||||
- **驻留式子 agent**:内核可派驻轻量内核的子 agent(自己的调度器、自己的 temp 图记忆、
|
**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。
|
||||||
共享通道登记表)。父经 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy)
|
|
||||||
派活与收活;inputch 可划给子,输入**在进内核之前**就已路由到子。
|
|
||||||
- **输出通道可寻址到具体 agent**:`AllowedOutputs` 授权集合(三处过滤点一致),
|
|
||||||
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
|
|
||||||
- **输入调度器**:排队/中断两类别 + 四级中断(L1~L4)+ 抢占/挂起/恢复/中断栈;
|
|
||||||
同级不抢占、有饥饿防护与抢占冷却;L4 只归内核与内核级插件(WebUI 终止按钮)。
|
|
||||||
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
|
|
||||||
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
|
|
||||||
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
|
|
||||||
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
|
|
||||||
- 修掉一批真实缺陷:销毁驻留子时入站 inputch(`child/<id>`)注册残留、
|
|
||||||
子的轮次永远显示 0(`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
|
|
||||||
设备心跳 pong 忘了 Flush(每 60 秒掉线)、Lua 插件桥与 SDK 1.3.0 对齐。
|
|
||||||
|
|
||||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
|
||||||
|
|
||||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 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.59GB、静置回收后稳态约 0.89GB);
|
|
||||||
`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」。
|
|
||||||
>
|
|
||||||
> 另有**一处许可口径已变更**(2026-09-24):上面 v1.2.0 条目里的「插件静态链接 SDK 故须同许可」
|
|
||||||
> **自今日起不成立**。SDK 改以 **MIT** 发布(宽松许可,不继承内核的 AGPL),故外部插件**不是**
|
|
||||||
> 本项目的衍生作品,作者可自行选择许可(含闭源、商业、私有),亦不受 §13 网络条款约束。
|
|
||||||
> 原文保留以呈现当时的口径。
|
|
||||||
>
|
|
||||||
> 另有**两项性能断言的量纲需要更正**(2026-09-20 实测):
|
|
||||||
> 「崩溃到恢复 <1s」不成立 —— 崩溃后是**线性退避重启**,即 1s / 2s / 3s(`procRestartBackoff=1s × 第 n 次`),
|
|
||||||
> 首次重启就要等 1s。且 5 分钟窗口内第 **4** 次崩溃即停止自动重启待人工介入(`procMaxRestarts=3`,判定为 `n > 3`)。
|
|
||||||
> 该断言写下时(v1.0.0)退避值已是 1s,故从未成立。
|
|
||||||
> 「RPC 往返 p50 24.1µs」与当前实测同量级但不吻合:本机 `BenchmarkToolInvoke` 实测
|
|
||||||
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
|
|
||||||
> 保留原文不修改,以免伪造历史。
|
|
||||||
|
|
||||||
**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。
|
|
||||||
|
|
||||||
**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 退场。**
|
**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 退场。**
|
||||||
|
|
||||||
@ -299,7 +220,7 @@ internal/
|
|||||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||||
|
|
||||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
- 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.0.1_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||||
- 校验:`SHA256SUMS`
|
- 校验:`SHA256SUMS`
|
||||||
|
|
||||||
@ -313,38 +234,4 @@ make test # go test ./...
|
|||||||
make install # 安装到系统
|
make install # 安装到系统
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux。
|
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||||
|
|
||||||
> `homed` 需 Linux(依赖 fd 继承与共享内存段的段内偏移解引用,见
|
|
||||||
> `cmd/homed/platform_windows.go`);Windows 上只构建 `waiter.exe`,
|
|
||||||
> `homed` 跑在 WSL2 里(见「下载」)。macOS 可构建 `waiter`/`initconfig`,
|
|
||||||
> `homed` 需在原生 macOS 构建。
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
本项目以 **GNU Affero 通用公共许可证第 3 版(AGPL-3.0-only)** 发布,全文见 [LICENSE](LICENSE)。
|
|
||||||
|
|
||||||
它是 GPL 家族里**传染性最强**的一档:不仅分发时须提供完整对应源码,
|
|
||||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
|
||||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
|
||||||
|
|
||||||
插件与本项目通过公开 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 静态链接
|
|
||||||
(SDK 源码会进入插件二进制),但那个仓**以 MIT 发布**——MIT 是宽松许可,拿到授权的代码不继承
|
|
||||||
本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有),
|
|
||||||
既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。
|
|
||||||
|
|
||||||
边界很清楚:**AGPL 覆盖内核与随包内置插件**(`homed`、`internal/`、`internal/plugins/` 下 18 个内置插件);
|
|
||||||
**MIT 覆盖公开 SDK**(`sdk/`,`go.mod` 零外部依赖、只依赖 Go 标准库,不引用内核任何代码)。
|
|
||||||
子进程隔离在这里不重要了——决定许可的是被链接的 SDK 代码本身,而它是 MIT。
|
|
||||||
|
|
||||||
### 随包分发的第三方组件
|
|
||||||
|
|
||||||
| 组件 | 许可 | 位置 |
|
|
||||||
|---|---|---|
|
|
||||||
| 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`。
|
|
||||||
|
|||||||
186
README_EN.md
186
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)
|
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
|
**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
|
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
|
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
|
||||||
@ -32,19 +27,6 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
|
|||||||
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
|
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
|
||||||
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
|
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
|
||||||
|
|
||||||
**Input Scheduling: 2 classes + 4 interrupt levels** — Input does not go straight to the LLM;
|
|
||||||
it first enters the scheduler. Two classes (queued = pending work, interrupt = ranked L1–L4 by
|
|
||||||
"how urgent") with preemption and frame saving (interrupt stack); same level never preempts
|
|
||||||
same level. L4 belongs only to the kernel and kernel-level plugins (e.g. the WebUI stop button).
|
|
||||||
See "Input Scheduler & Interrupt Mechanism" in [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md).
|
|
||||||
|
|
||||||
**Resident sub-agents** — The kernel can station lightweight-kernel child agents (their own
|
|
||||||
scheduler and temp graph memory, sharing the channel registry) to run long or backlogged work
|
|
||||||
in parallel. When the main agent stays busy, the kernel hands queued input to a temporary
|
|
||||||
**triage assistant**: simple items are handled directly, items needing the main agent get an
|
|
||||||
immediate "busy, please wait" — users no longer wait in silence.
|
|
||||||
See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md).
|
|
||||||
|
|
||||||
## Architecture Diagrams
|
## Architecture Diagrams
|
||||||
|
|
||||||
### 1. Message Processing Sequence
|
### 1. Message Processing Sequence
|
||||||
@ -53,7 +35,6 @@ See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as User/Plugin
|
participant U as User/Plugin
|
||||||
participant IO as IOManager
|
participant IO as IOManager
|
||||||
participant SCH as Input Scheduler
|
|
||||||
participant EV as eventLoop
|
participant EV as eventLoop
|
||||||
participant CTX as RelevanceContext
|
participant CTX as RelevanceContext
|
||||||
participant LLM as LLM+Tool Loop
|
participant LLM as LLM+Tool Loop
|
||||||
@ -61,14 +42,7 @@ sequenceDiagram
|
|||||||
participant MEM as Three-Layer Memory
|
participant MEM as Three-Layer Memory
|
||||||
|
|
||||||
U->>IO: InjectInput(type, payload)
|
U->>IO: InjectInput(type, payload)
|
||||||
IO->>SCH: inputCh
|
IO->>EV: inputCh
|
||||||
rect lavender
|
|
||||||
Note over SCH: 2 task classes + 4 interrupt levels (L1-L4)
|
|
||||||
SCH->>SCH: same level never preempts -> ready/interrupt queue
|
|
||||||
SCH->>SCH: higher level -> preempt (frame pushed to interrupt stack)
|
|
||||||
SCH->>SCH: offload (main agent busy too long -> temporary triage assistant)
|
|
||||||
end
|
|
||||||
SCH->>EV: pick one task and run it
|
|
||||||
rect lavender
|
rect lavender
|
||||||
Note over EV: processTextInput
|
Note over EV: processTextInput
|
||||||
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
||||||
@ -82,7 +56,7 @@ sequenceDiagram
|
|||||||
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
||||||
EV->>ST: StagePreAction Plugin can pre-intercept
|
EV->>ST: StagePreAction Plugin can pre-intercept
|
||||||
loop Tool loop
|
loop Tool loop
|
||||||
LLM->>LLM: safe point: interrupt eval / yield
|
LLM->>LLM: drainInterrupts
|
||||||
LLM->>LLM: LLM Chat
|
LLM->>LLM: LLM Chat
|
||||||
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
||||||
alt No tool call
|
alt No tool call
|
||||||
@ -190,120 +164,24 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
|
|||||||
cmd/homed/ Daemon entry, assembles all subsystems
|
cmd/homed/ Daemon entry, assembles all subsystems
|
||||||
cmd/waiter/ CLI client (Unix socket)
|
cmd/waiter/ CLI client (Unix socket)
|
||||||
internal/
|
internal/
|
||||||
├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
|
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline
|
||||||
├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
|
├── agent/api/ LLM Provider + 8 Lua adapters
|
||||||
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
|
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
|
||||||
├── knowledge/ Knowledge base (filesystem + TF-IDF)
|
├── knowledge/ Knowledge base (filesystem + TF-IDF)
|
||||||
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
||||||
├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
|
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||||
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
||||||
├── config/ SQLite config center
|
├── config/ SQLite config center
|
||||||
├── events/ Event bus
|
├── events/ Event bus
|
||||||
└── internal/lua/adapters/ 10 LLM protocol adapter scripts
|
└── 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
|
## Project Status
|
||||||
|
|
||||||
**v1.3.x line** (v1.3.1–v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
|
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**—`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
|
||||||
|
|
||||||
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
|
**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.
|
||||||
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
|
|
||||||
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
|
|
||||||
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
|
|
||||||
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
|
|
||||||
(consistent across all three filter points) lets parent/child deliver to each other;
|
|
||||||
device capabilities became output channels too (one `device/<id>` per device).
|
|
||||||
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1–L4)
|
|
||||||
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
|
|
||||||
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
|
|
||||||
plugins (e.g. the WebUI stop button).
|
|
||||||
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
|
|
||||||
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
|
|
||||||
to its own temp instance).
|
|
||||||
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
|
|
||||||
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
|
|
||||||
directly, items needing the main agent get an immediate "busy, please wait", so users no
|
|
||||||
longer wait 10+ minutes in silence.
|
|
||||||
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
|
|
||||||
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
|
|
||||||
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
|
|
||||||
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
|
|
||||||
|
|
||||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
|
||||||
|
|
||||||
- **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; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
|
|
||||||
`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.
|
|
||||||
>
|
|
||||||
> **One licensing statement has also changed** (2026-09-24): "statically linked plugins must match"
|
|
||||||
> in the v1.2.0 entry below **no longer holds**. The SDK is now released under **MIT** (a permissive
|
|
||||||
> license that does not inherit the kernel's AGPL), so external plugins are **not** derivative works
|
|
||||||
> of this project: authors choose their own license (closed-source, commercial or private included)
|
|
||||||
> and are not bound by §13. The original text is kept to show the position at the time.
|
|
||||||
>
|
|
||||||
> **Two performance claims also need correcting** (measured 2026-09-20):
|
|
||||||
> "crash-to-recovery under 1s" does not hold — restarts are **linearly backed off**, i.e.
|
|
||||||
> 1s / 2s / 3s (`procRestartBackoff=1s × nth crash`). Even the *first* restart waits 1s.
|
|
||||||
> And the **4th** crash within a 5-minute window stops automatic restarts pending human
|
|
||||||
> intervention (`procMaxRestarts=3`, tested as `n > 3`).
|
|
||||||
> The backoff was already 1s when this claim was written (v1.0.0), so it never held.
|
|
||||||
> "RPC round-trip p50 24.1µs" is the right order of magnitude but does not match current
|
|
||||||
> measurements: `BenchmarkToolInvoke` on this machine gives inline/small **30.4µs**,
|
|
||||||
> frame/small 51.5µs, inline/large 767µs, frame/large 398µs.
|
|
||||||
> The original text is left unedited rather than rewritten, so the history isn't falsified.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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.**
|
**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.**
|
||||||
|
|
||||||
@ -328,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 |
|
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||||
|
|
||||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
- 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.0.1_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||||
- Verification: `SHA256SUMS`
|
- Verification: `SHA256SUMS`
|
||||||
|
|
||||||
@ -342,46 +220,4 @@ make test # go test ./...
|
|||||||
make install # Install to system
|
make install # Install to system
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
|
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||||
|
|
||||||
> `homed` requires Linux (it relies on fd inheritance and intra-segment offset
|
|
||||||
dereferencing of the shared memory region; see `cmd/homed/platform_windows.go`).
|
|
||||||
On Windows only `waiter.exe` is built and `homed` runs under WSL2 (see Downloads).
|
|
||||||
macOS can build `waiter`/`initconfig`; `homed` must be built on native macOS.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
[homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) (the SDK source ends up inside the
|
|
||||||
plugin binary), but that repository is released under **MIT** — a permissive license, so code
|
|
||||||
received under it does **not** inherit this project's AGPL. External plugins are therefore **not
|
|
||||||
derivative works of this project**: authors pick their own license (closed-source, commercial or
|
|
||||||
private included), with no same-license obligation and no §13 network clause. The safety and
|
|
||||||
vitality of the third-party plugin ecosystem rest on this.
|
|
||||||
|
|
||||||
The boundary is clean: **AGPL covers the kernel and the bundled plugins** (`homed`, `internal/`,
|
|
||||||
the 18 built-in plugins under `internal/plugins/`); **MIT covers the public SDK** (`sdk/`, whose
|
|
||||||
`go.mod` has zero external dependencies and imports only the Go standard library — it never
|
|
||||||
references any kernel code). Process isolation is beside the point here — what decides the
|
|
||||||
license is the linked SDK code itself, and that code is MIT.
|
|
||||||
|
|
||||||
### 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
|
RelevanceContext — In-memory events[] + JSON persistence
|
||||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||||
agent→Response, user→Input, cold_storage→Input+Response
|
agent→Response, user→Input, cold_storage→Input+Response
|
||||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||||
├── Keep → timeline → chronologically sorted → system prompt
|
├── Keep → timeline → chronologically sorted → system prompt
|
||||||
└── Low score → Document layer archive (original timestamp)
|
└── Low score → Document layer archive (original timestamp)
|
||||||
Save: 5s debounce write to disk
|
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
|
↓ Prune archive ↑ LLM active recall
|
||||||
|
|
||||||
② Document (File Memory)
|
② 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)
|
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||||
Read:
|
Read:
|
||||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
├── 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
|
→ 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
|
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||||
error and never pretends to succeed**:
|
|
||||||
|
|
||||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||||
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)
|
|
||||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
- 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
|
- 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_query` | Search from Document |
|
||||||
| `doc_commit` | Write to 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
|
### Other Memory Layers
|
||||||
|
|
||||||
- **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types
|
- **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 |
|
| Method | Registration Mechanism | Compilation | Usage |
|
||||||
|--------|----------------------|-------------|-------|
|
|--------|----------------------|-------------|-------|
|
||||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
| 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. |
|
| 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 |
|
| 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 |
|
| Dimension | Built-in Plugin | External Plugin |
|
||||||
|-----------|----------------|-----------------|
|
|-----------|----------------|-----------------|
|
||||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
| 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 |
|
| 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.) |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.Knowledge().Search/Create
|
||||||
sdk.Settings().Get/Set/List
|
sdk.Settings().Get/Set/List
|
||||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)
|
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
|
### Plugin Interface
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -455,74 +392,7 @@ Extended fields:
|
|||||||
- Relation extension: Confidence
|
- Relation extension: Confidence
|
||||||
|
|
||||||
|
|
||||||
## Input Scheduler & Interrupt Mechanism
|
## Interrupt Mechanism
|
||||||
|
|
||||||
Inputs do not go straight to the LLM — they first enter the **input scheduler**
|
|
||||||
(`internal/agent/core/scheduler.go`). Full design:
|
|
||||||
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
|
|
||||||
|
|
||||||
### Two task classes
|
|
||||||
|
|
||||||
| Class | Level | Meaning |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
|
|
||||||
| `TaskInterrupt` | L1–L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
|
|
||||||
|
|
||||||
### Four interrupt levels
|
|
||||||
|
|
||||||
| Level | Meaning | Typical source |
|
|
||||||
|-------|---------|----------------|
|
|
||||||
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
|
|
||||||
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
|
|
||||||
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
|
|
||||||
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
|
|
||||||
|
|
||||||
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
|
|
||||||
plugin never gets preemption rights by accident. L4 declared by an external plugin is
|
|
||||||
**clamped to L3** (`clampPluginLevel`).
|
|
||||||
|
|
||||||
### Preemption and suspension
|
|
||||||
|
|
||||||
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
|
|
||||||
this is why messages normally wait for the running task to finish.
|
|
||||||
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
|
|
||||||
and resumed later; the stack is never re-sorted by priority.
|
|
||||||
- **Starvation guard**: preemption count raises the effective level
|
|
||||||
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
|
|
||||||
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
|
|
||||||
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
|
|
||||||
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
|
|
||||||
|
|
||||||
### Stop (user presses stop / `/stop`)
|
|
||||||
|
|
||||||
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
|
|
||||||
② short-circuit the x messages **already queued at the moment of stop** during their
|
|
||||||
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
|
|
||||||
Inputs arriving **after** the stop are unaffected.
|
|
||||||
|
|
||||||
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
|
|
||||||
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
|
|
||||||
task, and counting only `sched.queue` yields 0.
|
|
||||||
|
|
||||||
### Resident sub-agents and timely feedback
|
|
||||||
|
|
||||||
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
|
|
||||||
|
|
||||||
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
|
|
||||||
temp graph memory, sharing the channel registry.
|
|
||||||
- Parent→child control plane: `resident_agents`
|
|
||||||
(list / create / send / inspect / compress / reclaim / destroy).
|
|
||||||
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
|
|
||||||
configurable), the kernel hands queued inputs to a temporary **triage assistant**
|
|
||||||
(`offload_*` config): simple ones are handled directly, ones needing the main agent
|
|
||||||
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
|
|
||||||
- The triage assistant gets **no inputch** (it receives no plugin user input) and
|
|
||||||
**all output channels** (results must reach the original channel).
|
|
||||||
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
|
|
||||||
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
|
|
||||||
discarded with a per-item log entry).
|
|
||||||
|
|
||||||
### Legacy three-path view (still present, now a layer beneath the scheduler)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
interceptLoop (goroutine)
|
interceptLoop (goroutine)
|
||||||
@ -532,28 +402,15 @@ interceptLoop (goroutine)
|
|||||||
└── (c) InjectInput() → Trigger new processing when idle
|
└── (c) InjectInput() → Trigger new processing when idle
|
||||||
```
|
```
|
||||||
|
|
||||||
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
|
Three delivery paths:
|
||||||
|
|
||||||
## Context Budget
|
| Path | Effect | Timing |
|
||||||
|
|------|--------|--------|
|
||||||
|
| cancelLLM | Cancel current HTTP request | On context.Canceled |
|
||||||
|
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
|
||||||
|
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
|
||||||
|
|
||||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
Code: `internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||||
|
|
||||||
```
|
|
||||||
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
|
|
||||||
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
|
|
||||||
├── memory recall budget = (targetUsage - fixed) / 3
|
|
||||||
└── context events budget = remaining 2/3
|
|
||||||
```
|
|
||||||
|
|
||||||
★ **Window ≠ working band**: a source's real window may reach 1M, but near-full windows
|
|
||||||
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
|
|
||||||
working band separately. If the model name (e.g. `AUTO`) yields no window,
|
|
||||||
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
|
|
||||||
declare `core.llm.sources.<name>.context_window` explicitly.
|
|
||||||
|
|
||||||
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
|
|
||||||
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
|
|
||||||
budget, actual injection was still a few hundred characters.
|
|
||||||
|
|
||||||
|
|
||||||
## Configuration System
|
## Configuration System
|
||||||
|
|||||||
@ -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.
|
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
|
## What It Actually Does
|
||||||
|
|
||||||
Code is in the project root, implemented in Go.
|
Code is in the project root, implemented in Go.
|
||||||
|
|||||||
@ -29,80 +29,75 @@ type Plugin interface {
|
|||||||
|
|
||||||
| Method | Use Case | Complexity |
|
| 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 |
|
| **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"> :
|
<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
|
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||||
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).
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd homeagent-sdk/tools/hmapdev
|
cd homeagent-sdk/tools/plugindev
|
||||||
go build -o hmapdev
|
go build -o plugindev
|
||||||
# Add hmapdev to PATH or use directly
|
# Add plugindev to PATH or use directly
|
||||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### SDK Version Management
|
### SDK Version Management
|
||||||
|
|
||||||
`hmapdev sdk` manages local SDK versions:
|
`plugindev sdk` manages local SDK versions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev sdk list # list installed SDK versions
|
plugindev sdk list # list installed SDK versions
|
||||||
hmapdev sdk current # show current SDK version
|
plugindev sdk current # show current SDK version
|
||||||
hmapdev sdk latest # show latest available version
|
plugindev sdk latest # show latest available version
|
||||||
hmapdev sdk install v1.2.0 # install a specific version
|
plugindev sdk install v0.8.0 # install a specific version
|
||||||
hmapdev sdk use v1.2.0 # switch to a version
|
plugindev sdk use v0.8.0 # switch to a version
|
||||||
hmapdev sdk path # show current SDK path
|
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
|
### 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
|
```bash
|
||||||
hmapdev debug [dir] # dir defaults to the current directory
|
plugindev debug [dir] # dir defaults to the current directory
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a Go Plugin
|
### Creating a Go Plugin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev init myplugin
|
plugindev init myplugin
|
||||||
cd myplugin
|
cd myplugin
|
||||||
# Edit plugin code
|
# Edit plugin code
|
||||||
vim plugin.go
|
vim plugin.go
|
||||||
# Build and package (default is a multi-platform bundle, see below)
|
# Build and package (default is a multi-platform bundle, see below)
|
||||||
hmapdev build
|
plugindev build
|
||||||
# Output: dist/myplugin_bundle.hmap
|
# Output: dist/myplugin_bundle.hmap
|
||||||
# Single-platform build:
|
# Single-platform build:
|
||||||
hmapdev build --no-bundle
|
plugindev build --no-bundle
|
||||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a Lua Plugin
|
### Creating a Lua Plugin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev init myluaplugin --lua
|
plugindev init myluaplugin --lua
|
||||||
cd myluaplugin
|
cd myluaplugin
|
||||||
# Edit plugin code
|
# Edit plugin code
|
||||||
vim main.lua
|
vim main.lua
|
||||||
# Local test
|
# Local test
|
||||||
lua main.lua
|
lua main.lua
|
||||||
# Build and package
|
# Build and package
|
||||||
hmapdev build
|
plugindev build
|
||||||
# Output: dist/myluaplugin_lua.hmap
|
# Output: dist/myluaplugin_lua.hmap
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -133,16 +128,16 @@ myluaplugin/
|
|||||||
|
|
||||||
### Build & Package
|
### Build & Package
|
||||||
|
|
||||||
`hmapdev build` automatically handles compilation and packaging:
|
`plugindev build` automatically handles compilation and packaging:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd myplugin
|
cd myplugin
|
||||||
hmapdev build # default bundle mode (multi-platform)
|
plugindev build # default bundle mode (multi-platform)
|
||||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||||
hmapdev build --outdir dist # output directory (default: dist)
|
plugindev build --outdir dist # output directory (default: dist)
|
||||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||||
```
|
```
|
||||||
|
|
||||||
Execution process:
|
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
|
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||||
> skips legacy artifacts with an actionable error instead of crashing.
|
> skips legacy artifacts with an actionable error instead of crashing.
|
||||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
> - **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
|
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
> - 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
|
### 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
|
```bash
|
||||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@ -280,7 +275,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
|||||||
|
|
||||||
### Entry Point
|
### 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:
|
which is the entry point when the kernel loads the plugin:
|
||||||
|
|
||||||
```go
|
```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_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
|
`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
|
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
|
### 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.
|
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.
|
||||||
|
|
||||||
> 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).
|
|
||||||
|
|
||||||
**Registration**
|
**Registration**
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `sdk.log(level, msg)` | Log output |
|
| `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_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_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.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 |
|
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
|
||||||
|
|
||||||
**Stage hook context**
|
**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):
|
**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_text(source, channel, text)` | Deliver text message |
|
||||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
| `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)`)**
|
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
|
||||||
|
|
||||||
| Sub-table | Functions |
|
| 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.memory.*` | `recall(query, depth)`, `commit({triples})`, `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.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
|
||||||
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
|
| `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.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.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.json.*` | `encode(val)`, `decode(str)` |
|
||||||
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
|
| `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"> :
|
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||||
@ -790,14 +765,7 @@ pmgr.ReloadPlugins() // Reload all plugins
|
|||||||
|
|
||||||
Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabled_at`, `disabled_by`). Disabling takes effect immediately (plugin stops receiving input); full removal requires a restart.
|
Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabled_at`, `disabled_by`). Disabling takes effect immediately (plugin stops receiving input); full removal requires a restart.
|
||||||
|
|
||||||
> **Note**: `PluginMgr()` returns an interface with **only 3 methods** (`ReloadOne` /
|
> **Note**: `PluginMgr()` is only available to built-in plugins; external dynamic plugins cannot call it directly.
|
||||||
> `ListLoadedPlugins` / `IsPluginDisabled`). Enable/disable/remove/reload-all
|
|
||||||
> (`EnablePlugin` / `DisablePlugin` / `RemovePlugin` / `ReloadPlugins`) and the
|
|
||||||
> built-in check (`IsBuiltinPlugin`) exist only on the kernel-internal
|
|
||||||
> `internal/sdk.PluginManager` — external plugins cannot reach them. To reload from
|
|
||||||
> an external plugin, use `ReloadOne`.
|
|
||||||
> The two interfaces have similar names but are **not the same**:
|
|
||||||
> `sdk.PluginMgrAPI` (public, 3 methods) vs `internal/sdk.PluginManager` (internal, 9).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -811,7 +779,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
|||||||
|---------|------|----------|
|
|---------|------|----------|
|
||||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
|
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring |
|
||||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
|
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
|
||||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
|
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
|
||||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
|
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
|
||||||
@ -824,12 +792,6 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
|||||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
|
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
|
||||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
|
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
|
||||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
|
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
|
||||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
|
|
||||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
|
|
||||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
|
|
||||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
|
|
||||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
|
|
||||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
|
|
||||||
|
|
||||||
### Built-in Plugins
|
### Built-in Plugins
|
||||||
|
|
||||||
|
|||||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
|||||||
RelevanceContext — 内存 events[] + JSON持久化
|
RelevanceContext — 内存 events[] + JSON持久化
|
||||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||||
├── 保留 → timeline → 按时间排序 → system prompt
|
├── 保留 → timeline → 按时间排序 → system prompt
|
||||||
└── 低分 → Document 层归档 (原始时间戳)
|
└── 低分 → Document 层归档 (原始时间戳)
|
||||||
Save: 5s debounce 写盘
|
Save: 5s debounce 写盘
|
||||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
|||||||
↓ Prune 归档 ↑ LLM 主动召回
|
↓ Prune 归档 ↑ LLM 主动召回
|
||||||
|
|
||||||
② Document (文件记忆)
|
② Document (文件记忆)
|
||||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||||
读取:
|
读取:
|
||||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||||
@ -132,20 +132,11 @@ eventLoop() → processTextInput()
|
|||||||
→ 三元组 → GraphDB.Commit
|
→ 三元组 → GraphDB.Commit
|
||||||
```
|
```
|
||||||
|
|
||||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||||
|
|
||||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||||
|
|
||||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
|
||||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
|
||||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
|
||||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
|
||||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
|
||||||
|
|
||||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
|
||||||
|
|
||||||
**模型来源**(词对齐 300 维)
|
|
||||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||||
@ -198,37 +189,6 @@ eventLoop() → processTextInput()
|
|||||||
| `doc_query` | 从 Document 搜索 |
|
| `doc_query` | 从 Document 搜索 |
|
||||||
| `doc_commit` | 写入 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 实体类型
|
- **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 等 |
|
| 内置插件 | `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 等 |
|
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
| 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)` 入口函数 |
|
| 注册方式 | `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 安装 |
|
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
| 插件目录 | 无独立目录,编译进二进制 | `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 原样复现 |
|
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,lost update 原样复现 |
|
||||||
| 通知面 | 事件环 + 平台通知(Linux eventfd / macOS pipe / Windows Event) | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
|
| 通知面 | 事件环 + 平台通知(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,
|
- 每子进程一根专职 `waitLoop`(`cmd.Wait()` 唯一调用点)——不依赖 stdout EOF,
|
||||||
因为插件 fork 的孙子进程(browser 拉 chromium、editdoc 拉 python)继承同一 stdout,
|
因为插件 fork 的孙子进程(browser 拉 chromium、editdoc 拉 python)继承同一 stdout,
|
||||||
@ -374,20 +330,8 @@ sdk.Memory().Recall/Commit
|
|||||||
sdk.Knowledge().Search/Create
|
sdk.Knowledge().Search/Create
|
||||||
sdk.Settings().Get/Set/List
|
sdk.Settings().Get/Set/List
|
||||||
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道,详见 output_send__qq_help", handler)
|
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 接口
|
### Plugin 接口
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -444,64 +388,7 @@ type Plugin interface {
|
|||||||
- Relation 扩展:Confidence
|
- Relation 扩展:Confidence
|
||||||
|
|
||||||
|
|
||||||
## 输入调度器与中断机制
|
## 中断机制
|
||||||
|
|
||||||
输入不直接进 LLM —— 它们先进**输入调度器**(`internal/agent/core/scheduler.go`)。
|
|
||||||
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
|
|
||||||
|
|
||||||
### 两类任务
|
|
||||||
|
|
||||||
| 类别 | 级别 | 语义 |
|
|
||||||
|------|------|------|
|
|
||||||
| `TaskQueued` 排队输入 | 无级别(恒 0) | 待办工作。任何中断(≥ L1)都能抢它 |
|
|
||||||
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
|
|
||||||
|
|
||||||
### 四级中断
|
|
||||||
|
|
||||||
| 级别 | 含义 | 典型来源 |
|
|
||||||
|------|------|----------|
|
|
||||||
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
|
|
||||||
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
|
|
||||||
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
|
|
||||||
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
|
|
||||||
|
|
||||||
未声明级别时取 **L1**(`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
|
|
||||||
外部插件声明 L4 会被**夹到 L3**(`clampPluginLevel`)。
|
|
||||||
|
|
||||||
### 抢占与挂起
|
|
||||||
|
|
||||||
- **同级不能抢占同级**(`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
|
|
||||||
- 被抢占的任务压入**中断栈**(LIFO),用 `suspendStack` 保存现场,稍后恢复;
|
|
||||||
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
|
|
||||||
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`,
|
|
||||||
封顶 L4),确保低级别流不会被困。
|
|
||||||
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`(2s)内不再被抢,
|
|
||||||
避免高优先级流把同一个任务反复打断到永不完结。
|
|
||||||
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4,最多挂起 4 帧)。
|
|
||||||
|
|
||||||
### 停止(用户按停止按钮 / `/stop`)
|
|
||||||
|
|
||||||
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
|
|
||||||
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
|
|
||||||
当新输入再跑一遍。停止之后**新到**的输入不受影响。
|
|
||||||
|
|
||||||
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
|
|
||||||
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
|
|
||||||
|
|
||||||
### 驻留式子 agent 与及时反馈
|
|
||||||
|
|
||||||
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
|
|
||||||
|
|
||||||
- **驻留子**是轻量内核的独立 agent:自己的调度器、自己的 temp 图记忆、共享的通道登记表。
|
|
||||||
- 父对子的控制面:`resident_agents`(list / create / send / inspect / compress / reclaim / destroy)。
|
|
||||||
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m,可配),内核把排队输入交给
|
|
||||||
一个临时**分诊助手**(`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
|
|
||||||
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
|
|
||||||
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
|
|
||||||
- 回收/销毁时它手头的**残余任务由父显式决定**:`residual=keep`(转回父队列,默认)
|
|
||||||
或 `drop`(明确丢弃,逐条记日志)。
|
|
||||||
|
|
||||||
### 旧版三路径(仍存在,但已是调度器之下的一层)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
interceptLoop (goroutine)
|
interceptLoop (goroutine)
|
||||||
@ -511,26 +398,15 @@ interceptLoop (goroutine)
|
|||||||
└── (c) InjectInput() → 空闲时触发新处理
|
└── (c) InjectInput() → 空闲时触发新处理
|
||||||
```
|
```
|
||||||
|
|
||||||
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
|
三种投递路径:
|
||||||
|
|
||||||
## 上下文预算
|
| 路径 | 效果 | 时机 |
|
||||||
|
|------|------|------|
|
||||||
|
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
|
||||||
|
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
|
||||||
|
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
|
||||||
|
|
||||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
代码:`internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||||
|
|
||||||
```
|
|
||||||
maxCtx = provider.MaxContextTokens() // 声明窗口(per-source context_window 优先)
|
|
||||||
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
|
|
||||||
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
|
|
||||||
└── 上下文事件预算 = 其余 2/3
|
|
||||||
```
|
|
||||||
|
|
||||||
★ **窗口 ≠ 工作面**:源的真实窗口可能到 1M,但接近满窗口时注意力涣散、
|
|
||||||
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
|
|
||||||
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
|
|
||||||
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
|
|
||||||
|
|
||||||
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
|
|
||||||
实测:预算 400K 时实际注入仍只有几百字符。
|
|
||||||
|
|
||||||
|
|
||||||
## 配置系统
|
## 配置系统
|
||||||
|
|||||||
@ -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 语言实现。
|
代码位于项目仓库根目录,Go 语言实现。
|
||||||
|
|||||||
@ -30,80 +30,75 @@ type Plugin interface {
|
|||||||
|
|
||||||
| 方式 | 适用场景 | 复杂度 |
|
| 方式 | 适用场景 | 复杂度 |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
| **内置插件** | 随 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"> :
|
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||||
|
|
||||||
## 一、快速开始:使用 hmapdev 工具链
|
## 一、快速开始:使用 plugindev 工具链
|
||||||
|
|
||||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
|
||||||
|
|
||||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
|
||||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd homeagent-sdk/tools/hmapdev
|
cd homeagent-sdk/tools/plugindev
|
||||||
go build -o hmapdev
|
go build -o plugindev
|
||||||
# 将 hmapdev 加入 PATH 或直接使用
|
# 将 plugindev 加入 PATH 或直接使用
|
||||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### SDK 版本管理
|
### SDK 版本管理
|
||||||
|
|
||||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
plugindev sdk list # 列出已安装的 SDK 版本
|
||||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||||
hmapdev sdk latest # 显示最新可用版本
|
plugindev sdk latest # 显示最新可用版本
|
||||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
plugindev sdk install v0.8.0 # 安装指定版本
|
||||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
plugindev sdk use v0.8.0 # 切换使用版本
|
||||||
hmapdev sdk path # 显示当前 SDK 路径
|
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
|
```bash
|
||||||
hmapdev debug [dir] # dir 默认当前目录
|
plugindev debug [dir] # dir 默认当前目录
|
||||||
```
|
```
|
||||||
|
|
||||||
### 创建 Go 插件
|
### 创建 Go 插件
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev init myplugin
|
plugindev init myplugin
|
||||||
cd myplugin
|
cd myplugin
|
||||||
# 编辑插件代码
|
# 编辑插件代码
|
||||||
vim plugin.go
|
vim plugin.go
|
||||||
# 编译打包
|
# 编译打包
|
||||||
hmapdev build # 默认多平台 bundle(见下节)
|
plugindev build # 默认多平台 bundle(见下节)
|
||||||
# 输出: dist/myplugin_bundle.hmap
|
# 输出: dist/myplugin_bundle.hmap
|
||||||
# 单平台构建:
|
# 单平台构建:
|
||||||
hmapdev build --no-bundle
|
plugindev build --no-bundle
|
||||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 创建 Lua 插件
|
### 创建 Lua 插件
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev init myluaplugin --lua
|
plugindev init myluaplugin --lua
|
||||||
cd myluaplugin
|
cd myluaplugin
|
||||||
# 编辑插件代码
|
# 编辑插件代码
|
||||||
vim main.lua
|
vim main.lua
|
||||||
# 本地测试
|
# 本地测试
|
||||||
lua main.lua
|
lua main.lua
|
||||||
# 编译打包
|
# 编译打包
|
||||||
hmapdev build
|
plugindev build
|
||||||
# 输出: dist/myluaplugin_lua.hmap
|
# 输出: dist/myluaplugin_lua.hmap
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -134,16 +129,16 @@ myluaplugin/
|
|||||||
|
|
||||||
### 编译打包
|
### 编译打包
|
||||||
|
|
||||||
`hmapdev build` 会自动完成编译和打包:
|
`plugindev build` 会自动完成编译和打包:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd myplugin
|
cd myplugin
|
||||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
plugindev build # 默认 bundle 模式(多平台合集)
|
||||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||||
hmapdev build --replace <mod@path> # 追加 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`)|
|
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||||
|
|
||||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||||
|
|
||||||
@ -174,7 +169,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
|||||||
>
|
>
|
||||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||||
> 会跳过并报可操作错误,不崩溃。
|
> 会跳过并报可操作错误,不崩溃。
|
||||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
|
||||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||||
> 它现在只用于区分 Lua 插件。
|
> 它现在只用于区分 Lua 插件。
|
||||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||||
@ -183,12 +178,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
|||||||
|
|
||||||
### 构建目标与多平台打包(bundle)
|
### 构建目标与多平台打包(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
|
```bash
|
||||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
plugindev build --bundle # 显式开启 bundle(同上)
|
||||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
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
|
```go
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
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
|
### PluginSDK 核心 API
|
||||||
|
|
||||||
@ -592,26 +587,23 @@ lua main.lua
|
|||||||
|
|
||||||
### Lua SDK API
|
### 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)返回空值而非报错。
|
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||||
|
|
||||||
> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
|
|
||||||
|
|
||||||
**注册类**
|
**注册类**
|
||||||
|
|
||||||
| 函数 | 说明 |
|
| 函数 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `sdk.log(level, msg)` | 日志输出 |
|
| `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_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
|
||||||
| `sdk.register_api(name)` | 注册 API |
|
| `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.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
|
||||||
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
|
|
||||||
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
|
| `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`(与子进程外部插件能力对齐):
|
**Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
|
||||||
|
|
||||||
@ -640,37 +632,20 @@ end)
|
|||||||
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
||||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||||
| `sdk.inject_text_no_memory(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)`)**
|
**数据类(与子进程外部插件对齐,均返回 `(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.memory.*` | `recall(query, depth)`、`commit({triples})`、`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.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` |
|
||||||
| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` |
|
| `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.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.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.json.*` | `encode(val)`、`decode(str)` |
|
||||||
| `sdk.http.*` | `get(url)`、`post(url, body, content_type)` |
|
| `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"> :
|
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||||
@ -783,12 +758,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
|
|
||||||
内部机制:禁用记录存储在 SQLite `disabled_plugins` 表(`name`, `disabled_at`, `disabled_by`),禁用立即生效(插件不再接收输入),完全卸载需重启内核。
|
内部机制:禁用记录存储在 SQLite `disabled_plugins` 表(`name`, `disabled_at`, `disabled_by`),禁用立即生效(插件不再接收输入),完全卸载需重启内核。
|
||||||
|
|
||||||
> **注意**:`PluginMgr()` 返回的接口**只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` /
|
> **注意**:`PluginMgr()` 仅内置插件可用,外部动态插件无法直接调用。
|
||||||
> `IsPluginDisabled`)。启用/禁用/卸载/重载全部(`EnablePlugin` / `DisablePlugin` /
|
|
||||||
> `RemovePlugin` / `ReloadPlugins`)以及内置插件判定(`IsBuiltinPlugin`)只在内核内部
|
|
||||||
> 的 `internal/sdk.PluginManager` 上,外部插件拿不到——需重载插件请调 `ReloadOne`。
|
|
||||||
> 两个接口叫相似的名字但**不是同一个**:`sdk.PluginMgrAPI`(公开,3 方法)与
|
|
||||||
> `internal/sdk.PluginManager`(内部,9 方法)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -802,7 +772,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
|
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
|
||||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
||||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 |
|
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 |
|
||||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) |
|
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) |
|
||||||
@ -815,12 +785,6 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
|
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
|
||||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
|
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
|
||||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
|
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
|
||||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) |
|
|
||||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
|
|
||||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
|
|
||||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) |
|
|
||||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 |
|
|
||||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
|
|
||||||
|
|
||||||
### 内置插件
|
### 内置插件
|
||||||
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
# kbtree 本机部署交接(交给部署方执行)
|
|
||||||
|
|
||||||
我负责把 kbtree 的**代码**合入 main,并把 **skill + 调用方法**在本机装好。
|
|
||||||
**二进制替换与重启由你做** —— 我不碰运行中的生产守护进程。
|
|
||||||
|
|
||||||
## 现状(交接时的事实)
|
|
||||||
|
|
||||||
| 项 | 状态 |
|
|
||||||
|---|---|
|
|
||||||
| kbtree 代码 | ✅ 已在 `main`(`41d7543`),已推送 |
|
|
||||||
| skill | ✅ 已装 `/home/newqqagent/skills/knowledge-base/` |
|
|
||||||
| token | ✅ 已写入 `config_kbtree` 表(固定值,重启不变) |
|
|
||||||
| 配置库备份 | ✅ `config.db.bak-20260926-143643` |
|
|
||||||
| **服务** | ❌ **未上线** —— 运行中的二进制里没有 kbtree |
|
|
||||||
| 我构建的候选二进制 | `/tmp/homed-new`(84.7MB,与 main 同源,`-tags onnxruntime`) |
|
|
||||||
|
|
||||||
**为何未上线**:14:35 有人替换了 `/usr/local/bin/homed`(84,985,720 字节)并重启了
|
|
||||||
服务(现 PID 1450152)。那个二进制与 main 构建**不是同一份**,比我的大 2.4MB。
|
|
||||||
直接部署会覆盖它 —— 所以交给你决定何时、以及以哪个版本为准。
|
|
||||||
|
|
||||||
实测确认现役二进制不含 kbtree:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
strings /usr/local/bin/homed | grep -c kbtree # → 0
|
|
||||||
ss -ltn | grep 9892 # → 无监听
|
|
||||||
```
|
|
||||||
|
|
||||||
## 上线步骤
|
|
||||||
|
|
||||||
**必须按顺序,且第 1 步不能用 `cp`**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DATA=/home/newqqagent
|
|
||||||
|
|
||||||
# 1. 备份配置库(WAL 模式下 cp 会拿到不一致快照,必须用 .backup)
|
|
||||||
sqlite3 $DATA/config.db ".backup '$DATA/config.db.bak-$(date +%Y%m%d-%H%M%S)'"
|
|
||||||
|
|
||||||
# 2. 确认要部署的二进制
|
|
||||||
strings /tmp/homed-new | grep -c kbtree # 期望 ≥ 1;为 0 说明候选不对
|
|
||||||
|
|
||||||
# 3. 原子替换(install 内部是 rename,不会写坏运行中进程的映像)
|
|
||||||
install -m 0755 /tmp/homed-new /usr/local/bin/homed
|
|
||||||
|
|
||||||
# 4. 重启
|
|
||||||
systemctl restart homeagent
|
|
||||||
|
|
||||||
# 5. 验证
|
|
||||||
systemctl is-active homeagent
|
|
||||||
journalctl -u homeagent --since "-2min" | grep kbtree
|
|
||||||
ss -ltn | grep 9892
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 5 步期望看到
|
|
||||||
|
|
||||||
```
|
|
||||||
[kbtree] 已启动 http://127.0.0.1:9892
|
|
||||||
[kbtree] 外部 agent 可用:GET /tree、/categories、/counts、/search?q=&category=
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署后的冒烟测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/newqqagent/skills/knowledge-base
|
|
||||||
|
|
||||||
./scripts/kb_tree.sh -h # 帮助(不需要 token)
|
|
||||||
./scripts/kb_tree.sh categories # 分类列表
|
|
||||||
./scripts/kb_tree.sh counts # 各分类条目数
|
|
||||||
./scripts/kb_tree.sh tree -d 1 -i 0 # 只看第一层结构
|
|
||||||
./scripts/kb_tree.sh search -q 知识库 -c tech -l 3
|
|
||||||
```
|
|
||||||
|
|
||||||
token 自动从 `config/config.json` 读(权限 600),无需手动 export。
|
|
||||||
覆盖方式:`KB_TOKEN=xxx ./scripts/kb_tree.sh ...` 或改那个 json。
|
|
||||||
|
|
||||||
## 端口与 token
|
|
||||||
|
|
||||||
- 监听:`kbtree.listen_addr` = `127.0.0.1:9892`(**仅本机**)
|
|
||||||
- token:`kbtree.token` 已在 `config_kbtree` 表里设为固定值。
|
|
||||||
若要改成随机(每次重启变),把该行 value 置空即可 ——
|
|
||||||
但那样每次重启都要重新把 token 告诉所有使用者。
|
|
||||||
- 改 token 时**两处一起改**:`config_kbtree` 表 + `skills/knowledge-base/config/config.json`。
|
|
||||||
|
|
||||||
要对外(别的机器)时改 `listen_addr` 为 `0.0.0.0:9892`,
|
|
||||||
但**先想清楚 token 怎么分发** —— 它是唯一的屏障。
|
|
||||||
|
|
||||||
## 退出码约定(脚本)
|
|
||||||
|
|
||||||
| 码 | 含义 |
|
|
||||||
|---|---|
|
|
||||||
| 0 | 成功 |
|
|
||||||
| 2 | 参数错误 / 未知命令 |
|
|
||||||
| 3 | 令牌无效(401) |
|
|
||||||
| 4 | 分类不存在(404,stderr 会列出现有分类) |
|
|
||||||
| 5 | 其它 HTTP 错误 |
|
|
||||||
| 7 | 连不上(**通常是服务未上线**) |
|
|
||||||
| 8 | 请求发送失败 |
|
|
||||||
|
|
||||||
## 回滚
|
|
||||||
|
|
||||||
```bash
|
|
||||||
install -m 0755 <旧二进制> /usr/local/bin/homed
|
|
||||||
systemctl restart homeagent
|
|
||||||
```
|
|
||||||
|
|
||||||
配置与 skill 都不影响回滚(kbtree 未启动时它们只是闲置文件)。
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
---
|
|
||||||
name: knowledge-base
|
|
||||||
description: 按分类树检索 HomeAgent 知识库。当需要查阅项目知识、架构约定、历史决策,或用户提到"知识库/knowledge/知识库有哪些/这个项目的约定是什么"时使用。支持树形导航、分类内检索、以图搜知识。
|
|
||||||
version: 1.1.0
|
|
||||||
author: HomeAgent
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
先看树定位分类,再做定向检索。接口由 HomeAgent 的 `kbtree` 插件提供,
|
|
||||||
只读、需 token。详见下方"接入"。
|
|
||||||
|
|
||||||
HomeAgent 知识库按**分类树**组织(`tech/go/并发`、`life/sleep` …)。
|
|
||||||
|
|
||||||
### 何时用
|
|
||||||
|
|
||||||
- 用户问"这个项目/内核的某个约定是什么" → 先看树,找对分类再检索
|
|
||||||
- 用户提到"知识库"或某个看起来像分类名的词(如 `tech/go`)→ 查该子树
|
|
||||||
- 用户给了图片并问"知识库里有相关的吗" → 见"以图搜知识"
|
|
||||||
|
|
||||||
### 接入
|
|
||||||
|
|
||||||
服务默认监听 `127.0.0.1:9892`(配置项 `kbtree.listen_addr`),只读,
|
|
||||||
每次请求需带 token(配置项 `kbtree.token`;未配置则启动时随机生成)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE=http://127.0.0.1:9892
|
|
||||||
AUTH="X-API-Key: $KB_TOKEN" # 或 Authorization: Bearer <token> 或 ?token=
|
|
||||||
```
|
|
||||||
|
|
||||||
先摸清可用接口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -H "$AUTH" "$BASE/"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本机封装脚本(推荐先用它)
|
|
||||||
|
|
||||||
`scripts/kb_tree.sh` 把上面四条命令封好了,省得手拼 URL 与鉴权头:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
S=~/.claude/skills/knowledge-base/scripts/kb_tree.sh # 按实际安装路径调整
|
|
||||||
|
|
||||||
$S # 整棵树
|
|
||||||
$S tree -c tech -d 1 # tech 子树,只看第一层
|
|
||||||
$S categories # 分类列表
|
|
||||||
$S counts # 各分类条目数
|
|
||||||
$S search -q goroutine -c tech
|
|
||||||
```
|
|
||||||
|
|
||||||
token 读取顺序:环境变量 `KB_TOKEN` → 同上<E5908C><E4B88A>目录的 `../config.json`。
|
|
||||||
**不要**把 token 写在命令行上(会进 shell 历史与 `ps` 输出)。
|
|
||||||
|
|
||||||
### 核心工作流:先看树,再定向检索
|
|
||||||
|
|
||||||
**别一上来就全文搜索。** 知识库是分层的,先定位分类能显著提高命中率,
|
|
||||||
也能避免把范围外的弱匹配当答案。
|
|
||||||
|
|
||||||
#### 第 1 步 · 看有哪些分类
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -H "$AUTH" "$BASE/categories"
|
|
||||||
# {"categories":["cook","life","tech","tech/go","tech/rust"]}
|
|
||||||
|
|
||||||
# 内容最多的分类(按条目数倒序)
|
|
||||||
curl -s -H "$AUTH" "$BASE/counts"
|
|
||||||
# {"counts":[{"category":"tech","count":3}, ...], "total":5}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 第 2 步 · 浏览树结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 整棵树,只要结构
|
|
||||||
curl -s -H "$AUTH" "$BASE/tree?items=0"
|
|
||||||
|
|
||||||
# 只要第一层 —— 分类多时用来做懒加载
|
|
||||||
curl -s -H "$AUTH" "$BASE/tree?depth=1&items=0"
|
|
||||||
|
|
||||||
# 某棵子树,带条目详情
|
|
||||||
curl -s -H "$AUTH" "$BASE/tree?category=tech/go"
|
|
||||||
```
|
|
||||||
|
|
||||||
节点里 `name` 是**本级段名**(`"go"`),`path` 是**完整路径**
|
|
||||||
(`"tech/go"`)。拼层级用 `name`,把 `path` 拿去请求子节点。
|
|
||||||
`item_count` 是本级条目数,`total_count` 是整棵子树。
|
|
||||||
|
|
||||||
#### 第 3 步 · 分类内检索
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 关键词 + 分类子树(推荐)
|
|
||||||
curl -s -H "$AUTH" "$BASE/search?q=goroutine&category=tech"
|
|
||||||
|
|
||||||
# 全库
|
|
||||||
curl -s -H "$AUTH" "$BASE/search?q=goroutine&limit=5"
|
|
||||||
```
|
|
||||||
|
|
||||||
`category` 是**前缀匹配**:`tech` 会命中 `tech/go`、`tech/rust` 下的条目;
|
|
||||||
传 `tech/go` 只命中它自己的子树。
|
|
||||||
|
|
||||||
### 以图搜知识(多模态)
|
|
||||||
|
|
||||||
若知识条目挂了图片,它在**多模态统一空间**里有向量,能被图片本身检索到。
|
|
||||||
前提是宿主已接入多模态向量 provider(否则只是记录了媒体,不参与召回)。
|
|
||||||
|
|
||||||
判断是否就绪:HomeAgent 自身的知识库接口会返回稠密路状态
|
|
||||||
(`dense.enabled` / `dense.ready` / `dense.total`)。若为未启用,
|
|
||||||
**不要承诺"能以图搜"**。
|
|
||||||
|
|
||||||
本服务只提供**按关键词检索**——把图片字节提交给嵌入服务计算向量不在此接口内。所以:
|
|
||||||
- 用户给了图 → 用图的**可见内容**(或你先读图得到的文字)当关键词检索
|
|
||||||
- 或用本机可用的读图工具先看图,再拿描述来检索
|
|
||||||
|
|
||||||
### 读结果
|
|
||||||
|
|
||||||
`/search` 每条结果:
|
|
||||||
|
|
||||||
| 字段 | 含义 |
|
|
||||||
|---|---|
|
|
||||||
| `name` | 知识名(**已含分类前缀**,如 `tech/go/并发`) |
|
|
||||||
| `content` | 正文全文 |
|
|
||||||
|
|
||||||
`/tree` 里的条目额外有 `preview`(前 120 字)、`size`、`updated_at`、
|
|
||||||
`media`(挂载的媒体 digest/mime/kind)。
|
|
||||||
|
|
||||||
### 易错点
|
|
||||||
|
|
||||||
- **`name` 已经含分类**。不要再拼 `category + "/" + name`,会得到
|
|
||||||
`tech/go/tech/go/并发`。
|
|
||||||
- **检索会返回弱匹配。** 词法路会给所有条目打一个低分,靠排序把强命中顶到
|
|
||||||
前面。**只看第一条**;第一条明显不相关就换个分类或关键词,别把第 2、3 条
|
|
||||||
当答案。
|
|
||||||
- **分类不存在返回 404**,并在 `categories` 字段里附上现有分类 —— 用它自查
|
|
||||||
拼写。
|
|
||||||
- **`items=0` 只是不要正文**,`total_count` 仍准确,可用于判断规模。
|
|
||||||
- **本服务只读**。写方法返回 405。要写知识请用 HomeAgent 主 agent 的
|
|
||||||
`knowledge_create`(或 WebUI 界面),不要试图绕过它直接写这个接口。
|
|
||||||
- 知识名里不能有 `..`、空格(会被规范成 `_`)、点开头的段。
|
|
||||||
|
|
||||||
### 写入
|
|
||||||
|
|
||||||
本服务不提供写入。若你在 HomeAgent 主 agent 内部,写入用内核工具:
|
|
||||||
|
|
||||||
```
|
|
||||||
knowledge_create name="tech/go/调度" content="正文..."
|
|
||||||
```
|
|
||||||
|
|
||||||
`name` 用 `/` 表示分类层级(如 `tech/go/调度`)。写完它**立刻可检索**
|
|
||||||
(词法 IDF 是增量维护的,不必重启)。
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# kbtree 客户端:按分类树检索 HomeAgent 知识库。
|
|
||||||
#
|
|
||||||
# 由 knowledge-base skill 调用(agent 读 SKILL.md 后按需调本脚本)。
|
|
||||||
# 也可人工使用:
|
|
||||||
# kb_tree.sh # 整棵树
|
|
||||||
# kb_tree.sh -c tech -d 1 # tech 子树,只看一层
|
|
||||||
# kb_tree.sh -q goroutine -c tech # 在 tech 子树内检索
|
|
||||||
# kb_tree.sh -l # 列分类
|
|
||||||
#
|
|
||||||
# token 读取顺序:环境变量 KB_TOKEN > <skill>/config/config.json(或 <skill>/config.json)> 报错。
|
|
||||||
# 故意不放命令行参数:token 会进 shell 历史与 ps 输出。
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE="${KB_BASE:-http://127.0.0.1:9892}"
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# 读 token
|
|
||||||
# 帮助:-h/--help 必须在解析命令之前判,否则会被当成未知命令
|
|
||||||
for a in "$@"; do
|
|
||||||
case "$a" in
|
|
||||||
-h|--help)
|
|
||||||
echo "用法:"
|
|
||||||
echo " kb_tree.sh [tree|categories|counts|search] [选项]"
|
|
||||||
echo "选项:"
|
|
||||||
echo " -q 关键词 检索词(给了 q 就走 search)"
|
|
||||||
echo " -c 分类 限定分类子树(前缀匹配),如 tech / tech/go"
|
|
||||||
echo " -d 层数 树展开层数,1=只看第一层(懒加载)"
|
|
||||||
echo " -i 0|1 是否带条目详情,默认 1"
|
|
||||||
echo " -l 条数 search 的返回条数,默认 10"
|
|
||||||
echo "环境变量: KB_TOKEN(也可放同目录 config/config.json)"
|
|
||||||
exit 0 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "${KB_TOKEN:-}" ]]; then
|
|
||||||
# 两种布局都试:<skill>/config/config.json(当前布局)与 <skill>/config.json。
|
|
||||||
# 只写一种会因目录结构调整而静默失效——症状是「明明配了 token 却说没找到」。
|
|
||||||
for CFG in "$HERE/../config/config.json" "$HERE/../config.json"; do
|
|
||||||
if [[ -f "$CFG" ]]; then
|
|
||||||
KB_TOKEN="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("token",""))' "$CFG" 2>/dev/null || true)"
|
|
||||||
[[ -n "$KB_TOKEN" ]] && break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [[ -z "${KB_TOKEN:-}" ]]; then
|
|
||||||
cat >&2 <<'EOF'
|
|
||||||
kbtree: 未找到访问令牌。
|
|
||||||
|
|
||||||
取 token 的办法(其一):
|
|
||||||
1. 若 homed 启动日志里有「token 未配置,本次随机生成」,那行日志附近有 token;
|
|
||||||
2. 若已配置过固定 token,从配置库读:
|
|
||||||
sqlite3 <data_dir>/config.db "SELECT value FROM config_kbtree WHERE key='token'"
|
|
||||||
3. 直接设置:export KB_TOKEN=<token>
|
|
||||||
|
|
||||||
token 未配置时服务会在启动时随机生成,进程重启即失效。
|
|
||||||
EOF
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
auth=(-H "X-API-Key: ${KB_TOKEN}")
|
|
||||||
|
|
||||||
# 第一个参数若是选项(如 -q x),它就是选项而非子命令。
|
|
||||||
# 原先会把 "-q" 当命令名报「未知命令: -q」——而 `kb_tree.sh -q 并发` 是很自然的写法。
|
|
||||||
if [[ $# -gt 0 && "$1" == -* ]]; then
|
|
||||||
cmd=""
|
|
||||||
else
|
|
||||||
cmd="${1:-tree}"
|
|
||||||
shift || true
|
|
||||||
fi
|
|
||||||
query=""; category=""; depth=""; items=""; limit=""
|
|
||||||
while getopts "q:c:d:i:l:h" opt 2>/dev/null; do
|
|
||||||
case "$opt" in
|
|
||||||
q) query="$OPTARG" ;;
|
|
||||||
c) category="$OPTARG" ;;
|
|
||||||
d) depth="$OPTARG" ;;
|
|
||||||
i) items="$OPTARG" ;;
|
|
||||||
l) limit="$OPTARG" ;;
|
|
||||||
h) echo "用法: kb_tree.sh [tree|categories|counts|search] -q 关键词 -c 分类 -d 层数 -i 0|1 -l 条数"; exit 0 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# 预检:端口通不通。不通就直说"服务未上线",
|
|
||||||
# 而不是让用户看 curl: (7) Connection refused 后自行猜测。
|
|
||||||
HOSTPORT="${BASE#http://}"; HOSTPORT="${HOSTPORT%%/*}"
|
|
||||||
if ! (exec 3<>"/dev/tcp/${HOSTPORT%%:*}/${HOSTPORT##*:}") 2>/dev/null; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
kbtree: 连不上 $BASE(端口未监听)。
|
|
||||||
|
|
||||||
最常见原因——**服务还没上线**。kbtree 是 HomeAgent 的内置插件,
|
|
||||||
需要二进制里含它才会启动。若 homed 是在合入该插件之前构建/部署的,
|
|
||||||
就一直没有这个服务。上线后验证:
|
|
||||||
|
|
||||||
systemctl show homeagent -p MainPID --value
|
|
||||||
strings /usr/local/bin/homed | grep -c kbtree # 0 = 二进制里没有
|
|
||||||
|
|
||||||
本机上线步骤(会重启守护进程):
|
|
||||||
sqlite3 <data_dir>/config.db ".backup '<data_dir>/config.db.bak-\$(date +%Y%m%d-%H%M%S)'"
|
|
||||||
install -m 0755 <新二进制> /usr/local/bin/homed # 勿用 cp:会写坏运行中进程的映像
|
|
||||||
systemctl restart homeagent
|
|
||||||
journalctl -u homeagent -f | grep kbtree
|
|
||||||
EOF
|
|
||||||
exit 7
|
|
||||||
fi
|
|
||||||
|
|
||||||
enc() { python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1]))' "$1"; }
|
|
||||||
|
|
||||||
# kb_call 发请求并把错误翻译成人话。
|
|
||||||
# 不用 curl -f:它只吐 "curl: (22) 404",把服务端给的有用信息(404 会附现有分类)
|
|
||||||
# 全丢了 —— 而那恰恰是排查时最需要的。
|
|
||||||
kb_call() {
|
|
||||||
local url="$1" raw code
|
|
||||||
raw="$(curl -sS -w $'\n%{http_code}' "${auth[@]}" "$url" 2>&1)" || {
|
|
||||||
echo "kbtree: 请求失败: ${raw}" >&2; exit 8
|
|
||||||
}
|
|
||||||
code="$(printf '%s' "$raw" | tail -n1)"
|
|
||||||
body="$(printf '%s' "$raw" | sed '$d')"
|
|
||||||
case "$code" in
|
|
||||||
2*) printf '%s' "$body" | python3 -m json.tool; return 0 ;;
|
|
||||||
401) echo "kbtree: 令牌无效(401)。检查 config/config.json 或 KB_TOKEN 是否与 config_kbtree 表一致。" >&2; exit 3 ;;
|
|
||||||
404) echo "kbtree: 分类不存在(404)。服务端返回的现有分类:" >&2
|
|
||||||
printf '%s' "$body" | KB_BODY="$body" python3 -c '
|
|
||||||
import json, os, sys
|
|
||||||
try:
|
|
||||||
d = json.loads(os.environ.get("KB_BODY",""))
|
|
||||||
print(" " + ", ".join(d.get("categories", [])), file=sys.stderr)
|
|
||||||
except Exception:
|
|
||||||
print(" (无法解析响应体)", file=sys.stderr)
|
|
||||||
'
|
|
||||||
exit 4 ;;
|
|
||||||
*) echo "kbtree: HTTP $code" >&2; printf '%s\n' "$body" >&2; exit 5 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# 命令在解析选项**之后**定:没显式给子命令时,
|
|
||||||
# 有 -q 走 search、没有则走 tree。
|
|
||||||
if [[ -z "$cmd" ]]; then
|
|
||||||
if [[ -n "$query" ]]; then cmd="search"; else cmd="tree"; fi
|
|
||||||
fi
|
|
||||||
case "$cmd" in
|
|
||||||
tree|categories|counts|search) ;;
|
|
||||||
*) echo "未知命令: $cmd" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "$cmd" == "categories" ]]; then
|
|
||||||
kb_call "$BASE/categories"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "$cmd" == "counts" ]]; then
|
|
||||||
kb_call "$BASE/counts"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "$cmd" == "search" || -n "$query" ]]; then
|
|
||||||
[[ -n "$query" ]] || { echo "search 需要 -q 关键词" >&2; exit 2; }
|
|
||||||
url="$BASE/search?q=$(enc "$query")"
|
|
||||||
[[ -n "$category" ]] && url="$url&category=$(enc "$category")"
|
|
||||||
[[ -n "$limit" ]] && url="$url&limit=$limit"
|
|
||||||
kb_call "$url"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 默认:树
|
|
||||||
url="$BASE/tree"
|
|
||||||
[[ -n "$category" ]] && url="$url?category=$(enc "$category")"
|
|
||||||
q=""
|
|
||||||
[[ -n "$depth" ]] && q="depth=$depth"
|
|
||||||
[[ -n "$items" ]] && q="${q:+$q&}items=$items"
|
|
||||||
[[ -n "$q" ]] && url="$url?$q"
|
|
||||||
kb_call "$url"
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "knowledge-base",
|
|
||||||
"description": "按分类树检索 HomeAgent 知识库:树形导航、分类内检索、以图搜知识",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"author": "HomeAgent"
|
|
||||||
}
|
|
||||||
@ -810,11 +810,6 @@ const devOs = require("os");
|
|||||||
let deviceBridge = null; // 当前活动设备桥
|
let deviceBridge = null; // 当前活动设备桥
|
||||||
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
||||||
let deviceBridgeAddr = ""; // 设备桥网关地址
|
let deviceBridgeAddr = ""; // 设备桥网关地址
|
||||||
|
|
||||||
// 设备桥的**登记**状态(与"连接已建立"是两件事)。
|
|
||||||
// 连接成功但 bind 被拒时设备是失联的,只看 connected 会给出假阳性。
|
|
||||||
let deviceBridgeBound = false;
|
|
||||||
let deviceBridgeBindError = "";
|
|
||||||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||||||
let speechAccum = null;
|
let speechAccum = null;
|
||||||
|
|
||||||
@ -1257,33 +1252,9 @@ function onDeviceMsg(msg) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[device-bridge] exec error: " + e.message);
|
console.log("[device-bridge] exec error: " + e.message);
|
||||||
}
|
}
|
||||||
} else if (op === "bind_ack") {
|
} else if (op === "hello_ack" || op === "bind_ack") {
|
||||||
// ★ bind 结果必须判 ok —— 与鸿蒙端(DeviceBridge.ets:209)对齐。
|
|
||||||
//
|
|
||||||
// 原实现与 Go 客户端同病:只打一行日志、不看 ok。服务端 bind 被拒时回
|
|
||||||
// {"ok":false,"error":"bind rejected"} 并**关闭连接**,GUI 却既不报错也
|
|
||||||
// 不重连,设备静默失联 —— TCP/WS 通但从未登记进网关,命令永远下发不到。
|
|
||||||
//
|
|
||||||
// 另:成功时服务端**不含 device 字段**,原日志靠 `msg.device || deviceBridgeId`
|
|
||||||
// 兜底才显示得像成功,掩盖了「没有真的读 ok」这件事。
|
|
||||||
const accepted = msg.ok === true;
|
|
||||||
deviceBridgeBound = accepted;
|
|
||||||
if (accepted) {
|
|
||||||
console.log("[device-bridge] bind_ok device=" + deviceBridgeId);
|
|
||||||
} else {
|
|
||||||
deviceBridgeBindError = String(msg.error || "bind rejected");
|
|
||||||
console.error(
|
|
||||||
"[device-bridge] bind rejected: " +
|
|
||||||
deviceBridgeBindError +
|
|
||||||
"(设备令牌不匹配或设备未授权)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
rebuildTrayMenu();
|
|
||||||
} catch (e) {}
|
|
||||||
} else if (op === "hello_ack") {
|
|
||||||
console.log(
|
console.log(
|
||||||
"[device-bridge] hello_ack device=" + (msg.device || deviceBridgeId) + " online=" + msg.online,
|
"[device-bridge] " + op + " device=" + (msg.device || deviceBridgeId),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
@ -2109,8 +2080,6 @@ function argsSafe(cmd) {
|
|||||||
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
||||||
async function startDeviceBridge(cfg) {
|
async function startDeviceBridge(cfg) {
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
deviceBridgeBound = false;
|
|
||||||
deviceBridgeBindError = "";
|
|
||||||
const url = cfg.url || cfg.gateway || "";
|
const url = cfg.url || cfg.gateway || "";
|
||||||
const token = cfg.apiKey || cfg.token || "";
|
const token = cfg.apiKey || cfg.token || "";
|
||||||
if (!url || !token) {
|
if (!url || !token) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homeagent-gui",
|
"name": "homeagent-gui",
|
||||||
"version": "1.4.0",
|
"version": "1.0.0",
|
||||||
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
||||||
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
||||||
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
||||||
|
|||||||
@ -593,31 +593,6 @@ async function api(p, o) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从服务端发现设备网关地址(自动链接的权威来源)。
|
|
||||||
//
|
|
||||||
// 失败不报错:老版本 HomeAgent 没有这个端点,回退到本地推导即可
|
|
||||||
// (见 renderDeviceChannel 里的 state.discoveredGateway || 旧口径)。
|
|
||||||
//
|
|
||||||
// 优先 url_portal(门户同源形态):GUI 主进程连 WS 走系统解析器,
|
|
||||||
// devices.localhost 这类子域在系统解析器下通常解析不到 —— *.localhost
|
|
||||||
// 是浏览器内置特例(RFC 6761),不适用于普通进程。实测确认。
|
|
||||||
async function loadDiscoveredGateway() {
|
|
||||||
if (!state.currentConn || state.currentConn.type !== "webui") {
|
|
||||||
state.discoveredGateway = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var d = await api("/device/gateway");
|
|
||||||
state.discoveredGateway =
|
|
||||||
(d && d.available && (d.url_portal || d.url)) || "";
|
|
||||||
if (state.discoveredGateway) {
|
|
||||||
console.log("[device-bridge] discovered gateway: " + state.discoveredGateway);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
state.discoveredGateway = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Navigation =====
|
// ===== Navigation =====
|
||||||
function switchView(n) {
|
function switchView(n) {
|
||||||
document.querySelectorAll(".view").forEach((e) => {
|
document.querySelectorAll(".view").forEach((e) => {
|
||||||
@ -721,10 +696,6 @@ async function refreshDataOnly() {
|
|||||||
try {
|
try {
|
||||||
state.kernel = await api("/kernel");
|
state.kernel = await api("/kernel");
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
|
||||||
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
|
|
||||||
state.runtime = await api("/runtime");
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
try {
|
||||||
var s = await api("/settings");
|
var s = await api("/settings");
|
||||||
state.settings = s.settings || {};
|
state.settings = s.settings || {};
|
||||||
@ -748,7 +719,6 @@ async function refreshDataOnly() {
|
|||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
await loadDiscoveredGateway();
|
|
||||||
var d = await api("/device/online");
|
var d = await api("/device/online");
|
||||||
state.devices = (d && d.devices) || [];
|
state.devices = (d && d.devices) || [];
|
||||||
} else {
|
} else {
|
||||||
@ -805,10 +775,6 @@ async function refreshAll() {
|
|||||||
try {
|
try {
|
||||||
state.kernel = await api("/kernel");
|
state.kernel = await api("/kernel");
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
|
||||||
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
|
|
||||||
state.runtime = await api("/runtime");
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
try {
|
||||||
var s = await api("/settings");
|
var s = await api("/settings");
|
||||||
state.settings = s.settings || {};
|
state.settings = s.settings || {};
|
||||||
@ -833,7 +799,6 @@ async function refreshAll() {
|
|||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
await loadDiscoveredGateway();
|
|
||||||
var d = await api("/device/online");
|
var d = await api("/device/online");
|
||||||
state.devices = (d && d.devices) || [];
|
state.devices = (d && d.devices) || [];
|
||||||
} else {
|
} else {
|
||||||
@ -1039,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() {
|
function renderOverview() {
|
||||||
var s = state.status || {};
|
var s = state.status || {};
|
||||||
var k = state.kernel;
|
var k = state.kernel;
|
||||||
@ -1296,25 +1018,7 @@ function renderOverview() {
|
|||||||
"uptime",
|
"uptime",
|
||||||
) +
|
) +
|
||||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||||
statCard(
|
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||||
__("版本", "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",
|
|
||||||
) +
|
|
||||||
"</div>";
|
"</div>";
|
||||||
if (k) {
|
if (k) {
|
||||||
html +=
|
html +=
|
||||||
@ -1378,7 +1082,6 @@ function renderOverview() {
|
|||||||
"</span></div>" +
|
"</span></div>" +
|
||||||
"</div></div>";
|
"</div></div>";
|
||||||
}
|
}
|
||||||
html += renderRuntimePanel();
|
|
||||||
html +=
|
html +=
|
||||||
'<div class="card"><h2>' +
|
'<div class="card"><h2>' +
|
||||||
__("运行时", "Runtime") +
|
__("运行时", "Runtime") +
|
||||||
@ -1391,7 +1094,6 @@ function renderOverview() {
|
|||||||
) +
|
) +
|
||||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||||
"</div></div>";
|
"</div></div>";
|
||||||
html += renderLegalCard();
|
|
||||||
document.getElementById("view-overview").innerHTML = html;
|
document.getElementById("view-overview").innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5509,48 +5211,17 @@ async function connectFetchSSE(url) {
|
|||||||
var phase = p.phase || "";
|
var phase = p.phase || "";
|
||||||
var tool = p.tool || "";
|
var tool = p.tool || "";
|
||||||
if (p.channel !== "_consolidation_") {
|
if (p.channel !== "_consolidation_") {
|
||||||
// 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
|
if (phase === "pre_action")
|
||||||
// 与 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"),
|
|
||||||
);
|
|
||||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||||
} else if (phase === "before_toolcall") {
|
else if (phase === "before_toolcall") {
|
||||||
if (tool)
|
|
||||||
rtTrailPush(
|
|
||||||
2,
|
|
||||||
tool.indexOf("output_") === 0 ? "output" : "tool",
|
|
||||||
tool,
|
|
||||||
rtShortTool(tool),
|
|
||||||
);
|
|
||||||
if (tool) state.toolFlash = true;
|
|
||||||
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
||||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||||
if (!state.pendingTools) state.pendingTools = [];
|
if (!state.pendingTools) state.pendingTools = [];
|
||||||
state.pendingTools.push(tool);
|
state.pendingTools.push(tool);
|
||||||
rerenderChatIfActive();
|
rerenderChatIfActive();
|
||||||
}
|
}
|
||||||
} else if (phase === "before_output") {
|
} else if (phase === "before_output")
|
||||||
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
|
|
||||||
state.chatStage = __("生成回复中...", "Generating response...");
|
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");
|
var badge = document.getElementById("chat-stage");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
@ -5732,21 +5403,12 @@ function renderDevices() {
|
|||||||
}
|
}
|
||||||
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
||||||
var dbc = state.dbConfig || {};
|
var dbc = state.dbConfig || {};
|
||||||
// 网关地址优先用**服务端发现的权威值**(state.discoveredGateway),
|
var webuiUrl = "";
|
||||||
// 其次才是用户手填 / 本地推导。
|
|
||||||
//
|
|
||||||
// 为什么不能继续用「门户 URL 同 host 拼 /api/v1/device/ws」:
|
|
||||||
// 网关改造为子域反代后位于 devices.<基域名>,而**基域名与子域标签都是
|
|
||||||
// 服务端配置**,客户端无从得知。硬拼的结果是连到门户自己的路由上。
|
|
||||||
// 服务端 /api/v1/device/gateway 是唯一不会漂移的来源。
|
|
||||||
var webuiUrl = state.discoveredGateway || "";
|
|
||||||
if (
|
if (
|
||||||
!webuiUrl &&
|
|
||||||
state.currentConn &&
|
state.currentConn &&
|
||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
// 回退:老部署(无发现端点)仍按旧口径推导,保持向后兼容。
|
|
||||||
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
||||||
}
|
}
|
||||||
var curGateway = dbc.gateway || webuiUrl || "";
|
var curGateway = dbc.gateway || webuiUrl || "";
|
||||||
@ -5891,8 +5553,8 @@ function renderDevices() {
|
|||||||
html +=
|
html +=
|
||||||
'<p style="color:var(--text-muted)">' +
|
'<p style="color:var(--text-muted)">' +
|
||||||
__(
|
__(
|
||||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认经 HomeAgent 反代到 devices.<基域名>,或直连 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
||||||
"No devices yet. Devices connect via WebSocket (proxied by HomeAgent at devices.<base-domain>, or directly 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
"No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||||
) +
|
) +
|
||||||
"</p>";
|
"</p>";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2191,284 +2191,3 @@ td .switch {
|
|||||||
.kv-row .val .switch {
|
.kv-row .val .switch {
|
||||||
margin-right: 4px;
|
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,100 +0,0 @@
|
|||||||
// Command homed-kb-migrate 迁移存量知识库的目录名到规范名。
|
|
||||||
//
|
|
||||||
// 背景:旧版 Add 对名字**整串** sanitize、对路径**逐段** sanitize,
|
|
||||||
// 于是知识名(内存键 / LLM 可见的名字)与盘上目录从第一次落盘起就对不上。
|
|
||||||
// 典型残留:
|
|
||||||
//
|
|
||||||
// tech/_go_/note 分类段内的空格未被 TrimSpace 掉
|
|
||||||
// Tech/Upper 未小写化
|
|
||||||
// a/b with space 空格未替换成下划线
|
|
||||||
//
|
|
||||||
// 迁移把它们重命名到规范名,使三者一致。
|
|
||||||
//
|
|
||||||
// 安全设计:
|
|
||||||
// 1. **默认只报告**(-apply 才真改名)。批量 os.Rename 不可逆。
|
|
||||||
// 2. 检出目标名冲突则**整批拒绝**,不做部分迁移——半迁移状态比不迁移更难收拾。
|
|
||||||
// 3. 单条失败不中断整体,最后统一报告;执行前再查一次目标越界。
|
|
||||||
//
|
|
||||||
// 与 homed 启动时的关系:`homed` 启动会调同一个 PlanMigration 并**只报告**
|
|
||||||
// (见 cmd/homed/bootstrap.go 的 defaultApply)。本命令是人工确认后真正执行
|
|
||||||
// 的那一步。两者共用 internal/knowledge 里的同一份实现,避免口径漂移。
|
|
||||||
//
|
|
||||||
// 用法:
|
|
||||||
//
|
|
||||||
// homed-kb-migrate -root /data/homeagent/knowledge # 报告
|
|
||||||
// homed-kb-migrate -root /data/homeagent/knowledge -apply # 执行
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := flag.String("root", "", "知识库根目录(必填)")
|
|
||||||
apply := flag.Bool("apply", false, "真正执行重命名(缺省只报告)")
|
|
||||||
dryRun := flag.Bool("dry-run", false, "只报告(显式写法,与默认相同)")
|
|
||||||
limit := flag.Int("limit", 0, "单次最多改名条数,0 = 不限")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *root == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "错误:必须指定 -root <知识库根目录>")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
if *apply && *dryRun {
|
|
||||||
fmt.Fprintln(os.Stderr, "错误:-apply 与 -dry-run 互斥")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
abs, err := filepath.Abs(*root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "错误:%v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
abs = filepath.Clean(abs)
|
|
||||||
|
|
||||||
items, err := knowledge.PlanMigration(abs)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "错误:无法读取 %s:%v\n", abs, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
fmt.Printf("未发现任何知识条目(%s)\n", abs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var need, illegal int
|
|
||||||
for _, it := range items {
|
|
||||||
switch {
|
|
||||||
case it.Illegal:
|
|
||||||
illegal++
|
|
||||||
fmt.Printf(" [非法] %-40s 含 .. / 点段 / 隐藏段;写入与删除均已拒绝,需人工处理\n", it.OldName)
|
|
||||||
case it.NewName != "":
|
|
||||||
need++
|
|
||||||
fmt.Printf(" [迁移] %-40s → %s\n", it.OldName, it.NewName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n共 %d 条:需迁移 %d,已规范 %d,非法 %d\n",
|
|
||||||
len(items), need, len(items)-need-illegal, illegal)
|
|
||||||
|
|
||||||
if !*apply {
|
|
||||||
if need == 0 {
|
|
||||||
fmt.Println("\n无需迁移。加 -apply 不会改变任何东西。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("\n这是报告(未改动任何文件)。确认无误后加 -apply 执行。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, failed := knowledge.ApplyMigration(abs, items, *limit)
|
|
||||||
fmt.Printf("\n迁移完成:成功 %d,失败 %d\n", applied, failed)
|
|
||||||
if failed > 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "存在失败项。若为名称冲突,请先人工处理冲突的目录再重跑。")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,931 +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")
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
// 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发
|
|
||||||
// defer,而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
|
|
||||||
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
|
|
||||||
// 停机由调用点注册的 cleanup 负责(见下方返回值)。
|
|
||||||
distiller.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// initKnowledgeMigration 在知识库扫盘**之前**把存量目录名规范化。
|
|
||||||
//
|
|
||||||
// 为何不靠 Store 内部自己做:规范名是「内存键 + 盘上目录 + LLM 可见名字」
|
|
||||||
// 三者必须逐字一致,而磁盘重命名属于有破坏性的副作用,应该在 store 扫盘
|
|
||||||
// 之前、在明确的边界上一次性做完,而不是散在 Store 的初始化路径里。
|
|
||||||
//
|
|
||||||
// 为何默认只报告:os.Rename 不可逆,批量重命名生产数据必须由人确认。
|
|
||||||
// 需要真正迁移时用 homed-kb-migrate -apply(或把下面 defaultApply 打开)。
|
|
||||||
//
|
|
||||||
// 本函数体同样遵守 bootstrap 的平移原则。
|
|
||||||
func initKnowledgeMigration(cfg *types.Config) {
|
|
||||||
root := filepath.Join(cfg.Daemon.DataDir, "knowledge")
|
|
||||||
const (
|
|
||||||
// defaultApply = false ⇒ 启动时只扫描并报告,不改名。
|
|
||||||
defaultApply = false
|
|
||||||
// maxRenamePerRun 限制单次重命名数:给失控的目录规模设一个上限,
|
|
||||||
// 避免启动阶段被一次大迁移拖住。
|
|
||||||
maxRenamePerRun = 200
|
|
||||||
)
|
|
||||||
items, err := knowledge.PlanMigration(root)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[homed] 知识库迁移扫描失败(跳过): %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
need, illegal := 0, 0
|
|
||||||
for _, it := range items {
|
|
||||||
if it.Illegal {
|
|
||||||
illegal++
|
|
||||||
} else if it.NewName != "" {
|
|
||||||
need++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if need == 0 && illegal == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if illegal > 0 {
|
|
||||||
log.Printf("[homed] 知识库迁移:%d 条名称非法(含 .. / 点段 / 隐藏段),写入与删除均已拒绝,需人工处理", illegal)
|
|
||||||
}
|
|
||||||
if need == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[homed] 知识库迁移:%d/%d 条目录名待规范化(例:%s → %s)", need, len(items),
|
|
||||||
items[0].OldName, items[0].NewName)
|
|
||||||
if !defaultApply {
|
|
||||||
log.Printf("[homed] 知识库迁移:当前为只报告模式。确认清单后执行:homed-kb-migrate -root %s -apply", root)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if need > maxRenamePerRun {
|
|
||||||
log.Printf("[homed] 知识库迁移:需改名 %d 条超过单次上限 %d,本次只处理前 %d 条",
|
|
||||||
need, maxRenamePerRun, maxRenamePerRun)
|
|
||||||
}
|
|
||||||
applied, failed := knowledge.ApplyMigration(root, items, maxRenamePerRun)
|
|
||||||
log.Printf("[homed] 知识库迁移完成:成功 %d,失败 %d", applied, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
Offload: agentCore.OffloadOptions{
|
|
||||||
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
|
|
||||||
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
|
|
||||||
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
|
|
||||||
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
pluginReg.SetTerminalAPI(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)
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
|
|
||||||
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
|
|
||||||
//
|
|
||||||
// 历史 bug:main() 拆分时函数体内残留一句 `defer distiller.Stop()`,
|
|
||||||
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
|
|
||||||
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
|
|
||||||
// distillOnce,绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
|
|
||||||
// 真实调用方式断言,才照得出来。
|
|
||||||
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
|
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
st, cleanup := initMemoryStack(dir)
|
|
||||||
if st == nil || st.distiller == nil {
|
|
||||||
cleanup()
|
|
||||||
t.Fatal("initMemoryStack 未返回蒸馏器")
|
|
||||||
}
|
|
||||||
if st.db == nil {
|
|
||||||
cleanup()
|
|
||||||
t.Skip("图库未初始化,无法验证蒸馏接线")
|
|
||||||
}
|
|
||||||
if st.distiller.Stopped() {
|
|
||||||
cleanup()
|
|
||||||
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉(defer Stop 残留?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
|
|
||||||
cleanup()
|
|
||||||
if !st.distiller.Stopped() {
|
|
||||||
t.Fatal("cleanup 之后蒸馏器应已停止")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,188 +2,645 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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"
|
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"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
|
"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/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"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
||||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/kbtree"
|
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// main 是 worker 进程的启动序列。
|
|
||||||
//
|
|
||||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
|
||||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
|
||||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
|
||||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
|
||||||
// 因此释放顺序与拆分前完全一致。
|
|
||||||
func main() {
|
func main() {
|
||||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||||
// platform_windows.go。
|
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||||
requireSupportedPlatform()
|
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||||
|
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||||
opt := parseFlags()
|
flag.Parse()
|
||||||
|
|
||||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||||
if opt.role == "guard" {
|
if *role == "guard" {
|
||||||
runGuard(resolveDataDir(opt.dataDir))
|
runGuard(resolveDataDir(*dataDir))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||||
|
|
||||||
if opt.dataDir == "" {
|
if *dataDir == "" {
|
||||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
*dataDir = resolveDataDir(*dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opt.cliSocket == "" {
|
if *cliSocket == "" {
|
||||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
*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())
|
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)
|
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
|
||||||
defer closeMem()
|
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()
|
defer cfgReg.Close()
|
||||||
cfgReg.SeedDefaults(opt.dataDir)
|
cfgReg.SeedDefaults(*dataDir)
|
||||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
// 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()
|
cfg := cfgReg.ToConfig()
|
||||||
|
|
||||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
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)
|
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||||
defer closeLuaVM()
|
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(事件总线)
|
// 内核 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)
|
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||||
defer closeTextMem()
|
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())
|
ctx, stop := context.WithCancel(context.Background())
|
||||||
defer stop()
|
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()
|
provider := providerMgr.Default()
|
||||||
|
|
||||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||||
if opt.boot == "failback" {
|
if *boot == "failback" {
|
||||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 文档记忆 + 知识库 ----
|
// ========================================================================
|
||||||
|
// 文档记忆 + 知识库
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
docStore, closeDocStore := initDocStore(cfg)
|
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
|
||||||
defer closeDocStore()
|
if err := docStore.Start(); err != nil {
|
||||||
|
log.Printf("[homed] warning: document store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||||
defer closeMediaStore()
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
// ========================================================================
|
||||||
defer closeMultimodal()
|
// 人格设定
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
// 迁移必须在 store 扫盘**之前**:改名后扫盘一次到位,
|
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||||
// 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致。
|
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||||
initKnowledgeMigration(cfg)
|
if err != nil {
|
||||||
ks := initKnowledgeStore(cfg)
|
log.Printf("[homed] warning: load personality: %v", err)
|
||||||
|
}
|
||||||
|
if personality != nil && personality.Content != "" {
|
||||||
|
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 人格设定 ----
|
// ========================================================================
|
||||||
|
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
personality := loadPersonality(cfg, cfgReg)
|
stageHost := agentCore.NewStageHost()
|
||||||
|
|
||||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
pluginReg := plugin.NewRegistry()
|
||||||
|
pluginReg.SetIOManager(iom)
|
||||||
|
pluginReg.SetEventBus(evBus)
|
||||||
|
pluginReg.SetMemory(memDB)
|
||||||
|
pluginReg.SetTextMemory(textMem)
|
||||||
|
pluginReg.SetDocStore(docStore)
|
||||||
|
pluginReg.SetKnowledge(ks)
|
||||||
|
pluginReg.SetProviderManager(providerMgr)
|
||||||
|
pluginReg.SetConfigRegistry(cfgReg)
|
||||||
|
pluginReg.SetPluginDir(cfg.Plugin.Dir)
|
||||||
|
pluginReg.SetDataDir(*dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||||
|
|
||||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
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 Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
// ========================================================================
|
||||||
|
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
defaultPrompt := `你是 HomeAgent,一个持续运行的个人管家。
|
||||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
如需切换回复通道,使用 output_set_channel。
|
||||||
|
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||||
|
使用 output_list_channels 查看可用通道及其能力。
|
||||||
|
|
||||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||||
|
- 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,
|
||||||
|
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() 自注册工厂)
|
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||||
cli.DefaultSocket = opt.cliSocket
|
cli.DefaultSocket = *cliSocket
|
||||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
// 保留 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)
|
||||||
|
|
||||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||||
compactConfigDB(cfgReg)
|
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)
|
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||||
defer stopRuntime()
|
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)
|
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||||
defer closeIPC()
|
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",
|
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()))
|
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||||
|
|
||||||
// ---- 等待退出信号 ----
|
// ========================================================================
|
||||||
|
// 等待退出信号
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
sigCh := make(chan os.Signal, 1)
|
||||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
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)
|
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() {
|
func main() {
|
||||||
dataDir := flag.String("data", "", "data directory")
|
dataDir := flag.String("data", "", "data directory")
|
||||||
webuiUsername := flag.String("username", "admin", "webui username")
|
webuiUsername := flag.String("username", "admin", "webui username")
|
||||||
@ -63,24 +33,16 @@ func main() {
|
|||||||
|
|
||||||
dbPath := *dataDir + "/config.db"
|
dbPath := *dataDir + "/config.db"
|
||||||
db, err := sql.Open("sqlite3", dbPath)
|
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()
|
defer db.Close()
|
||||||
|
|
||||||
// 尽早验证数据库真的可用:sql.Open 是惰性的,不碰一次不会暴露驱动问题。
|
db.Exec("PRAGMA journal_mode=WAL")
|
||||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
|
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
|
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
pw := *webuiPassword
|
pw := *webuiPassword
|
||||||
if pw == "" {
|
if pw == "" {
|
||||||
@ -91,28 +53,13 @@ func main() {
|
|||||||
apiKey = randomSecret(16)
|
apiKey = randomSecret(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pt = "config_webui"
|
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 {
|
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt))
|
||||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
|
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
|
||||||
for _, kv := range [][2]string{
|
db.Exec(ws, "api_key", apiKey)
|
||||||
{"api_key", apiKey},
|
db.Exec(ws, "username", *webuiUsername)
|
||||||
{"username", *webuiUsername},
|
db.Exec(ws, "password", pw)
|
||||||
{"password", pw},
|
db.Exec(ws, "session_ttl_hours", "24")
|
||||||
{"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)
|
|
||||||
|
|
||||||
fmt.Printf("API_KEY=%s\n", apiKey)
|
fmt.Printf("API_KEY=%s\n", apiKey)
|
||||||
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)
|
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
|
|
||||||
//
|
|
||||||
// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住
|
|
||||||
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
|
|
||||||
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
|
|
||||||
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
|
|
||||||
//
|
|
||||||
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
|
|
||||||
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
|
|
||||||
//
|
|
||||||
// 用法(默认 dry-run,只列不删):
|
|
||||||
//
|
|
||||||
// memgc -db /home/newqqagent/memory/graph.db
|
|
||||||
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
|
|
||||||
//
|
|
||||||
// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
|
|
||||||
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
path := flag.String("db", "", "graph.db 路径(必填)")
|
|
||||||
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
|
|
||||||
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
|
|
||||||
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)")
|
|
||||||
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
|
|
||||||
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *path == "" {
|
|
||||||
flag.Usage()
|
|
||||||
log.Fatal("memgc: 必须指定 -db")
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := memory.NewGraphDB(*path)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: open %s: %v", *path, err)
|
|
||||||
}
|
|
||||||
defer g.Close()
|
|
||||||
|
|
||||||
if *sceneStats {
|
|
||||||
stats, err := g.SceneStats()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: scene stats: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("场景 %d 个:\n", len(stats))
|
|
||||||
for _, st := range stats {
|
|
||||||
fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
|
|
||||||
st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
|
|
||||||
st.UpdatedAt.Format("2006-01-02 15:04"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
|
|
||||||
// 没有任何场景引用,不补挂就永远吃不到场景召回。
|
|
||||||
if *tagScene != "" {
|
|
||||||
if *entityGlob == "" {
|
|
||||||
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测")
|
|
||||||
}
|
|
||||||
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: tag scene: %v", err)
|
|
||||||
}
|
|
||||||
if *apply {
|
|
||||||
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
junk, err := g.NoiseEntities()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: scan: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("噪音实体 %d 个:\n", len(junk))
|
|
||||||
for _, e := range junk {
|
|
||||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
de, dr, err := g.PurgeNoise(!*apply)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: purge: %v", err)
|
|
||||||
}
|
|
||||||
if *apply {
|
|
||||||
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *orphans {
|
|
||||||
list, err := g.OrphanEntities()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: orphans: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
|
|
||||||
for _, e := range list {
|
|
||||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
|
||||||
}
|
|
||||||
n, err := g.PurgeOrphans(!*apply)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("memgc: purge orphans: %v", err)
|
|
||||||
}
|
|
||||||
if *apply {
|
|
||||||
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if *apply {
|
|
||||||
fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,8 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"bundleName": "com.example.homeagent",
|
"bundleName": "com.example.homeagent",
|
||||||
"vendor": "HomeAgent",
|
"vendor": "HomeAgent",
|
||||||
"versionCode": 1004000,
|
"versionCode": 1000000,
|
||||||
"versionName": "1.4.0",
|
"versionName": "1.0.0",
|
||||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
|
|||||||
@ -32,10 +32,6 @@ export class ApiClient {
|
|||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearConnection(): void {
|
|
||||||
this.conn = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getConnection(): ConnectionConfig | null {
|
getConnection(): ConnectionConfig | null {
|
||||||
return this.conn;
|
return this.conn;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import { bundleManager } from '@kit.AbilityKit';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
|
|
||||||
*
|
|
||||||
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
|
|
||||||
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
|
|
||||||
* 实测已经漂过:app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
|
|
||||||
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
|
|
||||||
*/
|
|
||||||
export function appVersion(): string {
|
|
||||||
try {
|
|
||||||
const info: bundleManager.BundleInfo =
|
|
||||||
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
|
|
||||||
return info.versionName;
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,71 +4,16 @@ import { pasteboard } from '@kit.BasicServicesKit';
|
|||||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||||
import { componentSnapshot } from '@kit.ArkUI';
|
import { componentSnapshot } from '@kit.ArkUI';
|
||||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { camera, cameraPicker } from '@kit.CameraKit';
|
|
||||||
import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
||||||
import { appVersion } from './AppVersion';
|
|
||||||
|
|
||||||
// ===== 能力结果 =====
|
// ===== 能力结果 =====
|
||||||
|
|
||||||
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
|
|
||||||
export const LOCAL_DEVICE_CAPS: string[] = [
|
|
||||||
'status',
|
|
||||||
'deviceinfo',
|
|
||||||
'screensee',
|
|
||||||
'screensue',
|
|
||||||
'clipboardsee',
|
|
||||||
'clipboardsue',
|
|
||||||
'speakeruse',
|
|
||||||
'camerasue',
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface CapResult {
|
export interface CapResult {
|
||||||
status: string; // 'ok' | 'error'
|
status: string; // 'ok' | 'error'
|
||||||
output: string;
|
output: string;
|
||||||
error: string;
|
error: string;
|
||||||
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
|
|
||||||
// DeviceBridge 不要再发 cmd_result;否则网关会把后续分块挂在一条已完成的
|
|
||||||
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
|
|
||||||
chunked?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function okResult(output: string): CapResult {
|
||||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||||
return r;
|
return r;
|
||||||
@ -79,19 +24,6 @@ function errResult(errMsg: string): CapResult {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId),
|
|
||||||
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
|
|
||||||
* 而 import BridgeCaps,两边成环。分层也更干净:能力实现不碰 socket。
|
|
||||||
*/
|
|
||||||
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
|
|
||||||
|
|
||||||
// chunkedResult:结果已由能力自己分块发出,不再回 cmd_result。
|
|
||||||
function chunkedResult(output: string): CapResult {
|
|
||||||
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||||
|
|
||||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||||
@ -112,215 +44,53 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
|||||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||||
*/
|
*/
|
||||||
export async function capScreensee(): Promise<CapResult> {
|
export async function capScreensee(): Promise<CapResult> {
|
||||||
let full: image.PixelMap | null = null;
|
|
||||||
let packer: image.ImagePacker | null = null;
|
|
||||||
try {
|
try {
|
||||||
full = await captureScreenPixelMap();
|
const full: image.PixelMap = await captureScreenPixelMap();
|
||||||
const info: image.ImageInfo = await full.getImageInfo();
|
const info: image.ImageInfo = await full.getImageInfo();
|
||||||
const maxW: number = 420;
|
const maxW: number = 720;
|
||||||
const maxH: number = 640;
|
let targetW: number = info.size.width;
|
||||||
let scale: number = 1;
|
let targetH: number = info.size.height;
|
||||||
if (info.size.width > maxW) {
|
if (targetW > maxW) {
|
||||||
scale = maxW / info.size.width;
|
targetH = Math.floor(targetH * maxW / targetW);
|
||||||
|
targetW = maxW;
|
||||||
}
|
}
|
||||||
if (info.size.height * scale > maxH) {
|
let packed: ArrayBuffer;
|
||||||
scale = maxH / info.size.height;
|
if (targetW !== info.size.width) {
|
||||||
|
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
||||||
}
|
}
|
||||||
if (scale < 1) {
|
const packer: image.ImagePacker = image.createImagePacker();
|
||||||
await full.scale(scale, scale);
|
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
||||||
}
|
packed = await packer.packing(full, opt);
|
||||||
packer = image.createImagePacker();
|
packer.release();
|
||||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
|
full.release();
|
||||||
const packed: ArrayBuffer = await packer.packing(full, opt);
|
|
||||||
const helper: util.Base64Helper = new util.Base64Helper();
|
const helper: util.Base64Helper = new util.Base64Helper();
|
||||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||||
if (b64.length > 950000) {
|
|
||||||
return errResult('当前画面数据过大,请稍后重试');
|
|
||||||
}
|
|
||||||
return okResult('data:image/jpeg;base64,' + b64);
|
return okResult('data:image/jpeg;base64,' + b64);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
|
const msg: string = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
return errResult('screensee failed: ' + msg);
|
||||||
if (packer !== null) {
|
|
||||||
packer.release();
|
|
||||||
}
|
|
||||||
if (full !== null) {
|
|
||||||
full.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== camerasue:系统相机抓拍 =====
|
|
||||||
//
|
|
||||||
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
|
|
||||||
// (CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
|
|
||||||
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
|
|
||||||
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
|
|
||||||
//
|
|
||||||
// 结果落在应用沙箱(saveUri 指向 filesDir),不写系统媒体库,也就不需要
|
|
||||||
// READ_IMAGEVIDEO 这类受限权限。
|
|
||||||
const CAMERASUE_MAX_B64: number = 950000;
|
|
||||||
|
|
||||||
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
|
|
||||||
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* camerasue 实现。
|
|
||||||
*
|
|
||||||
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
|
|
||||||
* `<N秒>` = 录 N 秒视频。
|
|
||||||
*
|
|
||||||
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MB,base64 后还要再膨胀
|
|
||||||
* 1/3,既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
|
|
||||||
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
|
|
||||||
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件,agent 拿路径。
|
|
||||||
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
|
|
||||||
*/
|
|
||||||
export async function capCamerasue(context: common.UIAbilityContext,
|
|
||||||
rawArgs: string,
|
|
||||||
sendChunked: DataChunkSender | null): Promise<CapResult> {
|
|
||||||
const raw: string = rawArgs.trim();
|
|
||||||
let videoSeconds: number = 0;
|
|
||||||
if (raw.length > 0) {
|
|
||||||
const digits: RegExp = new RegExp('^\\d+$');
|
|
||||||
if (!digits.test(raw)) {
|
|
||||||
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
|
|
||||||
}
|
|
||||||
videoSeconds = parseInt(raw, 10);
|
|
||||||
if (videoSeconds <= 0 || videoSeconds > 300) {
|
|
||||||
return errResult('录像时长需在 1~300 秒之间');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isVideo: boolean = videoSeconds > 0;
|
|
||||||
|
|
||||||
const ext: string = isVideo ? '.mp4' : '.jpg';
|
|
||||||
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
|
|
||||||
try {
|
|
||||||
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
|
|
||||||
const f: fileIo.File = fileIo.openSync(filePath,
|
|
||||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
|
|
||||||
fileIo.closeSync(f);
|
|
||||||
} catch (e) {
|
|
||||||
return errResult('无法创建相机输出文件');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profile: cameraPicker.PickerProfile = {
|
|
||||||
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
|
|
||||||
saveUri: fileUri.getUriFromPath(filePath),
|
|
||||||
};
|
|
||||||
if (isVideo) {
|
|
||||||
profile.videoDuration = videoSeconds;
|
|
||||||
}
|
|
||||||
const mediaType: cameraPicker.PickerMediaType = isVideo
|
|
||||||
? cameraPicker.PickerMediaType.VIDEO
|
|
||||||
: cameraPicker.PickerMediaType.PHOTO;
|
|
||||||
const res: cameraPicker.PickerResult =
|
|
||||||
await cameraPicker.pick(context, [mediaType], profile);
|
|
||||||
if (res.resultCode !== 0 || res.resultUri.length === 0) {
|
|
||||||
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVideo) {
|
|
||||||
return readAndSendVideo(filePath, sendChunked);
|
|
||||||
}
|
|
||||||
return readPhotoAsBase64(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读回录像并以二进制分块回传;网关聚合后落盘,agent 拿文件路径。 */
|
|
||||||
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
|
|
||||||
let fd: number = -1;
|
|
||||||
try {
|
|
||||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
|
||||||
if (stat.size <= 0) {
|
|
||||||
return errResult('录像文件为空,请重试');
|
|
||||||
}
|
|
||||||
if (stat.size > CAMERASUE_MAX_VIDEO) {
|
|
||||||
return errResult('录像文件过大(超过 64MB),请缩短时长');
|
|
||||||
}
|
|
||||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
|
||||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
|
||||||
fd = rf.fd;
|
|
||||||
fileIo.readSync(fd, buf);
|
|
||||||
fileIo.closeSync(rf);
|
|
||||||
fd = -1;
|
|
||||||
if (sendChunked === null) {
|
|
||||||
return errResult('录像回传通道未就绪,请重试');
|
|
||||||
}
|
|
||||||
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
|
|
||||||
// 分块已代表本次请求的完整结果,DeviceBridge 不再回 cmd_result。
|
|
||||||
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
|
|
||||||
} catch (e) {
|
|
||||||
if (fd >= 0) {
|
|
||||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
|
||||||
}
|
|
||||||
return errResult('录像读取失败,请重试');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
|
|
||||||
function readPhotoAsBase64(filePath: string): CapResult {
|
|
||||||
let fd: number = -1;
|
|
||||||
try {
|
|
||||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
|
||||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
|
||||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
|
||||||
fd = rf.fd;
|
|
||||||
fileIo.readSync(fd, buf);
|
|
||||||
fileIo.closeSync(rf);
|
|
||||||
fd = -1;
|
|
||||||
const helper: util.Base64Helper = new util.Base64Helper();
|
|
||||||
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
|
|
||||||
if (b64.length > CAMERASUE_MAX_B64) {
|
|
||||||
return errResult('照片数据过大,请降低分辨率后重试');
|
|
||||||
}
|
|
||||||
return okResult('data:image/jpeg;base64,' + b64);
|
|
||||||
} catch (e) {
|
|
||||||
if (fd >= 0) {
|
|
||||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
|
||||||
}
|
|
||||||
return errResult('照片读取失败,请重试');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== clipboardsee / clipboardsue =====
|
// ===== 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> {
|
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||||
const granted: boolean = await ensureClipboardPermission(context);
|
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
||||||
if (!granted) {
|
// 这里直接尝试读取;系统拒绝时回错误信息。
|
||||||
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||||
const has: boolean = await clip.hasData();
|
const has: boolean = await clip.hasData();
|
||||||
if (!has) {
|
if (!has) {
|
||||||
return okResult('');
|
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
||||||
|
return empty;
|
||||||
}
|
}
|
||||||
const data: pasteboard.PasteData = await clip.getData();
|
const data: pasteboard.PasteData = await clip.getData();
|
||||||
const txt: string = data.getPrimaryText();
|
const txt: string = data.getPrimaryText();
|
||||||
return okResult(txt ?? '');
|
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
||||||
|
return out;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
|
const msg: string = e instanceof Error ? e.message : String(e);
|
||||||
|
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,9 +99,10 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
|
|||||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||||
await clip.setPasteData(data);
|
await clip.setPasteData(data);
|
||||||
return okResult('clipboard written');
|
return okResult('written ' + text.length + ' chars');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errResult('剪贴板写入失败,请保持应用在前台后重试');
|
const msg: string = e instanceof Error ? e.message : String(e);
|
||||||
|
return errResult('clipboardsue failed: ' + msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,11 +112,13 @@ class TtsSession {
|
|||||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||||
|
|
||||||
async speak(text: string): Promise<CapResult> {
|
async speak(text: string): Promise<CapResult> {
|
||||||
if (text.length > 4000) {
|
|
||||||
return errResult('朗读内容过长,请缩短到 4000 字以内');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (this.engine === null) {
|
if (this.engine === null) {
|
||||||
|
const extra: Record<string, Object> = {
|
||||||
|
'style': 'interaction-broadcast',
|
||||||
|
'locate': 'CN',
|
||||||
|
'name': 'EngineName',
|
||||||
|
};
|
||||||
const params: textToSpeech.CreateEngineParams = {
|
const params: textToSpeech.CreateEngineParams = {
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
person: 0,
|
person: 0,
|
||||||
@ -360,7 +133,8 @@ class TtsSession {
|
|||||||
this.engine.speak(text, sp);
|
this.engine.speak(text, sp);
|
||||||
return okResult('speaking');
|
return okResult('speaking');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errResult('语音服务暂时不可用,请稍后重试');
|
const msg: string = e instanceof Error ? e.message : String(e);
|
||||||
|
return errResult('speakeruse failed: ' + msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,47 +156,40 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
|
|||||||
return ttsSession.speak(text);
|
return ttsSession.speak(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownSpeakerUse(): void {
|
// ===== deviceinfo =====
|
||||||
ttsSession.shutdown();
|
|
||||||
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== status / deviceinfo =====
|
// ===== screensue 内容解析 =====
|
||||||
|
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||||
|
|
||||||
export function capStatus(deviceId: string): CapResult {
|
export interface ScreensuePayload {
|
||||||
const payload: DeviceStatusPayload = {
|
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||||
device_id: deviceId,
|
content: string;
|
||||||
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 {
|
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||||
const details: DeviceDetails = {
|
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||||
hostname: 'ohos-phone',
|
let rest: string = rawArgs.trim();
|
||||||
platform: 'OpenHarmony',
|
const tokens: string[] = rest.split(/\s+/);
|
||||||
arch: deviceInfo.abiList,
|
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||||||
os_release: deviceInfo.osFullName,
|
p.duration = parseInt(tokens[0], 10);
|
||||||
version: appVersion(),
|
rest = tokens.slice(1).join(' ');
|
||||||
cpus: 0,
|
} else {
|
||||||
brand: deviceInfo.brand,
|
rest = tokens.join(' ');
|
||||||
manufacturer: deviceInfo.manufacture,
|
}
|
||||||
model: deviceInfo.productModel,
|
p.content = rest.trim();
|
||||||
series: deviceInfo.productSeries,
|
return p;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
|
||||||
*
|
|
||||||
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
|
|
||||||
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
|
|
||||||
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
|
|
||||||
*
|
|
||||||
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
|
|
||||||
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CapResult } from './BridgeCaps';
|
|
||||||
import { appVersion } from './AppVersion';
|
|
||||||
|
|
||||||
// ===== 协议消息(与 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: appVersion(),
|
|
||||||
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,20 +1,17 @@
|
|||||||
import { deviceBridge } from './DeviceBridge';
|
import { deviceBridge, CmdReply } from './DeviceBridge';
|
||||||
import {
|
import {
|
||||||
CapResult,
|
CapResult,
|
||||||
DataChunkSender,
|
|
||||||
capScreensee,
|
capScreensee,
|
||||||
capCamerasue,
|
|
||||||
capClipboardSee,
|
capClipboardSee,
|
||||||
capClipboardsue,
|
capClipboardsue,
|
||||||
capSpeakerUse,
|
capSpeakerUse,
|
||||||
capDeviceInfo,
|
capDeviceInfo,
|
||||||
capStatus,
|
parseScreensue,
|
||||||
|
ScreensuePayload,
|
||||||
} from './BridgeCaps';
|
} from './BridgeCaps';
|
||||||
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
|
|
||||||
import { connStore } from './ConnStore';
|
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
|
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
||||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||||
|
|
||||||
let screensueHandler: ScreensueHandler | null = null;
|
let screensueHandler: ScreensueHandler | null = null;
|
||||||
@ -28,23 +25,10 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
|||||||
appContext = ctx;
|
appContext = ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
|
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
|
||||||
function splitCapability(command: string): string[] {
|
function splitCapability(command: string): string[] {
|
||||||
let start: number = 0;
|
const cmd: string = command.trim();
|
||||||
while (start < command.length && isCommandSpace(command.charAt(start))) {
|
const idx: number = cmd.indexOf(' ');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
return [cmd];
|
return [cmd];
|
||||||
}
|
}
|
||||||
@ -52,84 +36,51 @@ function splitCapability(command: string): string[] {
|
|||||||
return out;
|
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> {
|
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||||
const parts: string[] = splitCapability(command);
|
const parts: string[] = splitCapability(command);
|
||||||
const name: string = parts[0];
|
const name: string = parts[0];
|
||||||
const args: string = parts.length > 1 ? parts[1] : '';
|
const args: string = parts.length > 1 ? parts[1] : '';
|
||||||
|
|
||||||
|
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
||||||
if (name === 'screensee') {
|
if (name === 'screensee') {
|
||||||
if (hasArgs(args)) {
|
|
||||||
return errRes('screensee 不接受额外参数');
|
|
||||||
}
|
|
||||||
return capScreensee();
|
return capScreensee();
|
||||||
}
|
}
|
||||||
if (name === 'screensue') {
|
if (name === 'screensue') {
|
||||||
if (!hasArgs(args)) {
|
|
||||||
return errRes('screensue 需要展示内容');
|
|
||||||
}
|
|
||||||
const payload: ScreensuePayload = parseScreensue(args);
|
const payload: ScreensuePayload = parseScreensue(args);
|
||||||
if (payload.content.length === 0) {
|
|
||||||
return errRes('screensue 需要展示内容');
|
|
||||||
}
|
|
||||||
if (screensueHandler !== null) {
|
if (screensueHandler !== null) {
|
||||||
screensueHandler(payload);
|
screensueHandler(payload);
|
||||||
return okRes('内容已显示');
|
return okRes('shown');
|
||||||
}
|
}
|
||||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
return errRes('screensue: display layer not ready');
|
||||||
}
|
|
||||||
if (name === 'camerasue') {
|
|
||||||
if (appContext === null) {
|
|
||||||
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
|
|
||||||
}
|
|
||||||
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
|
|
||||||
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
|
|
||||||
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
|
|
||||||
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
|
|
||||||
};
|
|
||||||
return capCamerasue(appContext, args, sender);
|
|
||||||
}
|
}
|
||||||
if (name === 'clipboardsee') {
|
if (name === 'clipboardsee') {
|
||||||
if (hasArgs(args)) {
|
|
||||||
return errRes('clipboardsee 不接受额外参数');
|
|
||||||
}
|
|
||||||
if (appContext === null) {
|
if (appContext === null) {
|
||||||
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
|
return errRes('clipboardsee: app context missing');
|
||||||
}
|
}
|
||||||
return capClipboardSee(appContext);
|
return capClipboardSee(appContext);
|
||||||
}
|
}
|
||||||
if (name === 'clipboardsue') {
|
if (name === 'clipboardsue') {
|
||||||
if (!hasArgs(args)) {
|
if (args.length === 0) {
|
||||||
return errRes('clipboardsue 需要写入文字');
|
return errRes('clipboardsue: empty text');
|
||||||
}
|
}
|
||||||
return capClipboardsue(args);
|
return capClipboardsue(args);
|
||||||
}
|
}
|
||||||
if (name === 'speakeruse') {
|
if (name === 'speakeruse') {
|
||||||
if (!hasArgs(args)) {
|
if (args.length === 0) {
|
||||||
return errRes('speakeruse 需要朗读文字');
|
return errRes('speakeruse: empty text');
|
||||||
}
|
}
|
||||||
return capSpeakerUse(args);
|
return capSpeakerUse(args);
|
||||||
}
|
}
|
||||||
if (name === 'status') {
|
if (name === 'deviceinfo' || name === 'status') {
|
||||||
if (hasArgs(args)) {
|
return capDeviceInfo();
|
||||||
return errRes('status 不接受额外参数');
|
|
||||||
}
|
|
||||||
return capStatus(connStore.getDeviceId());
|
|
||||||
}
|
}
|
||||||
if (name === 'deviceinfo') {
|
if (name === 'camerasue') {
|
||||||
if (hasArgs(args)) {
|
return errRes('camerasue: camera capture not supported on this build');
|
||||||
return errRes('deviceinfo 不接受额外参数');
|
|
||||||
}
|
|
||||||
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
|
|
||||||
}
|
}
|
||||||
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 {
|
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,119 +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;
|
|
||||||
/**
|
|
||||||
* 服务端下发的增量游标(响应里的 last_seq)。
|
|
||||||
*
|
|
||||||
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
|
|
||||||
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
|
|
||||||
* (jianf 说的“暴露数据查询 api,前端轮询后 patch 视图”那条路)。
|
|
||||||
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
|
|
||||||
*/
|
|
||||||
lastSeq: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析后端 /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, lastSeq: 0 };
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
// seq:服务端单调递增序号,增量游标与 keyed 对账的定位符。
|
|
||||||
// 缺失(旧后端/本地乐观消息)时保持 undefined,不编造。
|
|
||||||
const seqVal: Object | undefined = item['seq'];
|
|
||||||
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
|
|
||||||
msg.seq = seqVal as number;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
// last_seq:增量游标。缺失时回退到本页最大 seq,保证游标不会倒退。
|
|
||||||
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
|
|
||||||
for (let i = 0; i < msgs.length; i++) {
|
|
||||||
const sq: number | undefined = msgs[i].seq;
|
|
||||||
if (sq !== undefined && sq > lastSeq) {
|
|
||||||
lastSeq = sq;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 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,206 +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/interrupt 的请求体。 */
|
|
||||||
interface InterruptBody {
|
|
||||||
/** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */
|
|
||||||
stop: boolean;
|
|
||||||
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
|
|
||||||
message?: 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止当前生成(停止按钮)。
|
|
||||||
*
|
|
||||||
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
|
|
||||||
* - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试);
|
|
||||||
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
|
|
||||||
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
|
|
||||||
*
|
|
||||||
* 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成
|
|
||||||
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
|
|
||||||
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
|
|
||||||
*/
|
|
||||||
export async function interruptChat(): Promise<void> {
|
|
||||||
if (connStore.getCurrentConnection() === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body: InterruptBody = { stop: true };
|
|
||||||
await apiClient.post('/chat/interrupt', body);
|
|
||||||
} catch (e) {
|
|
||||||
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
|
|
||||||
// 否则按钮会一直停在“停止”态,用户以为没生效。
|
|
||||||
}
|
|
||||||
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
|
|
||||||
// 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。
|
|
||||||
chatStore.setLoading(false);
|
|
||||||
chatStore.setStage('已停止');
|
|
||||||
chatStore.forceRefresh();
|
|
||||||
}
|
|
||||||
@ -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,602 +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;
|
|
||||||
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致(3s)。 */
|
|
||||||
const CHAT_POLL_MS: number = 3000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
|
|
||||||
* applyServerMessages 同口径)。
|
|
||||||
*
|
|
||||||
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
|
|
||||||
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
|
|
||||||
* 其中一条误判成重复而丢弃(“App 发出的消息不显示”就是这个表现)。
|
|
||||||
* seq 是服务端分配的唯一序号,才是可靠的定位符。
|
|
||||||
*
|
|
||||||
* 规则:
|
|
||||||
* - 服务端消息带 seq:本地已有同 seq → 原地更新(工具卡/最终文本是
|
|
||||||
* 原地改的,不产生新 seq,只靠 after 拿不到,必须靠尾部探测更新);
|
|
||||||
* 本地没有 → 追加。
|
|
||||||
* - 服务端消息无 seq(旧后端):退化为「本地末尾同角色同内容则认领」。
|
|
||||||
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq),
|
|
||||||
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
|
|
||||||
*
|
|
||||||
* tailOnly:只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
|
|
||||||
* 避免把历史中间的消息插进来造成顺序错乱。
|
|
||||||
*/
|
|
||||||
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
|
|
||||||
tailOnly: boolean, alloc: () => number): ChatMessage[] {
|
|
||||||
const out: ChatMessage[] = local.slice();
|
|
||||||
for (let i = 0; i < incoming.length; i++) {
|
|
||||||
const sm: ChatMessage = incoming[i];
|
|
||||||
const sq: number | undefined = sm.seq;
|
|
||||||
let found: number = -1;
|
|
||||||
if (sq !== undefined) {
|
|
||||||
// 从尾部往前找:新消息总在尾部,省掉全表扫描
|
|
||||||
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
|
|
||||||
if (out[j].seq === sq) {
|
|
||||||
found = j;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found >= 0) {
|
|
||||||
// 原地更新:保留本地 id(组件按 id 复用,不重建气泡),
|
|
||||||
// 只覆盖服务端权威字段。
|
|
||||||
const prev: ChatMessage = out[found];
|
|
||||||
if (prev.content !== sm.content) {
|
|
||||||
prev.content = sm.content;
|
|
||||||
}
|
|
||||||
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
|
|
||||||
prev.reasoningContent = sm.reasoningContent;
|
|
||||||
}
|
|
||||||
if (sm.toolCalls !== undefined) {
|
|
||||||
prev.toolCalls = sm.toolCalls;
|
|
||||||
}
|
|
||||||
if (sm.attachment !== undefined) {
|
|
||||||
prev.attachment = sm.attachment;
|
|
||||||
}
|
|
||||||
if (sm.source !== undefined) {
|
|
||||||
prev.source = sm.source;
|
|
||||||
}
|
|
||||||
prev.isFinal = true;
|
|
||||||
prev.isStreaming = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tailOnly) {
|
|
||||||
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
|
|
||||||
let maxLocalSeq: number = 0;
|
|
||||||
for (let j = 0; j < out.length; j++) {
|
|
||||||
const ls: number | undefined = out[j].seq;
|
|
||||||
if (ls !== undefined && ls > maxLocalSeq) {
|
|
||||||
maxLocalSeq = ls;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sq !== undefined && sq > maxLocalSeq) {
|
|
||||||
sm.id = alloc();
|
|
||||||
out.push(sm);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
|
|
||||||
if (sq !== undefined) {
|
|
||||||
let claimed: number = -1;
|
|
||||||
for (let j = out.length - 1; j >= 0; j--) {
|
|
||||||
const lm: ChatMessage = out[j];
|
|
||||||
if (lm.seq !== undefined) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (lm.role === sm.role) {
|
|
||||||
claimed = j;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (claimed >= 0) {
|
|
||||||
const prev: ChatMessage = out[claimed];
|
|
||||||
prev.seq = sq;
|
|
||||||
prev.isFinal = true;
|
|
||||||
prev.isStreaming = false;
|
|
||||||
if (sm.source !== undefined) {
|
|
||||||
prev.source = sm.source;
|
|
||||||
}
|
|
||||||
if (sm.attachment !== undefined) {
|
|
||||||
prev.attachment = sm.attachment;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sm.id = alloc();
|
|
||||||
out.push(sm);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
/** 增量游标:本地已知的最大服务端 seq(对应 WebUI 的 state.chatLastSeq) */
|
|
||||||
private lastSeq: number = 0;
|
|
||||||
/** 增量轮询中进行中,避免重入 */
|
|
||||||
private polling: boolean = false;
|
|
||||||
private pollTimer: number = -1;
|
|
||||||
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);
|
|
||||||
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
|
|
||||||
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
|
|
||||||
if (this.msgs.length === 0) {
|
|
||||||
this.msgs = parsed.msgs;
|
|
||||||
} else {
|
|
||||||
// 与本地未回显的消息按 seq 对账,而不是整表替换。
|
|
||||||
//
|
|
||||||
// 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
|
|
||||||
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
|
|
||||||
// (“客户端侧发出的消息不显示”)。
|
|
||||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
|
||||||
() => this.allocId());
|
|
||||||
}
|
|
||||||
this.offset = parsed.offset;
|
|
||||||
this.hasEarlier = parsed.hasMore;
|
|
||||||
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
|
|
||||||
if (parsed.lastSeq > this.lastSeq) {
|
|
||||||
this.lastSeq = parsed.lastSeq;
|
|
||||||
}
|
|
||||||
this.forceRefresh();
|
|
||||||
this.requestScroll();
|
|
||||||
this.startPolling();
|
|
||||||
} catch (e) {
|
|
||||||
// ignore history load failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
|
|
||||||
*
|
|
||||||
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
|
|
||||||
* after=<seq> 游标(commit 9711177),WebUI 前端据此 3s 轮询增量并 patch
|
|
||||||
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
|
|
||||||
* 永远进不来**(页面不会加载新的聊天信息)。
|
|
||||||
*
|
|
||||||
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
|
|
||||||
* 不会产生新 seq,单靠 after 拿不到这些更新。
|
|
||||||
*/
|
|
||||||
async pollIncremental(): Promise<void> {
|
|
||||||
if (this.polling) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.polling = true;
|
|
||||||
try {
|
|
||||||
if (this.lastSeq <= 0) {
|
|
||||||
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
|
|
||||||
this.polling = false;
|
|
||||||
await this.loadHistory();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await apiClient.getWithTimeout(
|
|
||||||
'/chat/history?after=' + this.lastSeq, 8000);
|
|
||||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
|
||||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
|
||||||
let changed: boolean = false;
|
|
||||||
if (parsed.msgs.length > 0) {
|
|
||||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
|
||||||
() => this.allocId());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (parsed.lastSeq > this.lastSeq) {
|
|
||||||
this.lastSeq = parsed.lastSeq;
|
|
||||||
}
|
|
||||||
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
|
|
||||||
try {
|
|
||||||
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
|
|
||||||
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
|
|
||||||
const tail: ParsedHistory = this.parseHistory(tailObj);
|
|
||||||
if (tail.msgs.length > 0) {
|
|
||||||
const before: number = this.msgs.length;
|
|
||||||
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
|
|
||||||
() => this.allocId());
|
|
||||||
if (this.msgs.length !== before) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 尾部探测失败不影响增量结果
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
this.forceRefresh();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 轮询失败静默:下一拍会重试(SSE 仍在负责流式渲染)
|
|
||||||
} finally {
|
|
||||||
this.polling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
|
|
||||||
startPolling(): void {
|
|
||||||
if (this.pollTimer >= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.pollTimer = setInterval(() => {
|
|
||||||
this.pollIncremental();
|
|
||||||
}, CHAT_POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPolling(): void {
|
|
||||||
if (this.pollTimer >= 0) {
|
|
||||||
clearInterval(this.pollTimer);
|
|
||||||
this.pollTimer = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 向上翻页:拉 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;
|
|
||||||
}
|
|
||||||
// 增量轮询与 SSE 同时拉起:SSE 负责 token 级流式观感,
|
|
||||||
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
|
|
||||||
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
|
|
||||||
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
|
|
||||||
this.startPolling();
|
|
||||||
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.stopPolling();
|
|
||||||
this.sse.close();
|
|
||||||
this.cancelRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatStore: ChatStore = new ChatStore();
|
|
||||||
@ -17,18 +17,6 @@ export const DEFAULT_WS_PORT: number = 9890;
|
|||||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||||
export const CHAT_PAGE_SIZE: number = 40;
|
export const CHAT_PAGE_SIZE: number = 40;
|
||||||
|
|
||||||
// ===== AppStorage 跨页面信号键 =====
|
|
||||||
//
|
|
||||||
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
|
|
||||||
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
|
|
||||||
// 中间还隔着 Index,逐层传回调会把两个无关页面耦在一起。
|
|
||||||
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
|
|
||||||
export const K_HAS_CONN: string = 'hasConn';
|
|
||||||
/** 外部请求切换主 Tab(-1 = 无请求),由 Index 监听 */
|
|
||||||
export const K_REQUESTED_TAB: string = 'requestedTab';
|
|
||||||
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
|
|
||||||
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
|
|
||||||
|
|
||||||
// ===== sakura / frost palette (style.css :root) =====
|
// ===== sakura / frost palette (style.css :root) =====
|
||||||
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
||||||
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
||||||
|
|||||||
@ -1,8 +1,75 @@
|
|||||||
import { webSocket } from '@kit.NetworkKit';
|
import { webSocket } from '@kit.NetworkKit';
|
||||||
|
import { DeviceInfo } from '../model/Model';
|
||||||
import { CapResult } from './BridgeCaps';
|
import { CapResult } from './BridgeCaps';
|
||||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
|
||||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||||
bridgeChunkSlices } from './BridgeProtocol';
|
|
||||||
|
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 {
|
export class DeviceBridgeClient {
|
||||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||||
@ -14,15 +81,14 @@ export class DeviceBridgeClient {
|
|||||||
private caps: string[] = [];
|
private caps: string[] = [];
|
||||||
private hostname: string = 'ohos';
|
private hostname: string = 'ohos';
|
||||||
private connected: boolean = false;
|
private connected: boolean = false;
|
||||||
private bound: boolean = false;
|
private everConnected: boolean = false;
|
||||||
private manualClose: boolean = false;
|
private manualClose: boolean = false;
|
||||||
private reconnectTimer: number = -1;
|
private reconnectTimer: number = -1;
|
||||||
private connectionGeneration: number = 0;
|
|
||||||
private cmdHandler: BridgeCmdHandler | null = null;
|
private cmdHandler: BridgeCmdHandler | null = null;
|
||||||
private onStateChange: ((open: boolean) => void) | null = null;
|
private onStateChange: ((open: boolean) => void) | null = null;
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.connected && this.bound;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDeviceId(): string {
|
getDeviceId(): string {
|
||||||
@ -51,40 +117,37 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||||
|
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
||||||
try {
|
try {
|
||||||
this.ws.off('open');
|
this.ws.off('open');
|
||||||
this.ws.off('message');
|
this.ws.off('message');
|
||||||
this.ws.off('close');
|
this.ws.off('close');
|
||||||
this.ws.off('error');
|
this.ws.off('error');
|
||||||
this.ws.close().catch(() => {
|
|
||||||
// ignore stale socket close failure
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore stale socket cleanup failure
|
// ignore
|
||||||
}
|
}
|
||||||
this.connectionGeneration = this.connectionGeneration + 1;
|
this.ws = webSocket.createWebSocket();
|
||||||
const generation: number = this.connectionGeneration;
|
this.bindWsEvents(authorized);
|
||||||
const socket: webSocket.WebSocket = webSocket.createWebSocket();
|
// 鉴权必须走请求头,不能拼 ?token= :
|
||||||
this.ws = socket;
|
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
||||||
this.connected = false;
|
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
||||||
this.bound = false;
|
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
||||||
this.bindWsEvents(socket, authorized, generation);
|
// 表现为 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 = {
|
const opts: webSocket.WebSocketRequestOptions = {
|
||||||
header: this.authHeader(),
|
header: this.authHeader(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await socket.connect(this.url, opts);
|
await this.ws.connect(this.url, opts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (generation === this.connectionGeneration && !this.manualClose) {
|
this.connected = false;
|
||||||
this.connected = false;
|
this.scheduleReconnect();
|
||||||
this.bound = false;
|
|
||||||
this.notifyState(false);
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
|
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
||||||
private authHeader(): Record<string, string> {
|
private authHeader(): Record<string, string> {
|
||||||
const h: Record<string, string> = {};
|
const h: Record<string, string> = {};
|
||||||
if (this.token.length > 0) {
|
if (this.token.length > 0) {
|
||||||
@ -94,48 +157,38 @@ export class DeviceBridgeClient {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
|
private bindWsEvents(authorized: boolean): void {
|
||||||
socket.on('open', (err: Error, value: Object) => {
|
this.ws.on('open', (err: Error, value: Object) => {
|
||||||
if (generation !== this.connectionGeneration || this.manualClose) {
|
|
||||||
socket.close().catch(() => {
|
|
||||||
// ignore stale socket close failure
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.bound = false;
|
this.everConnected = true;
|
||||||
|
this.cancelReconnect();
|
||||||
this.sendHello(authorized);
|
this.sendHello(authorized);
|
||||||
this.sendBind();
|
this.sendBind();
|
||||||
|
if (this.onStateChange !== null) {
|
||||||
|
this.onStateChange(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
|
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||||
if (generation === this.connectionGeneration && typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
this.handleTextFrame(value);
|
this.handleTextFrame(value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
|
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||||
this.handleSocketEnd(generation);
|
this.connected = false;
|
||||||
|
if (this.onStateChange !== null) {
|
||||||
|
this.onStateChange(false);
|
||||||
|
}
|
||||||
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
socket.on('error', (err: Error) => {
|
this.ws.on('error', (err: Error) => {
|
||||||
this.handleSocketEnd(generation);
|
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 {
|
private scheduleReconnect(): void {
|
||||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||||
return;
|
return;
|
||||||
@ -158,20 +211,18 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
|
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
||||||
updateAuthorized(authorized: boolean): void {
|
updateAuthorized(authorized: boolean): void {
|
||||||
this.lastAuthorized = authorized;
|
this.lastAuthorized = authorized;
|
||||||
if (this.connected && this.bound) {
|
if (this.connected) {
|
||||||
this.sendHello(authorized);
|
this.sendHello(authorized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.manualClose = true;
|
this.manualClose = true;
|
||||||
this.connectionGeneration = this.connectionGeneration + 1;
|
|
||||||
this.cancelReconnect();
|
this.cancelReconnect();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.bound = false;
|
|
||||||
try {
|
try {
|
||||||
this.ws.off('open');
|
this.ws.off('open');
|
||||||
this.ws.off('message');
|
this.ws.off('message');
|
||||||
@ -183,17 +234,40 @@ export class DeviceBridgeClient {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
this.notifyState(false);
|
if (this.onStateChange !== null) {
|
||||||
|
this.onStateChange(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendHello(authorized: boolean): void {
|
private sendHello(authorized: boolean): void {
|
||||||
this.lastAuthorized = authorized;
|
this.lastAuthorized = authorized;
|
||||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
const info: HelloDeviceInfo = {
|
||||||
this.hostname, authorized));
|
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 {
|
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,69 +280,67 @@ export class DeviceBridgeClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const op: string = obj['op'] as string ?? '';
|
const op: string = obj['op'] as string ?? '';
|
||||||
if (op === 'bind_ack') {
|
if (op === 'cmd') {
|
||||||
const accepted: boolean = obj['ok'] === true;
|
const reqId: string = obj['req_id'] as string ?? '';
|
||||||
if (accepted && this.connected && !this.manualClose) {
|
const command: string = obj['command'] as string ?? '';
|
||||||
this.bound = true;
|
if (reqId.length === 0 || command.length === 0) {
|
||||||
this.cancelReconnect();
|
return;
|
||||||
this.notifyState(true);
|
}
|
||||||
} else {
|
if (!this.lastAuthorized) {
|
||||||
this.bound = false;
|
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||||
this.notifyState(false);
|
return;
|
||||||
try {
|
}
|
||||||
this.ws.close().catch(() => {
|
this.dispatchCommand(reqId, command);
|
||||||
// ignore bind rejection close failure
|
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
||||||
});
|
if (this.onAck !== null) {
|
||||||
} catch (e) {
|
this.onAck(op);
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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 {
|
private dispatchCommand(reqId: string, command: string): void {
|
||||||
if (this.cmdHandler === null) {
|
if (this.cmdHandler === null) {
|
||||||
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
|
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||||
handler(reqId, command).then((res: CapResult) => {
|
handler(reqId, command).then((res: CapResult) => {
|
||||||
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
|
|
||||||
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
|
|
||||||
if (res.chunked === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.sendResult(reqId, res.status, res.output, res.error);
|
this.sendResult(reqId, res.status, res.output, res.error);
|
||||||
}).catch((e: Object) => {
|
}).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 {
|
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 客户端一致)=====
|
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||||
|
|
||||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
const startMsg: DataStartMessage = {
|
||||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
op: 'cmd_data_start',
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
req_id: reqId,
|
||||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
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 {
|
try {
|
||||||
this.ws.send(ab).catch(() => {
|
this.ws.send(ab).catch(() => {
|
||||||
// ignore per-chunk failure; end frame reports error below
|
// ignore per-chunk failure; end frame reports error below
|
||||||
@ -277,15 +349,32 @@ export class DeviceBridgeClient {
|
|||||||
break;
|
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 {
|
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 {
|
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 {
|
send(text: string): void {
|
||||||
@ -299,3 +388,7 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||||
|
|
||||||
|
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|||||||
@ -1,180 +0,0 @@
|
|||||||
import { common } from '@kit.AbilityKit';
|
|
||||||
import { http } from '@kit.NetworkKit';
|
|
||||||
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 地址(**回退路径**)。
|
|
||||||
*
|
|
||||||
* 网关改造为子域反代后位于 devices.<基域名>,而基域名与子域标签都是**服务端
|
|
||||||
* 配置**,客户端拼不出来。正常路径是先调 discoverGateway() 问服务端;
|
|
||||||
* 本函数只在「服务端没有发现端点」(老版本 HomeAgent)时兜底,
|
|
||||||
* 保留旧部署的可用性。
|
|
||||||
*/
|
|
||||||
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 等前台能力从主页面加载后即可接收。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 向门户询问设备网关的**权威地址**。
|
|
||||||
*
|
|
||||||
* 为什么必须问而不是自己拼:网关现在挂在 devices.<基域名> 子域上,基域名
|
|
||||||
* (webui.base_domain,默认 localhost)与子域标签(插件声明里可改)都在
|
|
||||||
* 服务端,客户端无从得知。服务端作答是唯一不会漂移的做法。
|
|
||||||
*
|
|
||||||
* 失败/老版本(404)不报错,返回空串让调用方回退到 deviceGatewayUrl()——
|
|
||||||
* 发现是增强而非必需。
|
|
||||||
*/
|
|
||||||
async function discoverGateway(portalUrl: string, apiKey: string): Promise<string> {
|
|
||||||
let base: string = portalUrl.trim();
|
|
||||||
if (base.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// 用户配置里可能填的是完整网关地址:截到门户根再拼发现路径
|
|
||||||
const cut: number = base.indexOf('/api/v1/');
|
|
||||||
if (cut >= 0) {
|
|
||||||
base = base.substring(0, cut);
|
|
||||||
}
|
|
||||||
while (base.length > 0 && base.charAt(base.length - 1) === '/') {
|
|
||||||
base = base.substring(0, base.length - 1);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await http.createHttp().request(base + '/api/v1/device/gateway', {
|
|
||||||
method: http.RequestMethod.GET,
|
|
||||||
header: { 'X-API-Key': apiKey } as Record<string, string>,
|
|
||||||
connectTimeout: 5000,
|
|
||||||
readTimeout: 5000,
|
|
||||||
});
|
|
||||||
if (r.responseCode !== 200) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const body: string = typeof r.result === 'string' ? r.result : '';
|
|
||||||
const parsed: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
|
||||||
// 服务端明确报告不可用(没有声明设备网关反代)时不返回地址,
|
|
||||||
// 让调用方回退,而不是拿着一个连不上的 URL 反复重连。
|
|
||||||
if (parsed['available'] !== true) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// 优先**门户同源形态**(url_portal:同一 host、同一端口)。
|
|
||||||
//
|
|
||||||
// 原因:子域形态 devices.<基域名> 依赖 DNS 解析,而 *.localhost 只有
|
|
||||||
// 浏览器内置该特例(RFC 6761)—— 应用内 HTTP/WS 客户端走系统解析器,
|
|
||||||
// 通常解析不到。门户同源形态无任何 DNS 依赖,永远可解析。
|
|
||||||
const portal: string = parsed['url_portal'] as string;
|
|
||||||
if (portal !== undefined && portal !== null && portal.length > 0) {
|
|
||||||
return portal;
|
|
||||||
}
|
|
||||||
const url: string = parsed['url'] as string;
|
|
||||||
return url === undefined || url === null ? '' : url;
|
|
||||||
} catch (e) {
|
|
||||||
// 网络失败 / 老版本无此端点:静默回退
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析设备桥最终使用的网关地址:优先服务端发现,回退同源推导。 */
|
|
||||||
async function resolveGatewayUrl(portalUrl: string, apiKey: string): Promise<string> {
|
|
||||||
const discovered: string = await discoverGateway(portalUrl, apiKey);
|
|
||||||
if (discovered.length > 0) {
|
|
||||||
return discovered;
|
|
||||||
}
|
|
||||||
return deviceGatewayUrl(portalUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const gatewayUrl: string = await resolveGatewayUrl(cur.url, cur.apiKey);
|
|
||||||
await deviceBridge.connect(
|
|
||||||
gatewayUrl, 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,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
|
|
||||||
*
|
|
||||||
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
|
|
||||||
* 超出工程「单文件 ≤400 行」的约定;而「screensue 内容怎么解析、怎么渲染」
|
|
||||||
* 与「设备能力怎么实现」本就是两件事。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { util } from '@kit.ArkTS';
|
|
||||||
|
|
||||||
// ===== screensue 内容解析 =====
|
|
||||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
|
||||||
|
|
||||||
export interface ScreensuePayload {
|
|
||||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.content = rest;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断 agent 下发的 screensue 内容是不是 HTML。
|
|
||||||
*
|
|
||||||
* 服务端两侧协议都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m
|
|
||||||
* 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。
|
|
||||||
*
|
|
||||||
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
|
|
||||||
* (`'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
|
|
||||||
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
|
|
||||||
*
|
|
||||||
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
|
|
||||||
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
|
|
||||||
*/
|
|
||||||
export function looksLikeHtml(content: string): boolean {
|
|
||||||
return findHtmlStart(content) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
|
|
||||||
function findHtmlStart(content: string): number {
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (content.charAt(i) !== '<') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
|
|
||||||
if (next === '/') {
|
|
||||||
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
|
|
||||||
if (isAsciiLetter(after)) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isAsciiLetter(next)) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAsciiLetter(ch: string): boolean {
|
|
||||||
if (ch.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const c: number = ch.charCodeAt(0);
|
|
||||||
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
|
|
||||||
*
|
|
||||||
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
|
|
||||||
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
|
|
||||||
*/
|
|
||||||
export function screensueHtmlDocument(content: string): string {
|
|
||||||
let body: string = content.trim();
|
|
||||||
// 反复剥成对的包裹引号(agent 把整段 HTML 当命令参数传时的常见形态)。
|
|
||||||
while (body.length >= 2) {
|
|
||||||
const first: string = body.charAt(0);
|
|
||||||
const last: string = body.charAt(body.length - 1);
|
|
||||||
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
|
|
||||||
body = body.substring(1, body.length - 1).trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const idx: number = findHtmlStart(body);
|
|
||||||
if (idx < 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (idx > 0) {
|
|
||||||
body = body.substring(idx);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
|
|
||||||
*
|
|
||||||
* 为什么必须上 Web(不再用 RichText):RichText 只认极小标签子集,
|
|
||||||
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
|
|
||||||
* HTML 文档(含 @keyframes 与 <svg>),RichText 下只能看到源码。用户明确要求引入 webview。
|
|
||||||
*
|
|
||||||
* 为什么用 base64 而不是明文 loadData:encoding 非 base64 时按 URL 规则转义,
|
|
||||||
* 一个几 KB 的完整文档会撞上长度/转义问题;base64 是整篇加载的推荐方式,
|
|
||||||
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8)。
|
|
||||||
*
|
|
||||||
* 片段(非完整文档)补一层 shell:加 <meta viewport> 让窄屏排版正确,
|
|
||||||
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML(走纯文本渲染)。
|
|
||||||
*/
|
|
||||||
export function screensueWebData(content: string, dark: boolean): string {
|
|
||||||
const fragment: string = screensueHtmlDocument(content);
|
|
||||||
if (fragment.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (hasHtmlShell(fragment)) {
|
|
||||||
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
|
|
||||||
return base64Utf8(fragment);
|
|
||||||
}
|
|
||||||
const fg: string = dark ? '#E8ECF4' : '#1B2430';
|
|
||||||
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
||||||
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
||||||
+ '<style>html,body{margin:0;padding:0}'
|
|
||||||
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
|
|
||||||
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
|
|
||||||
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
|
|
||||||
+ fragment + '</body></html>';
|
|
||||||
return base64Utf8(wrapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
|
|
||||||
function hasHtmlShell(s: string): boolean {
|
|
||||||
const head: string = s.substring(0, 400).toLowerCase();
|
|
||||||
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTF-8 字符串 → base64。
|
|
||||||
*
|
|
||||||
* 不能把 UTF-16 码元直接交给 Base64Helper:那样中文会变成乱码。
|
|
||||||
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
|
|
||||||
*/
|
|
||||||
export function base64Utf8(s: string): string {
|
|
||||||
const bytes: number[] = [];
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
let code: number = s.charCodeAt(i);
|
|
||||||
// 代理对(emoji 等)合成成一个码点。
|
|
||||||
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
|
|
||||||
const next: number = s.charCodeAt(i + 1);
|
|
||||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
|
||||||
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (code < 0x80) {
|
|
||||||
bytes.push(code);
|
|
||||||
} else if (code < 0x800) {
|
|
||||||
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
|
|
||||||
} else if (code < 0x10000) {
|
|
||||||
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
|
||||||
} else {
|
|
||||||
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
|
|
||||||
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const helper: util.Base64Helper = new util.Base64Helper();
|
|
||||||
return helper.encodeToStringSync(new Uint8Array(bytes));
|
|
||||||
}
|
|
||||||
@ -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[];
|
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 键:摘要卡与明细页共用 =====
|
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||||
export const K_UP: string = 'statUp';
|
export const K_UP: string = 'statUp';
|
||||||
export const K_VERSION: string = 'statVersion';
|
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_ERR: string = 'statErr';
|
||||||
export const K_LOADING: string = 'statLoading';
|
export const K_LOADING: string = 'statLoading';
|
||||||
export const K_REV: string = 'statRev';
|
export const K_REV: string = 'statRev';
|
||||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
|
||||||
export const K_BUILD: string = 'statBuildSub';
|
|
||||||
|
|
||||||
class StatusStore {
|
class StatusStore {
|
||||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||||
private groups: StatGroup[] = [];
|
private groups: StatGroup[] = [];
|
||||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
|
||||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
|
||||||
|
|
||||||
init(): void {
|
init(): void {
|
||||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||||
@ -118,17 +52,12 @@ class StatusStore {
|
|||||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroups(): StatGroup[] {
|
getGroups(): StatGroup[] {
|
||||||
return this.groups;
|
return this.groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRuntime(): RuntimeSnapshot | undefined {
|
|
||||||
return this.runtimeSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
isUp(): boolean {
|
isUp(): boolean {
|
||||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||||
}
|
}
|
||||||
@ -138,11 +67,8 @@ class StatusStore {
|
|||||||
this.fail(noConnectionMessage());
|
this.fail(noConnectionMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
|
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
||||||
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
|
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||||
// 版本 "-"、插件 0 的初始状态。
|
|
||||||
AppStorage.set<boolean>(K_LOADING, true);
|
|
||||||
AppStorage.set<string>(K_ERR, '');
|
|
||||||
try {
|
try {
|
||||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
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 agents: number = obj['agents'] as number ?? 0;
|
||||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||||
|
|
||||||
AppStorage.set<boolean>(K_UP, true);
|
AppStorage.setOrCreate<boolean>(K_UP, true);
|
||||||
AppStorage.set<string>(K_VERSION, version);
|
AppStorage.setOrCreate<string>(K_VERSION, version);
|
||||||
AppStorage.set<number>(K_AGENTS, agents);
|
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
||||||
AppStorage.set<string>(K_STARTED, startedAt);
|
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
||||||
|
|
||||||
const systemFields: StatField[] = [
|
const systemFields: StatField[] = [
|
||||||
{ label: '版本', value: version },
|
{ label: '版本', value: version },
|
||||||
@ -172,13 +98,12 @@ class StatusStore {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await this.collectKernel(groups);
|
await this.collectKernel(groups);
|
||||||
await this.collectRuntime();
|
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
this.bump();
|
this.bump();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.fail(userMessage('status.refresh', e));
|
this.fail(userMessage('status.refresh', e));
|
||||||
}
|
}
|
||||||
AppStorage.set<boolean>(K_LOADING, false);
|
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||||
@ -194,8 +119,8 @@ class StatusStore {
|
|||||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||||
|
|
||||||
AppStorage.set<number>(K_PLUGINS, pluginCount);
|
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
|
||||||
AppStorage.set<number>(K_TOOLS, toolCount);
|
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
|
||||||
|
|
||||||
const kernelFields: StatField[] = [
|
const kernelFields: StatField[] = [
|
||||||
{ label: 'Agent ID', value: agentId },
|
{ label: 'Agent ID', value: agentId },
|
||||||
@ -205,66 +130,8 @@ class StatusStore {
|
|||||||
if (startTime.length > 0) {
|
if (startTime.length > 0) {
|
||||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
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 });
|
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 / 可用源 / 是否可用
|
// LLM:provider / 可用源 / 是否可用
|
||||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||||
if (llm !== undefined && llm !== null) {
|
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 {
|
private fail(msg: string): void {
|
||||||
AppStorage.set<string>(K_ERR, msg);
|
AppStorage.setOrCreate<string>(K_ERR, msg);
|
||||||
AppStorage.set<boolean>(K_UP, false);
|
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||||
AppStorage.set<boolean>(K_LOADING, false);
|
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||||
this.groups = [];
|
this.groups = [];
|
||||||
this.runtimeSnapshot = undefined;
|
|
||||||
this.bump();
|
this.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||||
private bump(): void {
|
private bump(): void {
|
||||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
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 { apiClient } from '../common/ApiClient';
|
||||||
import { userMessage } from '../common/UserError';
|
import { userMessage } from '../common/UserError';
|
||||||
import { ChatAttachment } from '../model/Model';
|
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 { 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 { COLOR_ERROR } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { PlainCard } from './SubPage';
|
import { PlainCard } from './SubPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
* 附件解析与展示。
|
||||||
*
|
*
|
||||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
* (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 是新开标签页,移动端改为二级页)。
|
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ -341,3 +428,36 @@ export struct AttachmentDetailContent {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
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,285 +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, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
|
||||||
import { SUB_CONNECTIONS } from '../common/SettingsModel';
|
|
||||||
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_HAS_CONN) private hasConn: boolean = false;
|
|
||||||
@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;
|
|
||||||
/** 滚动世代号:scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
|
|
||||||
private scrollGen: number = 0;
|
|
||||||
private navHidden: boolean = false;
|
|
||||||
|
|
||||||
aboutToAppear(): void {
|
|
||||||
this.messages = chatStore.messages();
|
|
||||||
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
|
|
||||||
//
|
|
||||||
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
|
|
||||||
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
|
|
||||||
// 构造之前就完成了,requestScroll 递增的 chatScrollRev 就成了“挂载前
|
|
||||||
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
|
|
||||||
// “消息加载好了却停在顶部/中间,不滚到最新”。
|
|
||||||
if (this.messages.length > 0) {
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
|
|
||||||
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms,
|
|
||||||
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
|
|
||||||
// 用递增间隔重试到 ~1s,让后几帧的布局增长也跟得上。
|
|
||||||
//
|
|
||||||
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
|
|
||||||
const gen: number = ++this.scrollGen;
|
|
||||||
const delays: number[] = [50, 120, 220, 360, 550, 800];
|
|
||||||
for (let i = 0; i < delays.length; i++) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (gen !== this.scrollGen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.scroller.scrollEdge(Edge.Bottom);
|
|
||||||
}, delays[i]);
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
if (gen !== this.scrollGen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.autoScrolling = false;
|
|
||||||
this.navHidden = false;
|
|
||||||
navBar.setVisible(true);
|
|
||||||
}, 900);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 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.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
|
|
||||||
//
|
|
||||||
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
|
|
||||||
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
|
|
||||||
if (this.messages.length === 0 && !this.loading) {
|
|
||||||
Column({ space: 10 }) {
|
|
||||||
Image($r('app.media.ic_link'))
|
|
||||||
.width(34)
|
|
||||||
.height(34)
|
|
||||||
.fillColor(this.palette().textMuted)
|
|
||||||
.draggable(false)
|
|
||||||
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
|
|
||||||
.fontSize(15)
|
|
||||||
.fontWeight(FontWeight.Medium)
|
|
||||||
.fontColor(this.palette().textPrimary)
|
|
||||||
Text(this.hasConn
|
|
||||||
? '在下方输入框发送第一条消息'
|
|
||||||
: '请先在“后端连接”里填写服务地址与 API Key')
|
|
||||||
.fontSize(12)
|
|
||||||
.fontColor(this.palette().textMuted)
|
|
||||||
.textAlign(TextAlign.Center)
|
|
||||||
if (!this.hasConn) {
|
|
||||||
Button('去设置连接')
|
|
||||||
.height(34)
|
|
||||||
.fontSize(13)
|
|
||||||
.backgroundColor(this.palette().accent)
|
|
||||||
.fontColor(Color.White)
|
|
||||||
.margin({ top: 4 })
|
|
||||||
.onClick(() => {
|
|
||||||
// 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页
|
|
||||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
|
|
||||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
.height('100%')
|
|
||||||
.padding({ left: 44, right: 44 })
|
|
||||||
.justifyContent(FlexAlign.Center)
|
|
||||||
.alignItems(HorizontalAlign.Center)
|
|
||||||
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
|
|
||||||
.hitTestBehavior(HitTestMode.Transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 层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,356 +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, K_HAS_CONN } 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 syncConnFlag(): void {
|
|
||||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
|
||||||
}
|
|
||||||
|
|
||||||
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.syncConnFlag();
|
|
||||||
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.syncConnFlag();
|
|
||||||
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.syncConnFlag();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
this.syncConnFlag();
|
|
||||||
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,197 +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';
|
|
||||||
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
|
|
||||||
import { webview } from '@kit.ArkWeb';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* agent 主动推送的前台内容页。
|
|
||||||
*
|
|
||||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
|
||||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
|
||||||
*
|
|
||||||
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 ScreensueHtml)。
|
|
||||||
*
|
|
||||||
* HTML 走 **Web 组件**(用户明确要求):agent 推的常是完整文档 —— 带 <style>
|
|
||||||
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
|
|
||||||
* 对这些一律不渲染,实测只能看到满屏源码。
|
|
||||||
*
|
|
||||||
* 安全:内容来自 agent(第三方),所以显式关掉 JS 与本地文件访问 ——
|
|
||||||
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
|
|
||||||
* 纯文本仍走 Text(无需开销,也不该把文本塞进 Web)。
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
export struct ScreensuePage {
|
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
|
||||||
@Prop pushedText: string = '';
|
|
||||||
@Prop countdown: number = 0;
|
|
||||||
onClose: () => void = () => {
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 内容是不是 HTML(决定走 Web 还是 Text)。 */
|
|
||||||
private htmlMode(): boolean {
|
|
||||||
return looksLikeHtml(this.pushedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HTML 的 base64 载荷(空串表示不是 HTML)。 */
|
|
||||||
private webData(): string {
|
|
||||||
return screensueWebData(this.pushedText, this.isDark);
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if (this.htmlMode()) {
|
|
||||||
// HTML:整篇交给 Web 渲染(base64 loadData,见 screensueWebData)。
|
|
||||||
// 高度固定 420vp:Web 不参与父级自适应测量,给 height('100%')
|
|
||||||
// 会在 Scroll 里塌成 0。内容区本身可滚。
|
|
||||||
ScreenWebView({ data: this.webData(), isDark: this.isDark })
|
|
||||||
.width('100%')
|
|
||||||
.height(420)
|
|
||||||
} else {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
|
|
||||||
*
|
|
||||||
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web:
|
|
||||||
* `WebviewController` 与 Web 组件是一对一绑定的,必须等组件挂载(onControllerAttached)
|
|
||||||
* 才能真正 loadData;把它隔离在这里,ScreensuePage 只管布局与倒计时。
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
struct ScreenWebView {
|
|
||||||
// @Watch 挂在这里是必须的:ScreensuePage 在 `if (screensueVisible)` 里常驻,
|
|
||||||
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
|
|
||||||
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
|
|
||||||
// 实测:连推两条不同 HTML,倒计时变了、Web 里还是旧画面。
|
|
||||||
@Prop @Watch('onDataChanged') data: string = '';
|
|
||||||
@Prop isDark: boolean = true;
|
|
||||||
private controller: webview.WebviewController = new webview.WebviewController();
|
|
||||||
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001)。 */
|
|
||||||
private attached: boolean = false;
|
|
||||||
|
|
||||||
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
|
|
||||||
onDataChanged(): void {
|
|
||||||
if (this.attached) {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
Web({ src: '', controller: this.controller })
|
|
||||||
// ★ 内容来自 agent(第三方):显式关闭脚本与本地文件访问。
|
|
||||||
// javaScriptAccess 的默认值是 true,不写这一行等于放任远端内容执行脚本。
|
|
||||||
.javaScriptAccess(false)
|
|
||||||
.fileAccess(false)
|
|
||||||
.domStorageAccess(false)
|
|
||||||
.onlineImageAccess(false)
|
|
||||||
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
|
|
||||||
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
|
|
||||||
.horizontalScrollBarAccess(false)
|
|
||||||
.verticalScrollBarAccess(false)
|
|
||||||
.darkMode(WebDarkMode.Off)
|
|
||||||
.backgroundColor(Color.Transparent)
|
|
||||||
// 控制器挂载完才 loadData:过早调用会抛 17100001(控制器未与组件关联)。
|
|
||||||
.onControllerAttached(() => {
|
|
||||||
// 挂载完成才允许 loadData;此前的变更由 onDataChanged 记着,这里补一次。
|
|
||||||
this.attached = true;
|
|
||||||
this.load();
|
|
||||||
})
|
|
||||||
.width('100%')
|
|
||||||
.height('100%')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
|
|
||||||
private load(): void {
|
|
||||||
if (this.data.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.controller.loadData(this.data, 'text/html', 'base64');
|
|
||||||
} catch (e) {
|
|
||||||
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.
|
* 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.
|
* Uses Span children inside Text for inline bold/italic/code/link formatting.
|
||||||
*
|
*
|
||||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||||
import { RADIUS_SM } 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
|
@Component
|
||||||
export struct StaticMarkdownView {
|
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';
|
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { RuntimePanel } from './RuntimePanel';
|
|
||||||
import {
|
import {
|
||||||
statusStore, StatGroup, StatField, compactDuration,
|
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';
|
} from '../common/StatusStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,8 +19,6 @@ export struct StatusSummaryCard {
|
|||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@StorageProp(K_UP) private up: boolean = false;
|
@StorageProp(K_UP) private up: boolean = false;
|
||||||
@StorageProp(K_VERSION) private version: string = '-';
|
@StorageProp(K_VERSION) private version: string = '-';
|
||||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
|
||||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
|
||||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||||
@ -120,15 +117,10 @@ export struct StatusSummaryCard {
|
|||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||||
|
|
||||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)。
|
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||||
//
|
|
||||||
// 必须是**子组件**(@Prop 单向下发)而不是本组件里的 @Builder:
|
|
||||||
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
|
|
||||||
// 不会用新值重跑 builder —— 实测:K_PLUGINS 已经是 35,
|
|
||||||
// 但 @Builder 里画的还是首次的 0,永远显示 '-'。
|
|
||||||
Row({ space: 10 }) {
|
Row({ space: 10 }) {
|
||||||
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
|
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
|
||||||
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
|
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.margin({ top: 14 })
|
.margin({ top: 14 })
|
||||||
@ -138,24 +130,14 @@ export struct StatusSummaryCard {
|
|||||||
Text('版本')
|
Text('版本')
|
||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.fontColor(this.palette().textMuted)
|
.fontColor(this.palette().textMuted)
|
||||||
Column() {
|
Text(this.version)
|
||||||
Text(this.version)
|
.fontSize(12)
|
||||||
.fontSize(12)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontColor(this.palette().textSecondary)
|
||||||
.fontColor(this.palette().textSecondary)
|
.layoutWeight(1)
|
||||||
.maxLines(1)
|
.maxLines(1)
|
||||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
|
.margin({ left: 10 })
|
||||||
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('明细')
|
Text('明细')
|
||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.fontColor(this.palette().accent)
|
.fontColor(this.palette().accent)
|
||||||
@ -219,51 +201,6 @@ export struct StatusSummaryCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 能力计数小卡(插件 / 工具)。
|
|
||||||
*
|
|
||||||
* 单独成组件而非 @Builder:@Builder 的按值参数不会随父组件重渲染而刷新,
|
|
||||||
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
|
|
||||||
* 变化重渲染时,子组件拿到新值并重绘。
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
struct KpiTile {
|
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
|
||||||
@Prop icon: Resource = $r('app.media.ic_plug');
|
|
||||||
@Prop label: string = '';
|
|
||||||
@Prop value: number = 0;
|
|
||||||
@Prop tint: string = '';
|
|
||||||
|
|
||||||
build() {
|
|
||||||
Row({ space: 8 }) {
|
|
||||||
Image(this.icon)
|
|
||||||
.width(16)
|
|
||||||
.height(16)
|
|
||||||
.fillColor(this.tint)
|
|
||||||
.draggable(false)
|
|
||||||
Column({ space: 1 }) {
|
|
||||||
Text(this.value > 0 ? this.value.toString() : '-')
|
|
||||||
.fontSize(17)
|
|
||||||
.fontWeight(FontWeight.Bold)
|
|
||||||
.fontColor(this.palette().textPrimary)
|
|
||||||
Text(this.label)
|
|
||||||
.fontSize(11)
|
|
||||||
.fontColor(this.palette().textMuted)
|
|
||||||
}
|
|
||||||
.alignItems(HorizontalAlign.Start)
|
|
||||||
}
|
|
||||||
.layoutWeight(1)
|
|
||||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
|
||||||
.borderRadius(RADIUS_SM)
|
|
||||||
.backgroundColor(this.palette().bgHover)
|
|
||||||
.alignItems(VerticalAlign.Center)
|
|
||||||
}
|
|
||||||
|
|
||||||
private palette(): ThemePalette {
|
|
||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||||
*
|
*
|
||||||
@ -334,11 +271,6 @@ export struct StatusDetailContent {
|
|||||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||||
|
|
||||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
|
||||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
|
||||||
// 后者是进这个页面最先想看的。
|
|
||||||
RuntimePanel()
|
|
||||||
|
|
||||||
ForEach(this.groups, (g: StatGroup) => {
|
ForEach(this.groups, (g: StatGroup) => {
|
||||||
Column() {
|
Column() {
|
||||||
Text(g.title)
|
Text(g.title)
|
||||||
|
|||||||
@ -13,11 +13,7 @@ import { GradientBackground } from './GradientBackground';
|
|||||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||||
|
|
||||||
export function markSubPageOpen(open: boolean): void {
|
export function markSubPageOpen(open: boolean): void {
|
||||||
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
|
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||||
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
|
|
||||||
} else {
|
|
||||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
/** 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 { connStore } from '../common/ConnStore';
|
||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
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'. */
|
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||||
function storedThemeMode(): string {
|
function storedThemeMode(): string {
|
||||||
@ -32,7 +31,6 @@ export default class EntryAbility extends UIAbility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(): void {
|
onDestroy(): void {
|
||||||
stopForegroundBridge();
|
|
||||||
console.info('[HomeAgent] ability onDestroy');
|
console.info('[HomeAgent] ability onDestroy');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +86,6 @@ export default class EntryAbility extends UIAbility {
|
|||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
this.reapplyStoredTheme();
|
this.reapplyStoredTheme();
|
||||||
startForegroundBridge(this.context);
|
|
||||||
startUI();
|
startUI();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
startUI();
|
startUI();
|
||||||
@ -110,7 +107,6 @@ export default class EntryAbility extends UIAbility {
|
|||||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||||
this.reapplyStoredTheme();
|
this.reapplyStoredTheme();
|
||||||
this.applySystemBar();
|
this.applySystemBar();
|
||||||
startForegroundBridge(this.context);
|
|
||||||
startUI();
|
startUI();
|
||||||
}).catch((e: Error) => {
|
}).catch((e: Error) => {
|
||||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||||
@ -133,12 +129,10 @@ export default class EntryAbility extends UIAbility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onForeground(): void {
|
onForeground(): void {
|
||||||
startForegroundBridge(this.context);
|
|
||||||
console.info('[HomeAgent] ability onForeground');
|
console.info('[HomeAgent] ability onForeground');
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackground(): void {
|
onBackground(): void {
|
||||||
stopForegroundBridge();
|
|
||||||
console.info('[HomeAgent] ability onBackground');
|
console.info('[HomeAgent] ability onBackground');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,14 +26,6 @@ export interface ChatMessage {
|
|||||||
toolCalls?: ToolCallInfo[];
|
toolCalls?: ToolCallInfo[];
|
||||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||||
source?: string;
|
source?: string;
|
||||||
/**
|
|
||||||
* 服务端单调递增序号(后端 ChatMsg.seq)。
|
|
||||||
*
|
|
||||||
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
|
|
||||||
* 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。
|
|
||||||
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
|
|
||||||
*/
|
|
||||||
seq?: number;
|
|
||||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||||
attachment?: ChatAttachment;
|
attachment?: ChatAttachment;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,28 +1,35 @@
|
|||||||
import { deviceBridge } from '../common/DeviceBridge';
|
import { deviceBridge } from '../common/DeviceBridge';
|
||||||
|
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
||||||
import { connStore } from '../common/ConnStore';
|
import { connStore } from '../common/ConnStore';
|
||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
|
import { noConnectionMessage } from '../common/UserError';
|
||||||
import { handleNavOnScroll } from '../common/NavBarController';
|
import { handleNavOnScroll } from '../common/NavBarController';
|
||||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||||
import { DeviceInfo } from '../model/Model';
|
import { DeviceInfo } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { DeviceRootEntries } from '../components/DeviceRootEntries';
|
|
||||||
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
|
|
||||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
|
||||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
// 本机声明的能力(与 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
|
@Component
|
||||||
export struct DevicePage {
|
export struct DevicePage {
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@ -32,28 +39,56 @@ export struct DevicePage {
|
|||||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||||
@State activeSub: string = SUB_NONE;
|
@State activeSub: string = SUB_NONE;
|
||||||
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
|
@State bridgeConnected: boolean = false;
|
||||||
@State bridgeUrl: string = '';
|
@State bridgeUrl: string = '';
|
||||||
@State bridgeToken: string = '';
|
@State bridgeToken: string = '';
|
||||||
@State deviceId: string = '';
|
@State deviceId: string = '';
|
||||||
@State authorized: boolean = false;
|
@State authorized: boolean = false;
|
||||||
@State devices: DeviceInfo[] = [];
|
@State devices: DeviceInfo[] = [];
|
||||||
@State loadingDevices: boolean = false;
|
@State loadingDevices: boolean = false;
|
||||||
|
@State lastError: string = '';
|
||||||
@State toastMsg: string = '';
|
@State toastMsg: string = '';
|
||||||
@State toastIsError: boolean = false;
|
@State toastIsError: boolean = false;
|
||||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||||
private navStack: NavPathStack = new NavPathStack();
|
private navStack: NavPathStack = new NavPathStack();
|
||||||
|
private autoConnectTried: boolean = false;
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
this.deviceId = deviceBridge.getDeviceId();
|
||||||
this.deviceId = resolveDeviceId(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();
|
this.authorized = connStore.getDeviceAuth();
|
||||||
// Gateway URL derives from current connection
|
// Gateway URL derives from current connection
|
||||||
const cur = connStore.getCurrentConnection();
|
const cur = connStore.getCurrentConnection();
|
||||||
if (cur !== null) {
|
if (cur !== null) {
|
||||||
this.bridgeUrl = deviceGatewayUrl(cur.url);
|
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
||||||
this.bridgeToken = cur.apiKey;
|
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();
|
this.refreshDevices();
|
||||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||||
registerNavStack(2, this.navStack, () => {
|
registerNavStack(2, this.navStack, () => {
|
||||||
@ -69,6 +104,37 @@ export struct DevicePage {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
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
|
// ignore persist failure
|
||||||
}
|
}
|
||||||
deviceBridge.updateAuthorized(on);
|
deviceBridge.updateAuthorized(on);
|
||||||
|
if (!this.bridgeConnected) {
|
||||||
|
this.connectBridge();
|
||||||
|
}
|
||||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
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 {
|
private showToast(msg: string, isError: boolean): void {
|
||||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||||
this.toastIsError = isError;
|
this.toastIsError = isError;
|
||||||
@ -129,7 +231,31 @@ export struct DevicePage {
|
|||||||
const resp = await apiClient.get('/device/online');
|
const resp = await apiClient.get('/device/online');
|
||||||
if (resp.status >= 200 && resp.status < 300) {
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
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.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||||
this.devices = list;
|
this.devices = list;
|
||||||
});
|
});
|
||||||
@ -149,17 +275,7 @@ export struct DevicePage {
|
|||||||
Column() {
|
Column() {
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column() {
|
Column() {
|
||||||
DeviceRootEntries({
|
this.RootEntries()
|
||||||
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);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||||
@ -171,6 +287,9 @@ export struct DevicePage {
|
|||||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||||
handleNavOnScroll(state);
|
handleNavOnScroll(state);
|
||||||
})
|
})
|
||||||
|
.onAppear(() => {
|
||||||
|
this.maybeAutoConnect();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@ -230,58 +349,61 @@ export struct DevicePage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 二级页面路由表 */
|
||||||
* 二级页面路由表。
|
|
||||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
|
||||||
*/
|
|
||||||
@Builder
|
@Builder
|
||||||
SubDestination(name: string, param: object) {
|
SubDestination(name: string, param: object) {
|
||||||
NavDestination() {
|
NavDestination() {
|
||||||
if (name === SUB_LOCAL) {
|
if (name === SUB_LOCAL) {
|
||||||
DeviceLocalPane({
|
SubPageLayer({
|
||||||
deviceId: this.deviceId,
|
title: '本机设备',
|
||||||
authorized: this.authorized,
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
onToggleAuth: (on: boolean) => {
|
}) {
|
||||||
this.toggleAuthorized(on);
|
this.LocalDeviceContent()
|
||||||
},
|
}
|
||||||
})
|
|
||||||
} else if (name === SUB_CAPS) {
|
} else if (name === SUB_CAPS) {
|
||||||
DeviceCapsPane({
|
SubPageLayer({
|
||||||
|
title: '设备能力',
|
||||||
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
})
|
}) {
|
||||||
|
this.CapsContent()
|
||||||
|
}
|
||||||
} else if (name === SUB_GATEWAY) {
|
} else if (name === SUB_GATEWAY) {
|
||||||
DeviceGatewayPane({
|
SubPageLayer({
|
||||||
bridgeUrl: this.bridgeUrl,
|
title: '设备通道',
|
||||||
bridgeToken: this.bridgeToken,
|
tab: 2,
|
||||||
bridgeConnected: this.bridgeConnected,
|
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
onRefresh: () => {
|
}) {
|
||||||
this.refreshDevices();
|
this.GatewayContent()
|
||||||
},
|
}
|
||||||
})
|
|
||||||
} else if (name === SUB_LIST) {
|
} else if (name === SUB_LIST) {
|
||||||
DeviceListPane({
|
SubPageLayer({
|
||||||
devices: this.devices,
|
title: '接入的设备',
|
||||||
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
|
showRefresh: true,
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
this.refreshDevices();
|
this.refreshDevices();
|
||||||
},
|
},
|
||||||
})
|
}) {
|
||||||
|
this.OnlineDevicesContent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hideTitleBar(true)
|
.hideTitleBar(true)
|
||||||
.backgroundColor(Color.Transparent)
|
.backgroundColor(Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
Toast() {
|
Toast() {
|
||||||
if (this.toastMsg.length > 0) {
|
if (this.toastMsg.length > 0) {
|
||||||
@ -302,6 +424,269 @@ export struct DevicePage {
|
|||||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
.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;
|
const RADIUS_MD: number = 10;
|
||||||
|
|||||||
@ -7,15 +7,12 @@ import { apiClient } from '../common/ApiClient';
|
|||||||
import { navBar } from '../common/NavBarController';
|
import { navBar } from '../common/NavBarController';
|
||||||
import { handleBackPress } from '../common/NavStackRegistry';
|
import { handleBackPress } from '../common/NavStackRegistry';
|
||||||
import { ConnectionConfig } from '../model/Model';
|
import { ConnectionConfig } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
|
||||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||||
import { MotionBase } from '../components/MotionBase';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { GradientBackground } from '../components/GradientBackground';
|
import { GradientBackground } from '../components/GradientBackground';
|
||||||
import { ScreensuePage } from '../components/ScreensuePage';
|
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
|
||||||
import { snapshotComponentId } from '../common/BridgeCaps';
|
|
||||||
import { ScreensuePayload } from '../common/ScreensueHtml';
|
|
||||||
import { window, display } from '@kit.ArkUI';
|
import { window, display } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
@ -84,8 +81,6 @@ struct Index {
|
|||||||
/** 底部手势条高度(vp) */
|
/** 底部手势条高度(vp) */
|
||||||
@State bottomGesture: number = 16;
|
@State bottomGesture: number = 16;
|
||||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
||||||
/** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */
|
|
||||||
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
|
|
||||||
private swiper: SwiperController = new SwiperController();
|
private swiper: SwiperController = new SwiperController();
|
||||||
private screensueTimer: number = -1;
|
private screensueTimer: number = -1;
|
||||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||||
@ -104,12 +99,6 @@ struct Index {
|
|||||||
if (cur !== null) {
|
if (cur !== null) {
|
||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
|
|
||||||
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
|
|
||||||
// 所以此处读到的连接状态就是真实的启动态。
|
|
||||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
|
||||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
|
|
||||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
|
|
||||||
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||||
const st = connStore.getSettings();
|
const st = connStore.getSettings();
|
||||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||||
@ -121,14 +110,14 @@ struct Index {
|
|||||||
this.syncSystemBar();
|
this.syncSystemBar();
|
||||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||||
this.resolveSafeArea();
|
this.resolveSafeArea();
|
||||||
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
|
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
||||||
|
installCmdRouter();
|
||||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||||
this.showScreensue(payload);
|
this.showScreensue(payload);
|
||||||
});
|
});
|
||||||
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */
|
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */
|
||||||
private showScreensue(payload: ScreensuePayload): void {
|
private showScreensue(payload: ScreensuePayload): void {
|
||||||
this.screensueText = payload.content;
|
this.screensueText = payload.content;
|
||||||
this.screensueCountdown = payload.duration;
|
this.screensueCountdown = payload.duration;
|
||||||
@ -200,23 +189,6 @@ struct Index {
|
|||||||
AppStorage.set<number>('currentTab', this.currentTab);
|
AppStorage.set<number>('currentTab', this.currentTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
|
|
||||||
*
|
|
||||||
* 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是
|
|
||||||
* @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
|
|
||||||
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
|
|
||||||
*/
|
|
||||||
private onRequestedTab(): void {
|
|
||||||
const t: number = this.requestedTab;
|
|
||||||
AppStorage.set<number>(K_REQUESTED_TAB, -1);
|
|
||||||
if (t < 0 || t >= this.tabs.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.currentTab = t;
|
|
||||||
navBar.setVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncSystemBar(): void {
|
private syncSystemBar(): void {
|
||||||
const dark: boolean = this.isDark;
|
const dark: boolean = this.isDark;
|
||||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||||
@ -246,7 +218,7 @@ struct Index {
|
|||||||
private updateWideScreen(w: number): void {
|
private updateWideScreen(w: number): void {
|
||||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||||
if (wide !== this.isWide) {
|
if (wide !== this.isWide) {
|
||||||
AppStorage.set<boolean>('isWideScreen', wide);
|
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,10 +230,6 @@ struct Index {
|
|||||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||||
*/
|
*/
|
||||||
onBackPress(): boolean {
|
onBackPress(): boolean {
|
||||||
if (this.screensueVisible) {
|
|
||||||
this.closeScreensue();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return handleBackPress(this.currentTab, this.isWide);
|
return handleBackPress(this.currentTab, this.isWide);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,42 +339,51 @@ struct Index {
|
|||||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||||
.hitTestBehavior(HitTestMode.Transparent)
|
.hitTestBehavior(HitTestMode.Transparent)
|
||||||
|
|
||||||
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
|
// screensue 悬浮层:agent 推送给用户看的内容(置顶展示)
|
||||||
// 左侧一级页面与主导航保持可见、可操作。
|
|
||||||
if (this.screensueVisible) {
|
if (this.screensueVisible) {
|
||||||
if (this.isWide) {
|
Column() {
|
||||||
Row() {
|
Row() {
|
||||||
Column()
|
Circle({ width: 8, height: 8 })
|
||||||
.width(WIDE_NAV_BAR_WIDTH)
|
.fill(this.palette().accent)
|
||||||
.height('100%')
|
Text('agent 推送')
|
||||||
.hitTestBehavior(HitTestMode.None)
|
.fontSize(12)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
ScreensuePage({
|
.fontColor(this.palette().textSecondary)
|
||||||
pushedText: this.screensueText,
|
.margin({ left: 8 })
|
||||||
countdown: this.screensueCountdown,
|
Blank()
|
||||||
onClose: () => {
|
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();
|
this.closeScreensue();
|
||||||
},
|
})
|
||||||
})
|
|
||||||
.layoutWeight(1)
|
|
||||||
.height('100%')
|
|
||||||
.padding({ top: this.topInset })
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.margin({ bottom: 10 })
|
||||||
.hitTestBehavior(HitTestMode.Transparent)
|
|
||||||
} else {
|
Scroll() {
|
||||||
ScreensuePage({
|
Text(this.screensueText)
|
||||||
pushedText: this.screensueText,
|
.fontSize(15)
|
||||||
countdown: this.screensueCountdown,
|
.fontColor(this.palette().textPrimary)
|
||||||
onClose: () => {
|
.width('100%')
|
||||||
this.closeScreensue();
|
}
|
||||||
},
|
.constraintSize({ maxHeight: 320 })
|
||||||
})
|
.scrollBar(BarState.Auto)
|
||||||
.width('100%')
|
.align(Alignment.Top)
|
||||||
.height('100%')
|
|
||||||
.padding({ top: this.topInset })
|
|
||||||
}
|
}
|
||||||
|
.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%')
|
.width('100%')
|
||||||
|
|||||||
@ -1,26 +1,24 @@
|
|||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { userMessage } from '../common/UserError';
|
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||||
import { handleNavOnScroll } from '../common/NavBarController';
|
import { handleNavOnScroll } from '../common/NavBarController';
|
||||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
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 { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||||
import { PluginListView } from '../components/PluginListView';
|
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
import { SettingsEditor } from '../components/SettingsEditor';
|
||||||
import { ToastBar } from '../components/ToastBar';
|
|
||||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
|
||||||
|
|
||||||
/** 二级页面标识:插件详情 */
|
|
||||||
const SUB_NONE: string = '';
|
|
||||||
const SUB_DETAIL: string = 'detail';
|
|
||||||
|
|
||||||
interface InstallBody {
|
interface InstallBody {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 二级页面标识:插件详情 */
|
||||||
|
const SUB_NONE: string = '';
|
||||||
|
const SUB_DETAIL: string = 'detail';
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export struct PluginsPage {
|
export struct PluginsPage {
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@ -60,14 +58,134 @@ export struct PluginsPage {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
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> {
|
private async loadPlugins(): Promise<void> {
|
||||||
if (!apiClient.hasConnection()) {
|
if (!apiClient.hasConnection()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
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,启停会整行重挂载):
|
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
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> {
|
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||||
const name: string = plugin.name;
|
const name: string = plugin.name;
|
||||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||||
@ -148,14 +280,29 @@ export struct PluginsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情数据落地。
|
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||||
|
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||||
*/
|
*/
|
||||||
private async loadDetail(name: string): Promise<void> {
|
private async loadDetail(name: string): Promise<void> {
|
||||||
this.detailLoading = true;
|
this.detailLoading = true;
|
||||||
this.detailError = '';
|
this.detailError = '';
|
||||||
try {
|
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 给它们默认透明度过渡
|
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||||
this.detail = d;
|
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 {
|
private findRow(name: string): PluginRow | null {
|
||||||
for (let i = 0; i < this.plugins.length; i++) {
|
for (let i = 0; i < this.plugins.length; i++) {
|
||||||
if (this.plugins[i].name === name) {
|
if (this.plugins[i].name === name) {
|
||||||
@ -225,15 +387,8 @@ export struct PluginsPage {
|
|||||||
Column() {
|
Column() {
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column() {
|
Column() {
|
||||||
PluginListView({
|
this.ListStates()
|
||||||
plugins: this.plugins,
|
this.PluginList()
|
||||||
busy: this.loading,
|
|
||||||
activeName: this.activeName,
|
|
||||||
hasConn: apiClient.hasConnection(),
|
|
||||||
onSelect: (name: string) => {
|
|
||||||
this.openDetail(name);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||||
@ -256,15 +411,7 @@ export struct PluginsPage {
|
|||||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||||
NavFloatOverlay({ tab: 1 }) {
|
NavFloatOverlay({ tab: 1 }) {
|
||||||
if (this.showInstallForm) {
|
if (this.showInstallForm) {
|
||||||
PluginInstallForm({
|
this.InstallForm()
|
||||||
url: this.installUrl,
|
|
||||||
onUrlChange: (v: string) => {
|
|
||||||
this.installUrl = v;
|
|
||||||
},
|
|
||||||
onInstall: () => {
|
|
||||||
this.installPlugin();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
NavFloatRow() {
|
NavFloatRow() {
|
||||||
FloatIconButton({
|
FloatIconButton({
|
||||||
@ -294,7 +441,7 @@ export struct PluginsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
this.Toast()
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@ -346,29 +493,503 @@ export struct PluginsPage {
|
|||||||
this.loadDetail(this.activeName);
|
this.loadDetail(this.activeName);
|
||||||
},
|
},
|
||||||
}) {
|
}) {
|
||||||
PluginDetailPane({
|
this.DetailContent()
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hideTitleBar(true)
|
.hideTitleBar(true)
|
||||||
.backgroundColor(Color.Transparent)
|
.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.GET_NETWORK_INFO"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ohos.permission.READ_PASTEBOARD",
|
|
||||||
"reason": "$string:read_pasteboard_reason",
|
|
||||||
"usedScene": {
|
|
||||||
"abilities": ["EntryAbility"],
|
|
||||||
"when": "inuse"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
|
|||||||
@ -3,10 +3,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app_name",
|
"name": "app_name",
|
||||||
"value": "HomeAgent"
|
"value": "HomeAgent"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "read_pasteboard_reason",
|
|
||||||
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,56 +9,30 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
|
|||||||
```
|
```
|
||||||
HomeAgent/
|
HomeAgent/
|
||||||
├── AppScope/ 应用级配置与图标
|
├── AppScope/ 应用级配置与图标
|
||||||
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
|
|
||||||
├── entry/src/main/
|
├── entry/src/main/
|
||||||
│ ├── ets/
|
│ ├── ets/
|
||||||
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
|
│ │ ├── common/ 通信与全局状态
|
||||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||||
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
|
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
|
||||||
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
|
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||||
│ │ │ ├── UserError.ets 错误转人类可读文案
|
│ │ │ ├── ConnStore.ets 连接配置持久化
|
||||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
|
│ │ │ ├── StatusStore.ets 运行状态缓存
|
||||||
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
|
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
|
||||||
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
|
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||||
│ │ │ ├── ChatSession.ets 发送/中断(POST /chat、/chat/file)
|
│ │ │ └── UserError.ets 错误转人类可读文案
|
||||||
│ │ │ ├── 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 解析(块/行内/表格)
|
|
||||||
│ │ ├── components/ 可复用组件
|
│ │ ├── components/ 可复用组件
|
||||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||||
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
|
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||||
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
|
│ │ │ ├── StatusCards.ets 状态卡片
|
||||||
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
|
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||||
│ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
|
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||||
│ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
|
│ │ │ ├── SubPage.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 统一按压反馈与入场动画
|
|
||||||
│ │ │ └── GradientBackground.ets
|
│ │ │ └── GradientBackground.ets
|
||||||
│ │ ├── model/Model.ets 共享类型定义
|
│ │ ├── model/Model.ets 共享类型定义
|
||||||
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
|
│ │ ├── pages/ 页面
|
||||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||||
│ │ │ ├── ChatPage.ets 对话
|
│ │ │ ├── ChatPage.ets 对话
|
||||||
│ │ │ ├── DevicePage.ets 设备
|
│ │ │ ├── DevicePage.ets 设备
|
||||||
@ -71,30 +45,11 @@ HomeAgent/
|
|||||||
└── oh-package.json5 依赖
|
└── 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/)。
|
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
|
||||||
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
|
本工程用 `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` 含密码明文,未入库):
|
1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -109,27 +64,19 @@ HomeAgent/
|
|||||||
2. **构建 HAP**:
|
2. **构建 HAP**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cmd/ohos/HomeAgent
|
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||||
# ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位,
|
/path/to/command-line-tools/bin/hvigorw \
|
||||||
# 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。
|
--mode module -p module=entry@default assembleHap --no-daemon
|
||||||
# 一律用 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||||
|
|
||||||
3. **安装到设备**(`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**,
|
3. **安装到设备**:
|
||||||
否则下面这个路径不存在):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hdc install entry/build/default/outputs/default/entry-default-signed.hap
|
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
|
## 连接 homed
|
||||||
|
|
||||||
首次启动在「设置」里填:
|
首次启动在「设置」里填:
|
||||||
@ -150,19 +97,3 @@ HomeAgent/
|
|||||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
||||||
|
|
||||||
## 改动后的运行时验证清单
|
|
||||||
|
|
||||||
构建通过只能证明编译期没问题;ArkUI 的状态绑定、过渡动画与手势行为
|
|
||||||
必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
|
|
||||||
|
|
||||||
- [ ] 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
|
|
||||||
- [ ] 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
|
|
||||||
- [ ] 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
|
|
||||||
- [ ] 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
|
|
||||||
- [ ] 进插件列表与插件详情,确认状态色、开关与卸载正常
|
|
||||||
- [ ] 进出设备页四个二级页,确认授权开关与在线设备列表正常
|
|
||||||
- [ ] 宽屏(>=600vp)下确认左右分栏、返回手势与返回键行为
|
|
||||||
|
|
||||||
模拟器在无图形/无提权环境里可能起不来(需要写 `~/.Huawei` 等宿主目录),
|
|
||||||
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
|
|||||||
/conn use <name> switch to saved connection
|
/conn use <name> switch to saved connection
|
||||||
/conn del <name> delete saved connection
|
/conn del <name> delete saved connection
|
||||||
|
|
||||||
Server commands (local 与 remote 行为一致):
|
Server commands (sent to agent):
|
||||||
/status system status
|
/status system status
|
||||||
/kernel kernel status
|
/kernel kernel status
|
||||||
/settings [prefix] list settings
|
/settings [prefix] list settings
|
||||||
@ -32,27 +32,10 @@ Server commands (local 与 remote 行为一致):
|
|||||||
/plugin list list installed plugins
|
/plugin list list installed plugins
|
||||||
/plugin install <url> install plugin
|
/plugin install <url> install plugin
|
||||||
/plugin remove <name> remove plugin
|
/plugin remove <name> remove plugin
|
||||||
/plugin disable <name> disable plugin
|
|
||||||
/plugin enable <name> enable plugin
|
|
||||||
/plugin info <name> plugin details
|
/plugin info <name> plugin details
|
||||||
/memory query <text> query graph memory
|
/memory query <text> query graph memory
|
||||||
/memory graph dump full graph memory snapshot
|
/knowledge list knowledge base
|
||||||
/memory text [n] recent text-memory events + stats
|
|
||||||
/memory context [q] assembled memory context (what gets injected)
|
|
||||||
/memory tools memory tool definitions + tool prompt
|
|
||||||
/knowledge list knowledge base (+stats)
|
|
||||||
/knowledge delete <name> delete knowledge item
|
/knowledge delete <name> delete knowledge item
|
||||||
/config dump kernel config (JSON)
|
|
||||||
/tracker change-tracking stats
|
|
||||||
/tracker rollback roll back this session's file changes
|
|
||||||
/adapters list loaded Lua adapters
|
|
||||||
/adapters remove <name> remove a Lua adapter
|
|
||||||
/network network status + LLM endpoints
|
|
||||||
/runtime scheduler / residents / channel topology
|
|
||||||
/terminals list terminal sessions
|
|
||||||
/cmd/history command execution history
|
|
||||||
/terminal create|write|read|close … (local mode; calls agentcli tools)
|
|
||||||
/persona show persona (/persona set default|custom|later [text])
|
|
||||||
/agents list agents
|
/agents list agents
|
||||||
/chat <text> send to agent
|
/chat <text> send to agent
|
||||||
|
|
||||||
@ -71,26 +54,6 @@ Any other text is sent to the agent directly.`)
|
|||||||
reconnect()
|
reconnect()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
// /stop 与 /interrupt:取消当前生成(可附带一句新指令)。
|
|
||||||
// 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case,
|
|
||||||
// 于是它像普通文本一样被发给了 Agent。
|
|
||||||
// 本地交给 CLI 插件(内核优先级 L3),远端走 WebUI 的 chat/interrupt
|
|
||||||
// (内核优先级 L4)。两条路都是“真中断”,不是发一句话。
|
|
||||||
case cmd == "/stop" || cmd == "/interrupt" ||
|
|
||||||
strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "):
|
|
||||||
msg := stopMessage(cmd)
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
body := fmt.Sprintf(`{"message":%q}`, msg)
|
|
||||||
if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil {
|
|
||||||
fmt.Fprintf(out, "interrupt failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(out, "interrupt sent")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.Send(cmd)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case strings.HasPrefix(cmd, "/connect "):
|
case strings.HasPrefix(cmd, "/connect "):
|
||||||
cfg.Socket = strings.TrimSpace(cmd[9:])
|
cfg.Socket = strings.TrimSpace(cmd[9:])
|
||||||
cfg.Remote = ""
|
cfg.Remote = ""
|
||||||
@ -180,7 +143,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
rc.DoAPI("PUT", "/api/v1/settings", body)
|
rc.DoAPI("PUT", "/api/v1/settings", body)
|
||||||
fmt.Fprintln(out, "ok")
|
fmt.Fprintln(out, "ok")
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -189,7 +152,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -209,7 +172,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -219,7 +182,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -229,56 +192,17 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
// disable/enable:本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
|
case strings.HasPrefix(cmd, "/memory query "):
|
||||||
case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
|
q := strings.TrimSpace(cmd[14:])
|
||||||
verb := "disable"
|
|
||||||
name := strings.TrimSpace(cmd[16:])
|
|
||||||
if strings.HasPrefix(cmd, "/plugin enable ") {
|
|
||||||
verb = "enable"
|
|
||||||
name = strings.TrimSpace(cmd[15:])
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
fmt.Fprintln(out, "usage: /plugin disable|enable <name>")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
if rc := state.RemoteConn(); rc != nil {
|
||||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
|
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case strings.HasPrefix(cmd, "/memory "):
|
|
||||||
sub := strings.TrimSpace(cmd[8:])
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(sub, "query "):
|
|
||||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "query "))
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
|
||||||
printJSON(out, d)
|
|
||||||
case sub == "graph":
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/graph", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
case sub == "text" || strings.HasPrefix(sub, "text "):
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/text", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
case sub == "context" || strings.HasPrefix(sub, "context "):
|
|
||||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "context"))
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/context?q="+q, "")
|
|
||||||
printJSON(out, d)
|
|
||||||
case sub == "tools":
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/tools", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(out, "usage: /memory query <text> | /memory graph | /memory text [n] | /memory context [q] | /memory tools")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.Send(cmd)
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -288,126 +212,16 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
|
case cmd == "/knowledge":
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
if rc := state.RemoteConn(); rc != nil {
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send("/knowledge")
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/config":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/config", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/config")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/tracker":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/tracker", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/tracker")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/tracker rollback":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("POST", "/api/v1/tracker/rollback", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/tracker rollback")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/adapters" || strings.HasPrefix(cmd, "/adapters remove "):
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
if strings.HasPrefix(cmd, "/adapters remove ") {
|
|
||||||
name := strings.TrimSpace(cmd[17:])
|
|
||||||
d, _ := rc.DoAPI("DELETE", "/api/v1/adapters/"+name, "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/adapters", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.Send(cmd)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/persona" || strings.HasPrefix(cmd, "/persona set "):
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
if strings.HasPrefix(cmd, "/persona set ") {
|
|
||||||
rest := strings.TrimSpace(cmd[13:])
|
|
||||||
mode := rest
|
|
||||||
content := ""
|
|
||||||
if idx := strings.IndexByte(rest, ' '); idx > 0 {
|
|
||||||
mode = rest[:idx]
|
|
||||||
content = strings.TrimSpace(rest[idx+1:])
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf(`{"mode":%q,"content":%q}`, mode, content)
|
|
||||||
d, _ := rc.DoAPI("POST", "/api/v1/persona", body)
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/persona", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.Send(cmd)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/terminals":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/terminals", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/terminals")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/cmd/history":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/cmd/history")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "):
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
// 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建),
|
|
||||||
// 不静默当聊天发出去,直接说明。
|
|
||||||
fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具")
|
|
||||||
} else {
|
|
||||||
state.Send(cmd)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/runtime":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/runtime", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/runtime")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case cmd == "/network":
|
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/network", "")
|
|
||||||
printJSON(out, d)
|
|
||||||
} else {
|
|
||||||
state.Send("/network")
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -425,16 +239,6 @@ Any other text is sent to the agent directly.`)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。
|
|
||||||
func stopMessage(cmd string) string {
|
|
||||||
for _, prefix := range []string{"/interrupt", "/stop"} {
|
|
||||||
if strings.HasPrefix(cmd, prefix) {
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func printJSON(out io.Writer, d map[string]interface{}) {
|
func printJSON(out io.Writer, d map[string]interface{}) {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
fmt.Fprintln(out, "(no data)")
|
fmt.Fprintln(out, "(no data)")
|
||||||
|
|||||||
@ -39,7 +39,7 @@ type msgEntry struct {
|
|||||||
|
|
||||||
type daemonHandler struct {
|
type daemonHandler struct {
|
||||||
// homed 连接
|
// homed 连接
|
||||||
homeMu sync.Mutex
|
homeMu sync.Mutex
|
||||||
homeConn net.Conn
|
homeConn net.Conn
|
||||||
homeR *bufio.Reader
|
homeR *bufio.Reader
|
||||||
homeCfg *Config
|
homeCfg *Config
|
||||||
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
|
|||||||
if dg == "" || dt == "" {
|
if dg == "" || dt == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。
|
// 设备桥重连循环:WS 断开时自动重连
|
||||||
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
|
go runDeviceBridgeLoop(dg, dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
||||||
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
func runDeviceBridgeLoop(gateway, token string) {
|
||||||
for {
|
for {
|
||||||
bridge, err := connectDeviceBridge(gateway, token, authorized)
|
bridge, err := connectDeviceBridge(gateway, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
||||||
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
|
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "local"
|
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 := client.New(gw, token, deviceID, hostname, caps, info)
|
||||||
bridge.SetAuthorized(authorized)
|
|
||||||
|
|
||||||
// 注册命令处理器:cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
|
// 注册命令处理器
|
||||||
cr := client.NewCmdRouter()
|
cr := client.NewCmdRouter()
|
||||||
cr.Handle("homeagent-", handleHomeagentCmd)
|
cr.Handle("homeagent-", handleHomeagentCmd)
|
||||||
cr.HandleDefault(handleShellCmd)
|
cr.HandleDefault(handleShellCmd)
|
||||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
bridge.OnCmd(func(reqID, command string) {
|
||||||
if cmdType == "homeagent" {
|
|
||||||
handleHomeagentCmd(reqID, command)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cr.Dispatch(reqID, command)
|
cr.Dispatch(reqID, command)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -376,6 +371,8 @@ func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge
|
|||||||
// 设置全局变量供 sendBridgeResult 使用
|
// 设置全局变量供 sendBridgeResult 使用
|
||||||
deviceBridge = bridge
|
deviceBridge = bridge
|
||||||
deviceBridgeID = deviceID
|
deviceBridgeID = deviceID
|
||||||
|
auth := true // daemon 模式默认授权(配置已指定)
|
||||||
|
bridge.SetAuthorized(auth)
|
||||||
|
|
||||||
return bridge, nil
|
return bridge, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ===== 设备桥管理 =====
|
// ===== 设备桥管理 =====
|
||||||
@ -47,33 +46,28 @@ func startDeviceBridge(addr, token string) error {
|
|||||||
"platform": runtime.GOOS,
|
"platform": runtime.GOOS,
|
||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
"cpus": runtime.NumCPU(),
|
"cpus": runtime.NumCPU(),
|
||||||
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
|
|
||||||
// 因此与 homed 一致,不再是一个空缺字段。
|
|
||||||
"version": meta.Version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网关地址规范化(含「已是完整端点」「只有 host:port」两种旧输入形态)。
|
// 确保 gateway URL 格式正确
|
||||||
gateway := normalizeGateway(addr)
|
gateway := addr
|
||||||
|
if !strings.HasPrefix(gateway, "ws://") && !strings.HasPrefix(gateway, "wss://") {
|
||||||
|
gateway = "ws://" + gateway
|
||||||
|
// 默认 remotedevice WS 路径
|
||||||
|
if !strings.Contains(gateway, "/api/v1/device/ws") {
|
||||||
|
gateway = gateway + "/api/v1/device/ws"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||||
cmdRouter = client.NewCmdRouter()
|
cmdRouter = client.NewCmdRouter()
|
||||||
|
|
||||||
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
|
// 注册命令处理器
|
||||||
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
||||||
cmdRouter.HandleDefault(handleShellCmd)
|
cmdRouter.HandleDefault(handleShellCmd)
|
||||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
bridge.OnCmd(func(reqID, command string) {
|
||||||
if cmdType == "homeagent" {
|
|
||||||
handleHomeagentCmd(reqID, command)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cmdRouter.Dispatch(reqID, command)
|
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 {
|
if err := bridge.Start(); err != nil {
|
||||||
return fmt.Errorf("device bridge: %w", err)
|
return fmt.Errorf("device bridge: %w", err)
|
||||||
}
|
}
|
||||||
@ -773,4 +767,4 @@ func sanitizeID(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@ -1,134 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// discoveryPath 是 HomeAgent 的「设备网关在哪」端点(相对门户根)。
|
|
||||||
const discoveryPath = "/api/v1/device/gateway"
|
|
||||||
|
|
||||||
// discoveryResponse 是发现端点的响应。
|
|
||||||
type discoveryResponse struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
// URL 是**子域形态**(devices.<基域名>)。浏览器能解析(RFC 6761 内置
|
|
||||||
// 特例),但系统解析器(getent/Go/Node)通常解析不到 *.localhost —— 实测如此。
|
|
||||||
URL string `json:"url"`
|
|
||||||
// URLPortal 是**门户同源形态**(同一 host、同一端口,走路径挂载),
|
|
||||||
// 无任何 DNS 依赖。非浏览器客户端应当用这个。
|
|
||||||
URLPortal string `json:"url_portal"`
|
|
||||||
Preferred string `json:"preferred"`
|
|
||||||
Host string `json:"host"` // devices.<基域名>
|
|
||||||
Auth string `json:"auth"` // homeagent | none
|
|
||||||
Reason string `json:"reason"` // available=false 时的原因
|
|
||||||
Hint string `json:"hint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeGateway 把用户给的地址整理成可直接连接的 WebSocket URL。
|
|
||||||
//
|
|
||||||
// 保留两种输入形态的旧行为:
|
|
||||||
// - 已含路径(含 /api/v1/device/ws)→ 原样使用;
|
|
||||||
// - 只有 host[:port] → 补 ws:// 与默认 WS 路径。
|
|
||||||
//
|
|
||||||
// 新增:**已带子域标签的地址不再被改写**(例如 devices.example.com)——
|
|
||||||
// 旧实现只判断"是否含路径",对子域地址是对的;这里把这条显式化,
|
|
||||||
// 避免以后有人加"自动补门户路径"的逻辑时把它改坏。
|
|
||||||
func normalizeGateway(addr string) string {
|
|
||||||
g := strings.TrimSpace(addr)
|
|
||||||
if g == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(g, "ws://") || strings.HasPrefix(g, "wss://") {
|
|
||||||
if strings.Contains(g, "/api/v1/device/ws") {
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
|
||||||
}
|
|
||||||
// http(s):// 形态:转成 ws(s)://,其余同下
|
|
||||||
if strings.HasPrefix(g, "https://") {
|
|
||||||
g = "wss://" + strings.TrimPrefix(g, "https://")
|
|
||||||
} else if strings.HasPrefix(g, "http://") {
|
|
||||||
g = "ws://" + strings.TrimPrefix(g, "http://")
|
|
||||||
} else {
|
|
||||||
g = "ws://" + g
|
|
||||||
}
|
|
||||||
if strings.Contains(g, "/api/v1/device/ws") {
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
|
||||||
}
|
|
||||||
|
|
||||||
// discoverGateway 向门户询问设备网关的**权威地址**。
|
|
||||||
//
|
|
||||||
// 为什么需要:设备网关现在位于 devices.<基域名> 的子域反代上,而基域名与
|
|
||||||
// 子域标签都是**服务端配置**(webui.base_domain / 插件声明),客户端无从得知。
|
|
||||||
// 让服务端回答「网关在哪」是唯一不会漂移的做法。
|
|
||||||
//
|
|
||||||
// portalURL 是用户配置的门户地址(可能带路径/尾斜杠);token 是门户 api_key。
|
|
||||||
// 任何失败都返回错误,由调用方决定是否回退到自配地址 —— 发现是**增强**而非
|
|
||||||
// 必需,老版本 HomeAgent 没有这个端点。
|
|
||||||
func discoverGateway(portalURL, token string, timeout time.Duration) (string, error) {
|
|
||||||
base := strings.TrimSpace(portalURL)
|
|
||||||
if base == "" {
|
|
||||||
return "", fmt.Errorf("门户地址为空")
|
|
||||||
}
|
|
||||||
// http(s) → 对应的门户根;ws(s) 输入也要能问(GUI 里同一字段混用两种形态)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(base, "wss://"):
|
|
||||||
base = "https://" + strings.TrimPrefix(base, "wss://")
|
|
||||||
case strings.HasPrefix(base, "ws://"):
|
|
||||||
base = "http://" + strings.TrimPrefix(base, "ws://")
|
|
||||||
case !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://"):
|
|
||||||
base = "http://" + base
|
|
||||||
}
|
|
||||||
// 用户可能填的是完整网关地址(含 /api/v1/device/ws):截到根再拼发现路径
|
|
||||||
if i := strings.Index(base, "/api/v1/"); i >= 0 {
|
|
||||||
base = base[:i]
|
|
||||||
}
|
|
||||||
base = strings.TrimRight(base, "/")
|
|
||||||
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+discoveryPath, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("X-API-Key", token)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("发现端点返回 %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
var d discoveryResponse
|
|
||||||
if err := json.Unmarshal(body, &d); err != nil {
|
|
||||||
return "", fmt.Errorf("发现响应无法解析: %w", err)
|
|
||||||
}
|
|
||||||
if !d.Available {
|
|
||||||
msg := d.Reason
|
|
||||||
if msg == "" {
|
|
||||||
msg = "服务端报告设备网关不可用"
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("%s", msg)
|
|
||||||
}
|
|
||||||
// 优先门户同源形态:waiter 是普通进程,走系统解析器,
|
|
||||||
// 而 *.localhost 在系统解析器下通常解析不到(只有浏览器内置该特例)。
|
|
||||||
if p := strings.TrimSpace(d.URLPortal); p != "" {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
if p := strings.TrimSpace(d.URL); p != "" {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("服务端未给出可用的网关地址")
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNormalizeGateway(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
// 已是完整端点:原样
|
|
||||||
"ws://devices.localhost:8080/api/v1/device/ws": "ws://devices.localhost:8080/api/v1/device/ws",
|
|
||||||
"wss://devices.example.com/api/v1/device/ws": "wss://devices.example.com/api/v1/device/ws",
|
|
||||||
// 只有 ws 根:补路径
|
|
||||||
"ws://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
|
||||||
"ws://devices.example.com/": "ws://devices.example.com/api/v1/device/ws",
|
|
||||||
// 裸 host:port:补 scheme + 路径(旧行为)
|
|
||||||
"127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
|
||||||
"devices.example.com:8080": "ws://devices.example.com:8080/api/v1/device/ws",
|
|
||||||
// http(s) → ws(s)
|
|
||||||
"http://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
|
||||||
"https://devices.example.com": "wss://devices.example.com/api/v1/device/ws",
|
|
||||||
// ★ 子域地址不得被改写(这正是改造后的正确形态)
|
|
||||||
"devices.example.com": "ws://devices.example.com/api/v1/device/ws",
|
|
||||||
// 空
|
|
||||||
"": "",
|
|
||||||
" ": "",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := normalizeGateway(in); got != want {
|
|
||||||
t.Errorf("normalizeGateway(%q) = %q,期望 %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发现端点返回权威地址时,必须采用它(而不是自己拼门户同源地址)。
|
|
||||||
func TestDiscoverGatewayUsesServerAnswer(t *testing.T) {
|
|
||||||
var gotPath, gotKey string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath = r.URL.Path
|
|
||||||
gotKey = r.Header.Get("X-API-Key")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"available": true,
|
|
||||||
"url": "ws://devices.localhost:8080/api/v1/device/ws",
|
|
||||||
"url_portal": "ws://127.0.0.1:8080/api/v1/device/ws",
|
|
||||||
"auth": "none",
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
url, err := discoverGateway(srv.URL, "PORTAL-KEY", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// ★ 必须优先门户同源形态:waiter 走系统解析器,*.localhost 解析不到
|
|
||||||
if url != "ws://127.0.0.1:8080/api/v1/device/ws" {
|
|
||||||
t.Errorf("未优先采用门户同源形态: %q", url)
|
|
||||||
}
|
|
||||||
if gotPath != "/api/v1/device/gateway" {
|
|
||||||
t.Errorf("发现路径不对: %q", gotPath)
|
|
||||||
}
|
|
||||||
if gotKey != "PORTAL-KEY" {
|
|
||||||
t.Errorf("未带门户凭证: %q", gotKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户填的是完整网关地址时,也要能正确截到门户根再问。
|
|
||||||
func TestDiscoverGatewayFromFullEndpointInput(t *testing.T) {
|
|
||||||
var gotPath string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath = r.URL.Path
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"available": true, "url": "ws://devices.localhost/api/v1/device/ws",
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
// 输入形态:完整旧端点(含 /api/v1/device/ws)与 ws:// 前缀
|
|
||||||
for _, in := range []string{
|
|
||||||
srv.URL + "/api/v1/device/ws",
|
|
||||||
"ws://" + srv.Listener.Addr().String() + "/api/v1/device/ws",
|
|
||||||
} {
|
|
||||||
gotPath = ""
|
|
||||||
if _, err := discoverGateway(in, "k", 3*time.Second); err != nil {
|
|
||||||
t.Errorf("输入 %q 应成功: %v", in, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if gotPath != "/api/v1/device/gateway" {
|
|
||||||
t.Errorf("输入 %q 未截到门户根,实际路径 %q", in, gotPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 服务端明确报告不可用 → 必须返回错误(调用方据此回退),而不是给个连不上的 URL。
|
|
||||||
func TestDiscoverGatewayUnavailableReportsError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"available": false,
|
|
||||||
"reason": "本实例没有声明设备网关反代",
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
|
||||||
t.Error("服务端报告不可用时应返回错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 老版本 HomeAgent 没有该端点(404)→ 返回错误而不是 panic/空成功。
|
|
||||||
func TestDiscoverGatewayOldServerFallsBack(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.NotFoundHandler())
|
|
||||||
defer srv.Close()
|
|
||||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
|
||||||
t.Error("404 应返回错误,让调用方回退到自配地址")
|
|
||||||
}
|
|
||||||
// 空地址快速失败
|
|
||||||
if _, err := discoverGateway("", "k", 3*time.Second); err == nil {
|
|
||||||
t.Error("空门户地址应返回错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 老版本只给 url(无 url_portal)时,仍必须能用 —— 退回子域形态。
|
|
||||||
func TestDiscoverGatewayFallsBackToSubdomainForm(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"available": true,
|
|
||||||
"url": "ws://devices.example.com/api/v1/device/ws",
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
got, err := discoverGateway(srv.URL, "k", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != "ws://devices.example.com/api/v1/device/ws" {
|
|
||||||
t.Errorf("无 url_portal 时应退回 url,实际 %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 两者都没有 → 明确报错,而不是返回空串让调用方拿着空地址去连。
|
|
||||||
func TestDiscoverGatewayNoURLReportsError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"available": true})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
|
||||||
t.Error("两个形态都缺时应报错")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,8 +11,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -131,15 +129,8 @@ func main() {
|
|||||||
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
||||||
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
||||||
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
||||||
showVersion := flag.Bool("version", false, "打印版本并退出")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 版本号直接来自 internal/meta(与 homed 同一事实源,不可能各写一个)。
|
|
||||||
if *showVersion {
|
|
||||||
fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 本地能力测试模式(无需连接服务器)
|
// 本地能力测试模式(无需连接服务器)
|
||||||
if *testCap != "" {
|
if *testCap != "" {
|
||||||
runCapTest(*testCap, *testCapArgs)
|
runCapTest(*testCap, *testCapArgs)
|
||||||
@ -181,17 +172,6 @@ func main() {
|
|||||||
if dt == "" {
|
if dt == "" {
|
||||||
dt = cfg.DeviceToken
|
dt = cfg.DeviceToken
|
||||||
}
|
}
|
||||||
// 网关地址优先向门户**发现**(服务端才知道子域标签与基域名),
|
|
||||||
// 失败再回退到用户配置 —— 老版本 HomeAgent 没有发现端点。
|
|
||||||
// 只在用户已配置门户地址时尝试:没配门户就没有可问的对象。
|
|
||||||
if portal := cfg.Remote; portal != "" {
|
|
||||||
if discovered, err := discoverGateway(portal, cfg.APIKey, 5*time.Second); err == nil {
|
|
||||||
printlnC(colorGreen, "device gateway discovered: "+discovered)
|
|
||||||
dg = discovered
|
|
||||||
} else if dg == "" {
|
|
||||||
printlnC(colorYellow, "device gateway discovery failed: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dg != "" && dt != "" {
|
if dg != "" && dt != "" {
|
||||||
if err := startDeviceBridge(dg, dt); err != nil {
|
if err := startDeviceBridge(dg, dt); err != nil {
|
||||||
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
||||||
@ -205,18 +185,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if oneShotMsg != "" {
|
if oneShotMsg != "" {
|
||||||
// ★ -chat 是一次性问答,会立刻走到上面的 return 并触发
|
|
||||||
// defer stopDeviceBridge(),桥的生命周期只有几百毫秒。
|
|
||||||
//
|
|
||||||
// 后果:设备来不及完成 hello→bind 登记就已断开,服务端列表里永远
|
|
||||||
// 看不到它(实测:`device bridge active` 打印了、bind_ack 也收到了,
|
|
||||||
// 但 /api/v1/device/online 始终为空)。
|
|
||||||
// 这不是桥的错 —— 用裸客户端把 hello/bind 发完并保持连接,同一实例
|
|
||||||
// 上设备立刻出现在列表里(已验证)。
|
|
||||||
//
|
|
||||||
// 等待 bind 确认(或短暂超时)再退出:既让登记完成,也不把一次性
|
|
||||||
// 命令拖长。bind 失败要明说,而不是静默丢掉设备。
|
|
||||||
waitDeviceBind(3 * time.Second)
|
|
||||||
oneshot(state, oneShotMsg)
|
oneshot(state, oneShotMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -302,9 +270,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
|
|||||||
addrLabel = cfg.Remote
|
addrLabel = cfg.Remote
|
||||||
}
|
}
|
||||||
if colors {
|
if colors {
|
||||||
fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
|
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
|
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
|
||||||
}
|
}
|
||||||
fmt.Println("Type /help for commands.")
|
fmt.Println("Type /help for commands.")
|
||||||
|
|
||||||
@ -511,26 +479,3 @@ func printServerEventColored(rl respLine, raw string) {
|
|||||||
fmt.Printf("%s%s%s\n", clearLine, text, colorReset)
|
fmt.Printf("%s%s%s\n", clearLine, text, colorReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitDeviceBind 等待服务端确认 bind(最多 timeout),返回是否确认。
|
|
||||||
//
|
|
||||||
// 用于一次性命令(-chat):桥启动后立刻退出会让设备来不及登记。
|
|
||||||
// 超时不报错(服务端可能只是慢),bind 明确被拒则打出来 —— 那通常意味着
|
|
||||||
// 设备令牌不对或设备未授权,用户需要知道,而不是以为「桥起来了就好了」。
|
|
||||||
func waitDeviceBind(timeout time.Duration) bool {
|
|
||||||
if deviceBridge == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if deviceBridge.Bound() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if reason := deviceBridge.BindError(); reason != "" {
|
|
||||||
printlnC(colorYellow, "device bridge bind rejected: "+reason)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return deviceBridge.Bound()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.10)
|
|
||||||
project(ha_codec VERSION 0.1.0 LANGUAGES C)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# ha_codec — HomeAgent 内核编解码层(C 实现)
|
|
||||||
#
|
|
||||||
# 零外部依赖,纯 C99。产出静态库供 homed 经 cgo 链接,
|
|
||||||
# 同时可独立用于其他端(鸿蒙 / 嵌入式 / C SDK)。
|
|
||||||
#
|
|
||||||
# 使用方式:
|
|
||||||
# add_subdirectory(path/to/csrc)
|
|
||||||
# target_link_libraries(my_app ha_codec)
|
|
||||||
# target_include_directories(my_app PRIVATE ${HA_CODEC_INCLUDE_DIR})
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
option(BUILD_SHARED_LIBS "Build ha_codec as shared library" OFF)
|
|
||||||
option(BUILD_TESTS "Build ha_codec tests" OFF)
|
|
||||||
option(BUILD_FUZZ "Build libFuzzer targets" OFF)
|
|
||||||
option(BUILD_BENCH "Build micro benchmarks" OFF)
|
|
||||||
|
|
||||||
# ★ C99 而非编译器默认档(clang 默认 gnu17)。
|
|
||||||
# 理由:内核 C 侧的编译契约是 C99(cgo CFLAGS 与这里必须一致),
|
|
||||||
# 在更新的默认档下编译会**静默**通过,而 Go 侧用 -std=c99 编不过
|
|
||||||
# —— 两边同时构建、行为却分叉,是最难查的一类问题。
|
|
||||||
# 实测:本轮 -Wpedantic 门禁就当场抓到过 C11 特性(_Static_assert)。
|
|
||||||
# C90/C95 不支持:ha_abi.h 用了 // 注释与 stdint。
|
|
||||||
# C11 开关保留给想验证「未来切到 C11 也不坏」的人。
|
|
||||||
set(CMAKE_C_STANDARD 99)
|
|
||||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
|
||||||
set(CMAKE_C_EXTENSIONS OFF) # 禁用 gnu99 扩展,严格 -std=c99
|
|
||||||
|
|
||||||
set(HA_CODEC_SRC
|
|
||||||
src/ha_codec.c
|
|
||||||
src/ha_json_scan.c
|
|
||||||
)
|
|
||||||
|
|
||||||
if(BUILD_SHARED_LIBS)
|
|
||||||
add_library(ha_codec SHARED ${HA_CODEC_SRC})
|
|
||||||
if(WIN32)
|
|
||||||
set_target_properties(ha_codec PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
|
||||||
endif()
|
|
||||||
else()
|
|
||||||
add_library(ha_codec STATIC ${HA_CODEC_SRC})
|
|
||||||
endif()
|
|
||||||
|
|
||||||
set(HA_CODEC_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
|
||||||
target_include_directories(ha_codec PUBLIC ${HA_CODEC_INCLUDE})
|
|
||||||
|
|
||||||
# 告警门禁:零告警才允许通过(与 Makefile 的 csrc-lint 同一标准)。
|
|
||||||
# C 侧没有 Go 的 vet 等价物,告警是唯一的静态信号。
|
|
||||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
|
||||||
add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# 不链接任何外部库 —— 保持与 ha_remotedevice 同一克制标准
|
|
||||||
target_link_libraries(ha_codec PRIVATE)
|
|
||||||
|
|
||||||
set(HA_CODEC_INCLUDE_DIR ${HA_CODEC_INCLUDE} CACHE INTERNAL "ha_codec include directories")
|
|
||||||
|
|
||||||
install(TARGETS ha_codec
|
|
||||||
EXPORT ha_codec-targets
|
|
||||||
LIBRARY DESTINATION lib
|
|
||||||
ARCHIVE DESTINATION lib
|
|
||||||
RUNTIME DESTINATION bin
|
|
||||||
INCLUDES DESTINATION include
|
|
||||||
)
|
|
||||||
install(DIRECTORY include/ DESTINATION include)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 测试
|
|
||||||
# ============================================================
|
|
||||||
if(BUILD_TESTS)
|
|
||||||
add_executable(ha_codec_test test/test_ha_codec.c)
|
|
||||||
target_link_libraries(ha_codec_test PRIVATE ha_codec)
|
|
||||||
add_executable(ha_json_scan_test test/test_ha_json_scan.c)
|
|
||||||
target_link_libraries(ha_json_scan_test PRIVATE ha_codec)
|
|
||||||
enable_testing()
|
|
||||||
add_test(NAME ha_codec_test COMMAND ha_codec_test)
|
|
||||||
add_test(NAME ha_json_scan_test COMMAND ha_json_scan_test)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 模糊测试:编码语义与内存安全的持续检验
|
|
||||||
#
|
|
||||||
# 动机:ha_codec 声称**逐值等价于 Go 参考实现**,其中最关键的一条是
|
|
||||||
# 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。
|
|
||||||
# 该行为在正常输入下永远测不到 —— 只有随机字节才能覆盖
|
|
||||||
# 截断序列 / 过长编码 / 代理对 / 超 U+10FFFF / 嵌入 NUL。
|
|
||||||
# Go 侧已有 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死;
|
|
||||||
# C 侧则需要独立验证两件事:
|
|
||||||
# 1. 任何输入都不崩、不越界(内存安全)
|
|
||||||
# 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等)
|
|
||||||
# 两者在 libFuzzer 上是持续的,而不是等下一次手写用例。
|
|
||||||
#
|
|
||||||
# 需 clang + -fsanitize=fuzzer;无则明确跳过(门禁不能假装通过)。
|
|
||||||
# ============================================================
|
|
||||||
if(BUILD_FUZZ)
|
|
||||||
if(CMAKE_C_COMPILER_ID MATCHES "Clang")
|
|
||||||
foreach(fz IN ITEMS test_fuzz_ha_codec test_fuzz_ha_json_scan)
|
|
||||||
add_executable(${fz} test/${fz}.c)
|
|
||||||
target_link_libraries(${fz} PRIVATE ha_codec)
|
|
||||||
target_compile_options(${fz} PRIVATE
|
|
||||||
-fsanitize=fuzzer,address,undefined
|
|
||||||
-fno-omit-frame-pointer)
|
|
||||||
target_link_options(${fz} PRIVATE
|
|
||||||
-fsanitize=fuzzer,address,undefined)
|
|
||||||
endforeach()
|
|
||||||
else()
|
|
||||||
message(WARNING
|
|
||||||
"BUILD_FUZZ=ON 需要 clang(libFuzzer);当前编译器是 "
|
|
||||||
"${CMAKE_C_COMPILER_ID},已跳过。")
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 微基准:C 侧自身的开销(与 Go 侧 codec_bench_test.go 对照)
|
|
||||||
#
|
|
||||||
# 为什么 C 侧也要基准:Go 侧基准里,「C 实现省下的时间」与「cgo 边界成本」
|
|
||||||
# 是混在一起的。若 C 侧本身在某场景很慢,改 Go 绑定无济于事;
|
|
||||||
# 必须在能隔离处(纯 C、无边界)测出函数体成本,才知道该优化谁。
|
|
||||||
# ============================================================
|
|
||||||
if(BUILD_BENCH)
|
|
||||||
add_executable(ha_codec_bench bench/bench_ha_codec.c)
|
|
||||||
target_link_libraries(ha_codec_bench PRIVATE ha_codec)
|
|
||||||
endif()
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
/*
|
|
||||||
* bench_ha_codec.c —— C 侧纯函数微基准(无 cgo 边界成本)
|
|
||||||
*
|
|
||||||
* ============================ 为什么 Go 侧基准不够 ============================
|
|
||||||
* Go 侧 codec_bench_test.go 测到的数 = **函数体成本 + cgo 边界成本**(约 30ns)
|
|
||||||
* 两项混在一起。后果:看到某个场景慢,分不清该优化 C 函数体,还是该减少
|
|
||||||
* 跨语言调用次数(或把循环整体 C 化批量传一次)—— 而这三者的处方完全不同。
|
|
||||||
* 只有在能隔离边界成本的地方(纯 C 循环)测,才知道该动谁。
|
|
||||||
*
|
|
||||||
* 用法:cmake -DBUILD_BENCH=ON && ./ha_codec_bench [reps]
|
|
||||||
*
|
|
||||||
* 覆盖与 Go 侧 benchInputs 对齐(empty / ascii_short / zh_short / zh_200 /
|
|
||||||
* ascii_1k / zh_1k),便于两张表直接对读。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#ifndef _POSIX_C_SOURCE
|
|
||||||
# define _POSIX_C_SOURCE 199309L
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <time.h>
|
|
||||||
|
|
||||||
#include "ha_codec.h"
|
|
||||||
|
|
||||||
/* 单调时钟(纳秒)。
|
|
||||||
*
|
|
||||||
* ★ 必须是 clock_gettime(不是 clock()、不是 time()):基准要测的是
|
|
||||||
* 几十纳秒级的函数体耗时,clock()/time() 的分辨率是**秒**,
|
|
||||||
* 拿它测 ns/op 只会得到一堆 0 或被量化成整数秒的噪声。
|
|
||||||
*
|
|
||||||
* ★ CLOCK_MONOTONIC 与 clock_gettime 都是 POSIX 而**非 ISO C99**,
|
|
||||||
* 而 CMake 刻意设了 CMAKE_C_EXTENSIONS OFF(严格 -std=c99)
|
|
||||||
* ⇒ 未定义这两个符号。实测报错:
|
|
||||||
* error: storage size of 'ts' isn't known
|
|
||||||
* error: implicit declaration of function 'clock_gettime'
|
|
||||||
* 这是 C 化门禁当场抓出的真实可移植性缺陷 —— 若靠 Makefile 的裸 gcc
|
|
||||||
* (默认 gnu17)构建,它会**静默编过**;而到别人的严格 C99 工具链上就炸。
|
|
||||||
*
|
|
||||||
* 故显式请求 POSIX 声明。_POSIX_C_SOURCE 必须在包含任何头文件**之前**
|
|
||||||
* 定义(否则 feature test macro 无效,这也是最常见的踩法)。
|
|
||||||
* Windows/MSVC 走 _MSC_VER 分支(用 QueryPerformanceCounter),
|
|
||||||
* 保证这个 bench 文件在异端也能编。 */
|
|
||||||
#if defined(_MSC_VER)
|
|
||||||
# include <windows.h>
|
|
||||||
static double now_sec(void) {
|
|
||||||
LARGE_INTEGER f, c;
|
|
||||||
QueryPerformanceFrequency(&f);
|
|
||||||
QueryPerformanceCounter(&c);
|
|
||||||
return (double)c.QuadPart / (double)f.QuadPart;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
# ifndef _POSIX_C_SOURCE
|
|
||||||
# define _POSIX_C_SOURCE 199309L
|
|
||||||
# endif
|
|
||||||
# include <time.h>
|
|
||||||
static double now_sec(void) {
|
|
||||||
struct timespec ts;
|
|
||||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
||||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* 分配并填充 reps 个 'x' 的缓冲(可含 NUL 之外的任意字节)。 */
|
|
||||||
static char *make_fill(size_t n, char ch) {
|
|
||||||
char *p = (char *)malloc(n ? n : 1);
|
|
||||||
if (p) memset(p, ch, n);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void bench_estimate(const char *name, const char *s, size_t len, int reps) {
|
|
||||||
/* 预热:把指令缓存与分支预测器带进稳态,否则首个样本的冷启动会
|
|
||||||
* 均摊到很少的迭代上(reps 小的时候误差极大)。 */
|
|
||||||
for (int i = 0; i < reps; i++) (void)ha_codec_estimate_tokens(s, len);
|
|
||||||
|
|
||||||
double t0 = now_sec();
|
|
||||||
int acc = 0;
|
|
||||||
for (int i = 0; i < reps; i++) {
|
|
||||||
acc += ha_codec_estimate_tokens(s, len);
|
|
||||||
}
|
|
||||||
double dt = now_sec() - t0;
|
|
||||||
|
|
||||||
double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0;
|
|
||||||
double mbs = (dt > 0) ? ((double)len * reps / dt / 1e6) : 0.0;
|
|
||||||
printf(" %-12s len=%7zu %9.2f ns/op %8.1f MB/s (acc=%d)\n",
|
|
||||||
name, len, ns, mbs, acc);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void bench_truncate(const char *name, const char *s, size_t len,
|
|
||||||
int max_tokens, int reps) {
|
|
||||||
for (int i = 0; i < reps; i++) {
|
|
||||||
(void)ha_codec_truncate_by_tokens(s, len, max_tokens);
|
|
||||||
}
|
|
||||||
double t0 = now_sec();
|
|
||||||
size_t acc = 0;
|
|
||||||
for (int i = 0; i < reps; i++) {
|
|
||||||
acc += ha_codec_truncate_by_tokens(s, len, max_tokens);
|
|
||||||
}
|
|
||||||
double dt = now_sec() - t0;
|
|
||||||
double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0;
|
|
||||||
printf(" %-12s len=%7zu %9.2f ns/op (keep=%zu)\n",
|
|
||||||
name, len, ns, acc / (size_t)reps);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
int reps = (argc > 1) ? atoi(argv[1]) : 200000;
|
|
||||||
if (reps <= 0) reps = 200000;
|
|
||||||
|
|
||||||
printf("== ha_codec C 侧微基准(reps=%d,纯 C 无 cgo 边界)==\n", reps);
|
|
||||||
printf("-- ha_codec_estimate_tokens --\n");
|
|
||||||
|
|
||||||
bench_estimate("empty", "", 0, reps);
|
|
||||||
bench_estimate("ascii_short", "hello world", 11, reps);
|
|
||||||
bench_estimate("zh_short", "用户询问了系统状态", 27, reps);
|
|
||||||
{
|
|
||||||
char *zh200 = make_fill(180, 'a'); /* 逐字节非 ASCII 由下方覆盖 */
|
|
||||||
bench_estimate("ascii_200", zh200, 180, reps);
|
|
||||||
free(zh200);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
char *zh = make_fill(1024, 'x');
|
|
||||||
bench_estimate("ascii_1k", zh, 1024, reps);
|
|
||||||
free(zh);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
/* 真实中文:每字 3 字节 = 1024 字节 ≈ 341 rune */
|
|
||||||
char *zh = make_fill(1023, 'x');
|
|
||||||
for (size_t i = 0; i + 2 < 1024; i += 3) {
|
|
||||||
zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0;
|
|
||||||
}
|
|
||||||
bench_estimate("zh_1k", zh, 1024, reps);
|
|
||||||
free(zh);
|
|
||||||
}
|
|
||||||
|
|
||||||
printf("-- ha_codec_truncate_by_tokens (max_tokens=64) --\n");
|
|
||||||
{
|
|
||||||
char *a1k = make_fill(1024, 'x');
|
|
||||||
bench_truncate("ascii_1k", a1k, 1024, 64, reps);
|
|
||||||
free(a1k);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
char *zh = make_fill(1023, 'x');
|
|
||||||
for (size_t i = 0; i + 2 < 1024; i += 3) {
|
|
||||||
zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0;
|
|
||||||
}
|
|
||||||
bench_truncate("zh_1k", zh, 1024, 64, reps);
|
|
||||||
free(zh);
|
|
||||||
}
|
|
||||||
|
|
||||||
printf("-- ha_codec_model_context_window (含/不含匹配) --\n");
|
|
||||||
{
|
|
||||||
const char *models[4] = {
|
|
||||||
"deepseek/deepseek-v4.1-flash", "gpt-4-turbo", "qwen-max", "AUTO"
|
|
||||||
};
|
|
||||||
for (int w = 0; w < 4; w++) {
|
|
||||||
size_t l = strlen(models[w]);
|
|
||||||
for (int i = 0; i < reps; i++) {
|
|
||||||
(void)ha_codec_model_context_window(models[w], l);
|
|
||||||
}
|
|
||||||
double t0 = now_sec();
|
|
||||||
int acc = 0;
|
|
||||||
for (int i = 0; i < reps; i++) {
|
|
||||||
acc += ha_codec_model_context_window(models[w], l);
|
|
||||||
}
|
|
||||||
double dt = now_sec() - t0;
|
|
||||||
printf(" %-30s %9.2f ns/op (win=%d)\n", models[w],
|
|
||||||
(reps > 0) ? dt * 1e9 / reps : 0.0, acc / reps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printf("-- ABI --\n");
|
|
||||||
printf(" ha_codec_abi_version = %d\n", ha_codec_abi_version());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
#ifndef HA_ABI_H
|
|
||||||
#define HA_ABI_H
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ha_abi.h — HomeAgent C 库的 ABI 版本契约
|
|
||||||
*
|
|
||||||
* ============================ 为什么需要它 ============================
|
|
||||||
* ha_codec.h 声明「签名一经发布即冻结」,但**冻结只写在注释里**——注释不
|
|
||||||
* 参与编译,Go/C 两侧对「我以为的版本」不一致时没有任何机制会报错。
|
|
||||||
* 本头文件把冻结变成**编译期与测试期可断言的事实**:
|
|
||||||
*
|
|
||||||
* 1. 每个 C 库声明自己的 ABI 主/次版本(HA_CODEC_ABI_MAJOR/MINOR)。
|
|
||||||
* 2. Go 侧(internal/agent/api/codec_cgo.go)持有一份 Go 常量副本,
|
|
||||||
* 由 TestABIVersionMatches 比对 C 宏 —— 版本漂移**在测试里判红**,
|
|
||||||
* 而不是等到线上表现为「插件行为诡异」才排查。
|
|
||||||
* 3. 主版本不同 = ABI 不兼容,必须走大版本流程(与 homeagent-sdk 同一标准)。
|
|
||||||
*
|
|
||||||
* ============================ 改动规则 ============================
|
|
||||||
* - 只增不改、只加不改:新增函数/字段 → MINOR+1
|
|
||||||
* - 改签名、删函数、改结构体布局 → MAJOR+1(且所有调用方必须同步重编)
|
|
||||||
* - 纯内部实现优化(不动任何声明)→ 不动版本号
|
|
||||||
*
|
|
||||||
* ⚠️ 与 homeagent-sdk 的 C ABI 不同:本项目的 C 库是**源码内联编译**
|
|
||||||
* (Go 侧符号链接 csrc/ 权威源,见 codec_cgo.go 顶部),不存在跨版本
|
|
||||||
* 混链的 .so/.a,所以「同批重建」是天然成立的——版本宏的作用是
|
|
||||||
* **防语义漂移**(两侧对同一组函数的理解不一致),不是防二进制不兼容。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ==================== 编译期断言(C99/C11 兼容) ==================== */
|
|
||||||
|
|
||||||
/* 静态断言:版本号写错必须在编译期就炸,不能带着荒谬版本号发布出去。
|
|
||||||
*
|
|
||||||
* ★ C99 没有 _Static_assert(那是 C11),而本项目 C 侧统一 -std=c99
|
|
||||||
* (见 codec_cgo.go 的 cgo CFLAGS 与 CMakeLists 的 C_STANDARD)。故需兼容垫片:
|
|
||||||
* C11+ 用原生 _Static_assert;C99 回退到「数组维度为 0 即编译失败」的老写法。
|
|
||||||
* 这条垫片是 -Wall -Wextra -Wpedantic 门禁上线时**当场抓出来的**(首次编译即告警),
|
|
||||||
* 即基础设施已经开始在发挥作用。
|
|
||||||
*
|
|
||||||
* 用法:第二个参数必须是**标识符**(不能是字符串)——C99 分支要用它 ## 成
|
|
||||||
* 一个 typedef 名,而 `##` 不能拼接字符串字面量(拼接会直接编译报错)。
|
|
||||||
* 原生 _Static_assert 分支则把它当 msg 传(此时它在诊断里显示为标识符,
|
|
||||||
* 仍能指出是哪个断言)。同一文件内每个断言的 tag 必须不同。 */
|
|
||||||
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
|
|
||||||
# define HA_STATIC_ASSERT(cond, tag) _Static_assert(cond, #tag)
|
|
||||||
#elif defined(__cplusplus) && __cplusplus >= 201103L
|
|
||||||
# define HA_STATIC_ASSERT(cond, tag) static_assert(cond, #tag)
|
|
||||||
#else
|
|
||||||
# define HA_STATIC_ASSERT(cond, tag) \
|
|
||||||
typedef char ha_sa_##tag##_line_##__LINE__[(cond) ? 1 : -1]
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ==================== ABI 版本 ==================== */
|
|
||||||
|
|
||||||
/* 编码语义主版本:改动任一已发布函数的语义/签名时 +1。
|
|
||||||
* 2026-09-26:首版 1.0(上下文窗口推断 + token 估算/截断)。 */
|
|
||||||
#define HA_CODEC_ABI_MAJOR 1
|
|
||||||
|
|
||||||
/* 编码语义次版本:纯新增(加函数、加枚举值)时 +1。 */
|
|
||||||
#define HA_CODEC_ABI_MINOR 0
|
|
||||||
|
|
||||||
/* 合成版号,便于日志/断言单值比较:major*1000 + minor */
|
|
||||||
#define HA_CODEC_ABI_VERSION (HA_CODEC_ABI_MAJOR * 1000 + HA_CODEC_ABI_MINOR)
|
|
||||||
|
|
||||||
/* 编译期锁死:ABI 版本必须落在「已知的、未被遗忘的」区间。
|
|
||||||
* 若有人把版本号改成 0 或 999 之类(通常是手滑/拷贝粘贴出错),
|
|
||||||
* 编译立即失败,而不是带着一个荒谬的版本号发布出去。 */
|
|
||||||
HA_STATIC_ASSERT(HA_CODEC_ABI_MAJOR >= 1 && HA_CODEC_ABI_MAJOR <= 9,
|
|
||||||
ha_codec_abi_major_in_range);
|
|
||||||
HA_STATIC_ASSERT(HA_CODEC_ABI_MINOR >= 0 && HA_CODEC_ABI_MINOR <= 99,
|
|
||||||
ha_codec_abi_minor_in_range);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_ABI_H */
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
#ifndef HA_CODEC_H
|
|
||||||
#define HA_CODEC_H
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ha_codec — HomeAgent 内核编解码层(C 实现)
|
|
||||||
*
|
|
||||||
* ============================ 接口冻结声明 ============================
|
|
||||||
* 本头文件是对外契约。函数签名、语义、返回值一经发布即为冻结接口,
|
|
||||||
* 修改必须走大版本流程(与 third_party/homeagent-sdk 同一冻结标准)。
|
|
||||||
*
|
|
||||||
* 设计约束(见 docs/zh/c-core/llm-orchestration-c.md §四):
|
|
||||||
* 1. 只吃 const char* + **显式长度**,出数值/字节偏移 —— 不回调 Go、
|
|
||||||
* 不传 Go 指针、不要求 NUL 结尾
|
|
||||||
* 2. **不 malloc**:不需要出参缓冲区,需要「结果」时返回字节偏移/长度,
|
|
||||||
* 由调用方在自己的缓冲上切片(零拷贝)
|
|
||||||
* 3. 无状态、纯函数、线程安全(不写全局可变状态)
|
|
||||||
*
|
|
||||||
* 当前覆盖:L1 协议编解码层中的纯计算部分(第一个最小切片)。
|
|
||||||
*
|
|
||||||
* ============================ 为什么签名带长度 ============================
|
|
||||||
* 初版签名用 `const char*` 隐含「NUL 结尾」,于是每次调用都要:
|
|
||||||
* Go `C.CString` 分配+拷贝一遍 → C `strlen` 再扫一遍。
|
|
||||||
* 实测这部分开销占单次调用的 80% 以上(cgo 边界本身仅 ~30ns,
|
|
||||||
* 而初版 ModelContextWindow 实测 175ns)。
|
|
||||||
* 改为「指针 + 长度」后,Go 侧用 unsafe.StringData 直接传底层数组,
|
|
||||||
* 零分配零拷贝。这是设计约束第 1 条的字面要求。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#include "ha_abi.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ==================== ABI 自述(供 Go 侧与日志核对) ==================== */
|
|
||||||
|
|
||||||
/* 返回 HA_CODEC_ABI_VERSION(major*1000 + minor)。
|
|
||||||
*
|
|
||||||
* 存在的意义:Go 侧不该靠 `#include` 宏做版本断言(cgo 头文件里的宏在
|
|
||||||
* 预处理后不可见),而要**运行期/测试期**能问 C 侧「你自称什么版本」。
|
|
||||||
* 由 codec_cgo.go 绑定、codec_abiversion_test.go 与 C 侧宏三方比对。 */
|
|
||||||
int ha_codec_abi_version(void);
|
|
||||||
|
|
||||||
/* ==================== 模型上下文窗口推断 ==================== */
|
|
||||||
|
|
||||||
/* 无法从模型名推断时的哨兵值(与 Go 侧一致)。
|
|
||||||
*
|
|
||||||
* 为什么返回哨兵而不是直接给兜底值:调用方需要区分「真推断出了」与
|
|
||||||
* 「推断不出、只能兜底」——后者要打一行日志(窗口被低估必须可见),
|
|
||||||
* 并提示部署方用 per-source context_window 显式声明。
|
|
||||||
* 若 C 侧直接返回兜底值,调用方就永远分不清这两种情况。 */
|
|
||||||
#define HA_CODEC_CONTEXT_WINDOW_UNKNOWN (-1)
|
|
||||||
|
|
||||||
/* 由模型名推断最大上下文窗口(token 数);推断不出返回
|
|
||||||
* HA_CODEC_CONTEXT_WINDOW_UNKNOWN。
|
|
||||||
*
|
|
||||||
* model 为 UTF-8 字节序列,**不需要 NUL 结尾**;model_len 是字节数。
|
|
||||||
* model 为 NULL 或 model_len 为 0 时返回 UNKNOWN。
|
|
||||||
*
|
|
||||||
* 匹配大小写不敏感(仅对 ASCII 字母做折叠;非 ASCII 字节按原样比较,
|
|
||||||
* 与 Go 侧对模型名的实际输入一致)。
|
|
||||||
*
|
|
||||||
* 语义必须与 Go 侧 modelContextWindowPure 逐值一致(黄金对照测试钉死)。 */
|
|
||||||
int ha_codec_model_context_window(const char *model, size_t model_len);
|
|
||||||
|
|
||||||
/* ==================== token 估算与截断 ==================== */
|
|
||||||
|
|
||||||
/* 粗略估算 token 数。
|
|
||||||
*
|
|
||||||
* 规则(与 Go 侧 EstimateTokens 一致):保守取 max(1, runeCount * 2)。
|
|
||||||
* 按 UTF-8 **字符数**(rune)计,不是字节数。
|
|
||||||
* text 为 NULL 或 text_len 为 0 返回 0。
|
|
||||||
*
|
|
||||||
* 非法 UTF-8 序列按 Go 的 utf8 解码语义处理(每字节一个 rune),
|
|
||||||
* 保证与 Go 侧逐值一致。 */
|
|
||||||
int ha_codec_estimate_tokens(const char *text, size_t text_len);
|
|
||||||
|
|
||||||
/* 按 token 预算计算「应保留的字节数」。
|
|
||||||
*
|
|
||||||
* ★ 返回的是**字节数**而非字符串:截断结果必然是输入的前缀,
|
|
||||||
* 调用方直接在自己的缓冲上切片即可(零拷贝、无出参缓冲区、无 malloc)。
|
|
||||||
*
|
|
||||||
* 语义与 Go 侧 TruncateByTokens 一致:从开头保留 maxTokens/2 个 rune;
|
|
||||||
* 未超预算时返回 text_len(即整串)。
|
|
||||||
* max_tokens <= 0 或 text 为 NULL/text_len 为 0 时返回 0。
|
|
||||||
*
|
|
||||||
* 返回值保证 <= text_len。 */
|
|
||||||
size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len,
|
|
||||||
int max_tokens);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_CODEC_H */
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
#ifndef HA_JSON_SCAN_H
|
|
||||||
#define HA_JSON_SCAN_H
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ha_json_scan — HomeAgent 内核 LLM 协议层的 JSON 扫描/取值层(C 实现)
|
|
||||||
*
|
|
||||||
* ============================ 定位 ============================
|
|
||||||
* 本库**不是**通用 JSON 库,是**流式协议分块解析**专用的零分配扫描层。
|
|
||||||
* 它服务 `parseOpenAICompatibleStreamChunkFull`(每个 SSE chunk 跑一次的最热路径)。
|
|
||||||
*
|
|
||||||
* ★ 为什么不复用 SDK 的 remotedevice/src/ha_json.c(实测,见 plan.md §七):
|
|
||||||
* 1. 无 `\u` 解码 —— `\u4f60\u597d` 得到 `?0?d?d?0`(LLM 内容全靠转义时直接损坏)
|
|
||||||
* 2. 只有 `_get_int`,无浮点 —— `temperature:0.7` **静默**变 0
|
|
||||||
* 3. `null` 与「键缺失」不可区分
|
|
||||||
* 4. 架构是 **DOM + malloc**,与「不 malloc / 零拷贝 / 纯函数」正交
|
|
||||||
* 它的定位是 remotedevice 设备通道,不是 LLM 协议层。
|
|
||||||
*
|
|
||||||
* ============================ 设计:scan / extract 两段分离 ============================
|
|
||||||
* **scan** 只出结构 span(键 span / 值 span),零分配、零解码、零求值。
|
|
||||||
* **extract** 按 span 取值,解码只发生在真正需要它的调用方身上。
|
|
||||||
*
|
|
||||||
* 为什么必须分离:`content` 可能是很大的多模态数组,而 `stringifyContent`
|
|
||||||
* 只需要「把 text 字段拼起来」。若 scan 阶段就为每个字符串 `\u` 解码并分配
|
|
||||||
* 缓冲,等于把解码成本付给了不需要它的调用方 —— 那正是我们要消灭的分配。
|
|
||||||
*
|
|
||||||
* ============================ 不可协商的约束(与 ha_codec.h 同标准) ============================
|
|
||||||
* 1. 只吃 `const char*` + **显式长度**,不要求 NUL 结尾
|
|
||||||
* (否则又是 `strlen` + 拷贝的老问题,见第一刀 §7.1 的 82% 自找开销)
|
|
||||||
* 2. **不 malloc**:结果一律以 span(指针+长度)回给调用方,Go 侧零拷贝切片
|
|
||||||
* 3. 无状态、纯函数、线程安全(不写全局可变状态)
|
|
||||||
* 4. 语法语义必须与 Go `encoding/json` **一致**,由黄金对照测试钉死
|
|
||||||
*
|
|
||||||
* ============================ 语义对齐(易踩,全部实测) ============================
|
|
||||||
* - 键匹配**大小写不敏感**(Go `encoding/json` 行为)
|
|
||||||
* - 字符串取值时非法 UTF-8 每字节替换为 U+FFFD(与 Go 一致)
|
|
||||||
* - 重复键**后者胜**
|
|
||||||
* - 本层**不做类型检查**:`{"a":{}}` 对 `a` 的扫描成功,是否「类型不对应报错」
|
|
||||||
* 由调用方按 Go 的 interface{} / 强类型语义决定(见 §三.2.2 的实测)
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#include "ha_abi.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ==================== ABI 版本(与 ha_abi.h 同步) ==================== */
|
|
||||||
|
|
||||||
#define HA_JSON_SCAN_ABI_MAJOR 1
|
|
||||||
#define HA_JSON_SCAN_ABI_MINOR 0
|
|
||||||
#define HA_JSON_SCAN_ABI_VERSION \
|
|
||||||
(HA_JSON_SCAN_ABI_MAJOR * 1000 + HA_JSON_SCAN_ABI_MINOR)
|
|
||||||
|
|
||||||
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MAJOR >= 1 && HA_JSON_SCAN_ABI_MAJOR <= 9,
|
|
||||||
ha_jsonscan_abi_major_in_range);
|
|
||||||
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MINOR >= 0 && HA_JSON_SCAN_ABI_MINOR <= 99,
|
|
||||||
ha_jsonscan_abi_minor_in_range);
|
|
||||||
|
|
||||||
/* 返回 HA_JSON_SCAN_ABI_VERSION(供 Go 侧与日志核对)。 */
|
|
||||||
int ha_json_scan_abi_version(void);
|
|
||||||
|
|
||||||
/* ==================== span 与扫描器 ==================== */
|
|
||||||
|
|
||||||
/* 字节区间 [p, p+len)。指针指向**调用方的原缓冲**,本库从不持有或释放。 */
|
|
||||||
typedef struct {
|
|
||||||
const char *p;
|
|
||||||
size_t len;
|
|
||||||
} ha_span;
|
|
||||||
|
|
||||||
/* 扫描器:对一段 JSON 文本的只读游标。
|
|
||||||
*
|
|
||||||
* ★ 就地结构体(非指针):调用方在栈上持有,零分配。
|
|
||||||
* 但因此**不可拷贝后混用**(拷贝出的副本与原游标各自独立推进)。
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
const char *s; /* 缓冲区起点 */
|
|
||||||
size_t n; /* 缓冲区长度 */
|
|
||||||
size_t i; /* 当前游标偏移 */
|
|
||||||
} ha_json_scan;
|
|
||||||
|
|
||||||
/* 用 (s, n) 初始化扫描器,游标置于起点。s 可为 NULL(此时按 n=0 处理)。 */
|
|
||||||
void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n);
|
|
||||||
|
|
||||||
/* 跳过前导 ASCII 空白(空格 / \t / \n / \r)。返回是否已到结尾。 */
|
|
||||||
int ha_json_scan_ws(ha_json_scan *sc);
|
|
||||||
|
|
||||||
/* 当前是否已到结尾(不含空白跳过)。 */
|
|
||||||
int ha_json_scan_eof(const ha_json_scan *sc);
|
|
||||||
|
|
||||||
/* 跳过**一个完整的 JSON 值**(对象 / 数组 / 字符串 / 数字 / 字面量)。
|
|
||||||
*
|
|
||||||
* 用于二次进数组内部(如 stringifyContent 取数组元素的 text 字段):
|
|
||||||
* 先 skip 前面的元素,再对目标元素单独扫描。
|
|
||||||
* 返回 0 表示语法错误,1 表示成功。成功后游标停在该值之后。
|
|
||||||
*/
|
|
||||||
int ha_json_skip(ha_json_scan *sc);
|
|
||||||
|
|
||||||
/* 解析一个字符串值,出**原始字节 span**(含转义序列,未解码)。
|
|
||||||
*
|
|
||||||
* 入参:游标应停在 `"` 上(或之前的空白,函数自己跳过空白)。
|
|
||||||
* 出参 raw:不含两端引号的原始内容 span(指向原缓冲,零拷贝)。
|
|
||||||
* 返回 0 = 语法错误(未闭合 / 非字符串)。
|
|
||||||
*
|
|
||||||
* ★ 注意:不做 `\u` 解码、不做非法 UTF-8 替换 —— 那是 extract 阶段的事。
|
|
||||||
*/
|
|
||||||
int ha_json_scan_string(ha_json_scan *sc, ha_span *raw);
|
|
||||||
|
|
||||||
/* ==================== 顶层对象:扫描出键值对 ==================== */
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 顶层对象的迭代器。
|
|
||||||
*
|
|
||||||
* ★ 为什么由本库来切「顶层逗号」而不是让 C 侧只解析第一个键:
|
|
||||||
* LLM 的 `content` 里常含 `{`、`}`、`,`(代码、JSON 片段、模板)。
|
|
||||||
* 若调用方自己按逗号切开顶层,会被内容里的逗号错切。
|
|
||||||
* 本库扫**字符串感知**的边界,保证只在真正的顶层分隔符处切分。
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
ha_json_scan sc; /* 游标 */
|
|
||||||
int started; /* 是否已消费过至少一个成员 */
|
|
||||||
int done; /* 迭代是否已结束(正常或异常) */
|
|
||||||
int error; /* 结束原因:1 = 输入畸形(而非正常的 '}') */
|
|
||||||
} ha_json_members;
|
|
||||||
|
|
||||||
/* 初始化顶层对象迭代。非法(首个非空白字符不是 '{')时返回 0。 */
|
|
||||||
int ha_json_members_init(ha_json_members *m, const char *s, size_t n);
|
|
||||||
|
|
||||||
/* 取下一个成员。
|
|
||||||
*
|
|
||||||
* 出参:
|
|
||||||
* key —— 键的原始字节 span(未解码,不含引号);可为 NULL
|
|
||||||
* val —— 值的**完整 span**(未解码);可为 NULL
|
|
||||||
*
|
|
||||||
* 返回: 1 = 拿到一个完整成员;0 = 结束。
|
|
||||||
*
|
|
||||||
* ★ 本函数**内部会完整跳过一个值**,因此:
|
|
||||||
* 1. 返回 1 蕴含「这个成员是良构的」(值能独立被 skip)——
|
|
||||||
* 调用方拿到的 val 一定可解析,不必自己再验一次。
|
|
||||||
* 2. 游标在返回前已推进到值之后,下一次调用直接看下一个成员。
|
|
||||||
* (早期版本只报值的**起始位置**、不消费值,迫使调用方自己
|
|
||||||
* 修正游标 —— 那是个错误的设计:调用方一旦忘了推进,下一个
|
|
||||||
* 成员就会解析到上一个值,而模糊测试立刻把它暴露了出来。)
|
|
||||||
*
|
|
||||||
* ★ 结束时要区分原因:用 ha_json_members_complete() 判断是否正常。
|
|
||||||
* 返回 0 既可能是「正常扫到 '}'」也可能是「输入畸形」——
|
|
||||||
* 要复刻 Go 的严格性(畸形 ⇒ 整块作废)就必须能分辨。
|
|
||||||
*
|
|
||||||
* ★ 键匹配请用 ha_json_key_eq(大小写不敏感),不要自己 memcmp。
|
|
||||||
*/
|
|
||||||
int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val);
|
|
||||||
|
|
||||||
/* 迭代是否**正常结束**(消费到闭合的 '}')。
|
|
||||||
*
|
|
||||||
* 语义:只有在 next() 返回 0 之后才有意义。
|
|
||||||
* 返回 1 = 对象良构且已完整扫描;0 = 输入畸形(缺 '}' / 尾逗号 /
|
|
||||||
* 值非法等)。调用方若要复刻 Go 的严格性,应要求它为 1。
|
|
||||||
*/
|
|
||||||
int ha_json_members_complete(const ha_json_members *m);
|
|
||||||
|
|
||||||
/* 大小写不敏感地比较键 span 与 ASCII 字面量。返回 1/0。
|
|
||||||
*
|
|
||||||
* ★ 必须用它而不是 memcmp:Go `encoding/json` 的键匹配**大小写不敏感**,
|
|
||||||
* 实测 `{"delta":{"CONTENT":"up"}}` 能取出 content="up"。
|
|
||||||
* 逐字节比对会静默漏掉这类输入。 */
|
|
||||||
int ha_json_key_eq(ha_span key, const char *name);
|
|
||||||
|
|
||||||
/* ==================== 取值(extract) ==================== */
|
|
||||||
|
|
||||||
/* 字符串解码的**写入回调**。
|
|
||||||
*
|
|
||||||
* ★ 为什么用回调而不是「分配缓冲返回」:本库不 malloc。调用方把自己的
|
|
||||||
* Go 侧 buffer / 栈缓冲 / 直接写目标的位置交给本库,解码结果逐个 rune
|
|
||||||
* 以 UTF-8 字节写入 —— 非法序列按 Go 语义替换为 U+FFFD。
|
|
||||||
*
|
|
||||||
* ★ 为什么按 rune 而不是按字节:`\uXXXX` 可能产生多字节 rune(含代理对
|
|
||||||
* 合成的 4 字节 emoji),调用方不该关心编码细节。
|
|
||||||
*/
|
|
||||||
typedef void (*ha_json_sink)(void *ctx, const char *utf8_bytes, size_t len);
|
|
||||||
|
|
||||||
/* 把字符串值 span(raw 形式,含转义)解码并经 sink 输出。
|
|
||||||
*
|
|
||||||
* 出参 out_len:解码后的字节总数(便于调用方预分配 / 校验)。
|
|
||||||
* 返回 0 = 原始 span 含**语法错误**(如 \u 后不是 4 位十六进制)。
|
|
||||||
*
|
|
||||||
* 非法 UTF-8 处理:与 Go `encoding/json` 一致 —— 每个非法字节一个 U+FFFD
|
|
||||||
* (**不是**按整个序列丢弃)。见真值表 §2.8。
|
|
||||||
*/
|
|
||||||
int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx,
|
|
||||||
size_t *out_len);
|
|
||||||
|
|
||||||
/* 把字符串值 span 解码进调用方提供的缓冲(不足则失败,不截断)。
|
|
||||||
*
|
|
||||||
* 返回写入的字节数;缓冲不足时返回 (size_t)-1 且不写。
|
|
||||||
* 适合长度已知且不关心「只需长度」的场景。
|
|
||||||
*/
|
|
||||||
size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap);
|
|
||||||
|
|
||||||
/* 读整数(仅接受 JSON 整数语法,可选负号;不允许小数点/指数)。
|
|
||||||
*
|
|
||||||
* ★ 与 Go 的对应关系:Go 里 `int` 字段会接受 `1e2`(=100)与拒绝 `1.5`;
|
|
||||||
* 本函数**只认纯整数**,指数/小数由调用方按「类型不匹配 ⇒ 整块作废」
|
|
||||||
* 语义处理(见真值表 §2.2)。这样职责清晰:本层只回答「这是不是整数」。
|
|
||||||
*
|
|
||||||
* 返回 1 = 成功且 *out 已写;0 = 不是合法整数。
|
|
||||||
* 溢出返回 0(与 Go 报错等价)。
|
|
||||||
*/
|
|
||||||
int ha_json_get_int(ha_span raw, long long *out);
|
|
||||||
|
|
||||||
/* ==================== 字符串取值的便捷路径 ==================== */
|
|
||||||
|
|
||||||
/* 在对象 span 内取键 name 的字符串值,解码进 out(NUL 结尾)。
|
|
||||||
*
|
|
||||||
* 返回:解码后字节数(不含结尾 NUL);键缺失 / 类型不是字符串 / 缓冲不足
|
|
||||||
* 返回 (size_t)-1。out 在成功时保证 NUL 结尾。
|
|
||||||
*
|
|
||||||
* 便捷函数:内部走 members 迭代 + key_eq + decode,适合调用方只取一两个键
|
|
||||||
* 且不需要「类型不匹配 ⇒ 整块作废」细节的场景。
|
|
||||||
*/
|
|
||||||
size_t ha_json_object_get_string(ha_span obj, const char *name,
|
|
||||||
char *out, size_t out_cap);
|
|
||||||
|
|
||||||
/* 在对象 span 内取键 name 的整数值。
|
|
||||||
* 返回 1 = 成功;0 = 键缺失 / 非合法整数 / 溢出。 */
|
|
||||||
int ha_json_object_get_int(ha_span obj, const char *name, long long *out);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_JSON_SCAN_H */
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
#ifndef HA_SSE_H
|
|
||||||
#define HA_SSE_H
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ha_sse — LLM 流式协议(SSE 分块)的「结构导航」辅助层
|
|
||||||
*
|
|
||||||
* ============================ 定位 ============================
|
|
||||||
* 本层不是 JSON 库(那是 ha_json_scan),而是把 ha_json_scan 原语组合成
|
|
||||||
* **协议层需要的几次定位**,供内核 parseOpenAICompatibleStreamChunkFull 使用。
|
|
||||||
*
|
|
||||||
* ★ 为什么这些函数放在 csrc/ 而不是内联在 Go 的 cgo 前言里:
|
|
||||||
* 放在 cgo 前言里的 C 代码**逃出了全部 C 门禁**(告警 / ASan+UBSan /
|
|
||||||
* 交叉编译 / 模糊测试),而它恰恰是本刀最容易出错的位置。
|
|
||||||
* 移进 csrc/ 后,同一个 -Wall -Wextra -Wpedantic -Wconversion 门禁
|
|
||||||
* 与 sanitizer 都覆盖到它 —— 这是一次真实的结构调整,不是形式主义。
|
|
||||||
*
|
|
||||||
* ============================ 为什么只做「导航」 ============================
|
|
||||||
* 实测两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5)使「全量 C 化」不成立:
|
|
||||||
* §5.1 重复键是**字段级合并**(json.Unmarshal 的 SetIndex 叠加语义)
|
|
||||||
* §5.2 stringifyContent 的 default 分支是 json.Marshal(键排序 / 浮点
|
|
||||||
* 最短往返 / HTML 转义 / int 舍入)
|
|
||||||
* 二者都只在**取值**阶段需要,故本层只回答「值在哪里、它的热分支结果是什么」,
|
|
||||||
* 需要重新序列化的形态交回 Go(由 encoding/json 保证语义)。
|
|
||||||
*
|
|
||||||
* ============================ 键匹配:大小写敏感 ============================
|
|
||||||
* 本层是 **map key** 语义(`m["text"]`)⇒ 大小写敏感。
|
|
||||||
* 实测 `{"TEXT":"up"}` 取不到 `text`、`{"text":"low"}` 可以(§5.4-1)。
|
|
||||||
*
|
|
||||||
* ⚠️ 与 ha_json_key_eq(大小写**不**敏感,用于 struct 字段名)语义相反。
|
|
||||||
* 两者用途不同、都必要,**不要「统一」掉**。
|
|
||||||
* struct 字段那一跳由 encoding/json 负责,天然正确。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#include "ha_abi.h"
|
|
||||||
#include "ha_json_scan.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define HA_SSE_ABI_MAJOR 1
|
|
||||||
#define HA_SSE_ABI_MINOR 1
|
|
||||||
#define HA_SSE_ABI_VERSION (HA_SSE_ABI_MAJOR * 1000 + HA_SSE_ABI_MINOR)
|
|
||||||
|
|
||||||
HA_STATIC_ASSERT(HA_SSE_ABI_MAJOR >= 1 && HA_SSE_ABI_MAJOR <= 9,
|
|
||||||
ha_sse_abi_major_in_range);
|
|
||||||
HA_STATIC_ASSERT(HA_SSE_ABI_MINOR >= 0 && HA_SSE_ABI_MINOR <= 99,
|
|
||||||
ha_sse_abi_minor_in_range);
|
|
||||||
|
|
||||||
int ha_sse_abi_version(void);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 在对象里按**大小写敏感**的键定位值。
|
|
||||||
*
|
|
||||||
* 返回: 1 = 找到(*out 已写);0 = 未找到(对象良构);-1 = 对象畸形。
|
|
||||||
* *dup 在发现**重复键**时置 1(后者胜已写入 *out)——
|
|
||||||
* 调用方据此整体回退到 encoding/json,因为重复键的字段级合并语义
|
|
||||||
* 见 sse-codec-c.md §5.1,本层不实现。
|
|
||||||
*/
|
|
||||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 在对象里按**大小写不敏感**的键定位值(struct 字段语义)。
|
|
||||||
*
|
|
||||||
* ★ 为什么必须与 ha_sse_obj_find 并存(两个函数,语义相反):
|
|
||||||
* - Go 的 `raw struct{ Choices ... \`json:"choices"\` }` 是 **struct 字段**,
|
|
||||||
* encoding/json 对字段名做**大小写不敏感**匹配 ⇒ 实测
|
|
||||||
* `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 能取到 content="ci"。
|
|
||||||
* - 而 `content` 是 `interface{}` → `map[string]interface{}`,取 `m["text"]`
|
|
||||||
* 是 **map key** 语义 ⇒ 大小写**敏感**(实测 `{"TEXT":"up"}` 取不到)。
|
|
||||||
* 跳错层就会静默漏掉字段(或取到不该取的),故两个函数都必要,
|
|
||||||
* 调用方必须按「这一跳在 Go 里是 struct 还是 map」来选择。
|
|
||||||
*
|
|
||||||
* 返回与 ha_sse_obj_find 相同:1=找到 0=未找到 -1=畸形。
|
|
||||||
* 对 *dup:大小写不敏感语义下,`{"CHOICES":..,"choices":..}` 两次都会命中
|
|
||||||
* 同一个 Go 字段(后者胜),故同样置 dup 让调用方回退。
|
|
||||||
*/
|
|
||||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 校验 doc 是「**恰好一个**良构 JSON 对象」(尾部只允许空白)。
|
|
||||||
*
|
|
||||||
* 返回 1 = 是;0 = 否。
|
|
||||||
*
|
|
||||||
* ★ 为什么必须单独校验尾部:ha_sse_obj_find 用 members_complete 只保证
|
|
||||||
* 对象本身闭合,**不检查尾部残留** —— 而 Go 的 json.Unmarshal 会拒绝
|
|
||||||
* `{"a":1}{"b":2}`(trailing garbage)。少了这一步,快速路径会比 Go 宽松,
|
|
||||||
* 把一个 Go 判为失败的块判为成功 ⇒ 静默接受垃圾块。
|
|
||||||
*/
|
|
||||||
int ha_sse_root_object(const ha_span *doc);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 取数组**第一个元素**的 span。
|
|
||||||
*
|
|
||||||
* 返回: 1 = 有元素;0 = 空数组;-1 = 非数组或畸形。
|
|
||||||
*
|
|
||||||
* 为什么只要第一个:`choices[0]` 是协议约定(Go 侧也只读 resp.Choices[0]),
|
|
||||||
* 本层据此避免为后续元素做无用功。
|
|
||||||
*/
|
|
||||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* stringifyContent 的 **C 可判定分支**:
|
|
||||||
* - 字符串值 → 反转义后原样输出
|
|
||||||
* - 数组值 → 逐元素取对象的 "text" 字段(精确键)拼接
|
|
||||||
*
|
|
||||||
* 返回: 1 = 已写入(*outlen 为字节数);0 = 需回退 Go。
|
|
||||||
* 回退的两种情形:
|
|
||||||
* a) 缓冲不足(调用方应给 >= val->len*3+4 的 cap)
|
|
||||||
* b) 值类型是对象 / 数字 / 字面量 —— 那些要走 json.Marshal(§5.2)
|
|
||||||
*
|
|
||||||
* 数组元素的规则(§5.4-2/3 实测):
|
|
||||||
* · 非对象元素 **静默跳过**(`["a",{"text":"b"}]` → "b")
|
|
||||||
* · 非对象的 "text"(如 text:123)**静默跳过**
|
|
||||||
* · 元素里出现重复的 "text" 键 ⇒ 整体回退 Go(合并语义)
|
|
||||||
*/
|
|
||||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* arguments 为**字符串**时,取出其解码结果(省掉 interface{} 与二次解析)。
|
|
||||||
*
|
|
||||||
* 返回 1 = 已写入;0 = 不是字符串或失败(调用方按既有路径处理)。
|
|
||||||
* 非字符串 arguments(对象/数组/数字)**必须**回退 Go:那里的
|
|
||||||
* `rawArgsString` 会 `json.Marshal` 重新编码,而这个**重新编码的键序
|
|
||||||
* 可能与原文不同**(实测 {"b":2,"a":1} → {"a":1,"b":2})——
|
|
||||||
* 逐值一致要求由 encoding/json 来做。
|
|
||||||
*/
|
|
||||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
|
||||||
|
|
||||||
/* ==================================================================== */
|
|
||||||
/* 批量定位:**一次调用**返回整块解析所需的全部字段 */
|
|
||||||
/* ==================================================================== */
|
|
||||||
/*
|
|
||||||
* ★ 为什么需要它(第三刀实测的教训,见 sse-codec-c.md §六):
|
|
||||||
* 逐字段往返做 5+ 次 cgo 调用,每次约 168ns 边界 + 2 allocs(out-param
|
|
||||||
* 逃逸到堆)⇒ 约 1µs 固定成本,把全部收益吃光,结果比原实现更慢。
|
|
||||||
*
|
|
||||||
* 本接口把它压成 **1 次调用**,并顺带解决另外两点:
|
|
||||||
* · **单趟键分派**:不再「每个键各扫一遍对象」,而是遍历一次成员表
|
|
||||||
* 就分派(原来 6 次扫描 → 2 次)
|
|
||||||
* · **解码内联**:content / reasoning_content 的解码在同一趟里写进
|
|
||||||
* 调用方缓冲,不再各来一次往返
|
|
||||||
*
|
|
||||||
* 结果写在调用方的 ha_chunk_out 里(C 结构体、无 Go 指针 ⇒ 可安全传指针)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 槽位索引(固定约定,**改动必须 bump ABI**)。 */
|
|
||||||
#define HA_CHUNK_SLOT_DELTA 0
|
|
||||||
#define HA_CHUNK_SLOT_CONTENT 1
|
|
||||||
#define HA_CHUNK_SLOT_REASONING 2
|
|
||||||
#define HA_CHUNK_SLOT_TOOL_CALLS 3
|
|
||||||
#define HA_CHUNK_SLOT_FINISH_REASON 4
|
|
||||||
#define HA_CHUNK_SLOT_USAGE 5
|
|
||||||
#define HA_CHUNK_SLOT_COUNT 6
|
|
||||||
|
|
||||||
/* 槽位类型。与 Go 侧「该字段是什么 Go 类型」对应,而非单纯 JSON 类型。 */
|
|
||||||
#define HA_CHUNK_KIND_ABSENT 0
|
|
||||||
#define HA_CHUNK_KIND_NULL 1
|
|
||||||
#define HA_CHUNK_KIND_STRING 2
|
|
||||||
#define HA_CHUNK_KIND_OBJECT 3
|
|
||||||
#define HA_CHUNK_KIND_ARRAY 4
|
|
||||||
#define HA_CHUNK_KIND_OTHER 5 /* 数字 / 布尔 */
|
|
||||||
|
|
||||||
/* ha_sse_chunk_locate 返回码。 */
|
|
||||||
#define HA_CHUNK_OK 0 /* 定位成功,可用快速路径 */
|
|
||||||
#define HA_CHUNK_FALLBACK -1 /* 需回退 Go:重复键 / 畸形 / 顶层非对象 /
|
|
||||||
* 多 choices / 缓冲不足 */
|
|
||||||
#define HA_CHUNK_TYPE_FAIL -2 /* 与 Go 一致的「整块作废」(类型不符) */
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
ha_span span; /* 原始值 span(未解码,指向 data) */
|
|
||||||
int kind; /* HA_CHUNK_KIND_* */
|
|
||||||
} ha_chunk_slot;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
ha_chunk_slot slot[HA_CHUNK_SLOT_COUNT];
|
|
||||||
/* choices 数组本身的 span(choices_count>0 时有效) */
|
|
||||||
ha_span choices_span;
|
|
||||||
/* choices[0] 的 span(choices_count==1 时有效) */
|
|
||||||
ha_span choice0_span;
|
|
||||||
/* 解码/反转义结果(写入 sbuf,以 [off,len) 表示;kind 非字符串时为 (0,0)) */
|
|
||||||
size_t content_off; size_t content_len;
|
|
||||||
size_t reasoning_off; size_t reasoning_len;
|
|
||||||
size_t finish_off; size_t finish_len;
|
|
||||||
|
|
||||||
int has_choices; /* choices 是否存在且非 null */
|
|
||||||
int choices_kind; /* ABSENT / NULL / ARRAY */
|
|
||||||
int choices_count; /* 元素个数(>1 时调用方必须回退,见下) */
|
|
||||||
int choice0_kind; /* ABSENT / NULL / OBJECT */
|
|
||||||
} ha_chunk_out;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 一次调用定位整块解析所需的全部字段。
|
|
||||||
*
|
|
||||||
* data/len : SSE chunk 原始字节(不需要 NUL 结尾)
|
|
||||||
* out : 输出(调用方持有;C 只在本调用内写它)
|
|
||||||
* sbuf/scap : 解码输出缓冲(content / reasoning_content / finish_reason)
|
|
||||||
* sused : 出参,缓冲区实际用量
|
|
||||||
*
|
|
||||||
* 返回 HA_CHUNK_OK / HA_CHUNK_FALLBACK / HA_CHUNK_TYPE_FAIL。
|
|
||||||
*
|
|
||||||
* ★ 调用方**必须**检查 choices_count:Go 侧是 `[]struct`,Unmarshal 会解析
|
|
||||||
* **全部**元素,而本层只取 [0](协议约定)。若元素 >1,本层无法保证
|
|
||||||
* 其余元素也能被 Go 解析(它们可能有类型错误)⇒ 必须回退。
|
|
||||||
* 本函数在 choices_count>1 时**直接返回 FALLBACK**,不给调用方犯错的机会。
|
|
||||||
*/
|
|
||||||
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
|
|
||||||
char *sbuf, size_t scap, size_t *sused);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_SSE_H */
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
/*
|
|
||||||
* ha_codec.c — HomeAgent 内核编解码层(C 实现)
|
|
||||||
*
|
|
||||||
* ============================ 性能设计(勿回退)============================
|
|
||||||
* 1. **不 malloc**:模型名折叠用栈缓冲(短名走快路径,超长走零分配的回退)。
|
|
||||||
* 2. **不 strlen**:长度由调用方传入(见 ha_codec.h 签名说明)。
|
|
||||||
* 3. **ASCII 批量快路径**:连续 ASCII 成批计数,避免逐字节函数调用。
|
|
||||||
* 4. **截断提前短路**:数满 keep 个 rune 立即返回,不扫完整串。
|
|
||||||
* 5. **截断返回字节数**而非字符串:结果必然是输入前缀,调用方自己切片。
|
|
||||||
*
|
|
||||||
* 初版的三个反例(实测代价,见 docs/zh/c-core/llm-orchestration-c.md §7.1):
|
|
||||||
* - Go 侧 C.CString(malloc+拷贝)+ C 侧 strlen,单这一项约 75ns,
|
|
||||||
* 而 cgo 边界本身仅约 32ns —— 即 **82% 的开销是自找的**,不是 cgo 的成本。
|
|
||||||
* 初版由此得出「C 比 Go 慢」的结论是错的。
|
|
||||||
* - 逐字节 utf8_next 函数调用 ⇒ 1KB ASCII 比纯 Go 慢 7 倍。
|
|
||||||
* - 1KB 中文要先扫完整串才判断是否截断。
|
|
||||||
*
|
|
||||||
* 语义必须与 Go 侧实现逐值一致,由黄金对照测试钉死(含畸形 UTF-8)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ha_codec.h"
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 大小写不敏感的子串匹配 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* 只折 ASCII 字母;非 ASCII 字节原样(与 Go strings.ToLower 对模型名的
|
|
||||||
* 实际效果一致——模型名都是 ASCII,中文/日文字节不受 ToLower 影响)。 */
|
|
||||||
static unsigned char ascii_lower(unsigned char c) {
|
|
||||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 已折叠缓冲(长度 hn)中是否含子串 sub(sub 必须已小写、ASCII)。
|
|
||||||
* memcmp 版本:折叠一次后可向量化比较,是短名快路径。 */
|
|
||||||
static int contains(const char *m, size_t hn, const char *sub) {
|
|
||||||
size_t m_len = strlen(sub);
|
|
||||||
if (m_len == 0 || hn < m_len) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
size_t last = hn - m_len;
|
|
||||||
for (size_t i = 0; i <= last; i++) {
|
|
||||||
/* 首字节过滤掉绝大多数位置,避免无谓 memcmp */
|
|
||||||
if (m[i] == sub[0] && memcmp(m + i, sub, m_len) == 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 边比较边折叠:**任意长度**都正确,无需缓冲(超长模型名的回退路径)。
|
|
||||||
* sub 中的 ASCII 字母按小写处理;非 ASCII 字节按字节精确比较
|
|
||||||
* (因此可直接用于 "\xe9\x9b\xb6\xe4\xb8\x80" 这类多字节字面量)。 */
|
|
||||||
static int contains_ci(const char *h, size_t hn, const char *sub) {
|
|
||||||
size_t m_len = strlen(sub);
|
|
||||||
if (m_len == 0 || hn < m_len) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
size_t last = hn - m_len;
|
|
||||||
for (size_t i = 0; i <= last; i++) {
|
|
||||||
size_t j = 0;
|
|
||||||
while (j < m_len &&
|
|
||||||
ascii_lower((unsigned char)h[i + j]) == (unsigned char)sub[j]) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j == m_len) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 模型名的不可变视图:能进栈缓冲就折叠,否则按原样(用 contains_ci 匹配)。 */
|
|
||||||
typedef struct {
|
|
||||||
const char *p;
|
|
||||||
size_t n;
|
|
||||||
int folded;
|
|
||||||
} model_view;
|
|
||||||
|
|
||||||
/* 栈缓冲容量:模型名实测都是几十字节。超出则退化为不折叠 +
|
|
||||||
* contains_ci —— 仍**零分配且语义正确**,只是少了 memcmp 的向量化优势。 */
|
|
||||||
#define HA_MODEL_STACK 256
|
|
||||||
|
|
||||||
static int mv_contains(const model_view *v, const char *sub) {
|
|
||||||
return v->folded ? contains(v->p, v->n, sub) : contains_ci(v->p, v->n, sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 模型上下文窗口推断 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_codec_model_context_window(const char *model, size_t model_len) {
|
|
||||||
if (model == NULL || model_len == 0) {
|
|
||||||
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
char stack[HA_MODEL_STACK];
|
|
||||||
model_view v;
|
|
||||||
if (model_len < HA_MODEL_STACK) {
|
|
||||||
for (size_t i = 0; i < model_len; i++) {
|
|
||||||
stack[i] = (char)ascii_lower((unsigned char)model[i]);
|
|
||||||
}
|
|
||||||
stack[model_len] = '\0';
|
|
||||||
v.p = stack;
|
|
||||||
v.n = model_len;
|
|
||||||
v.folded = 1;
|
|
||||||
} else {
|
|
||||||
v.p = model;
|
|
||||||
v.n = model_len;
|
|
||||||
v.folded = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顺序与 Go 侧 switch 分支**严格一致**:先匹配到的分支胜出。
|
|
||||||
* 这不是「随便一组 if」,顺序错了就会给出不同窗口
|
|
||||||
* (例:gpt-4-turbo 必须先于裸 gpt-4 命中)。 */
|
|
||||||
if (mv_contains(&v, "deepseek-v4") || mv_contains(&v, "deepseek-v3")) {
|
|
||||||
return 1048576;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "deepseek-r1") || mv_contains(&v, "deepseek-chat")) {
|
|
||||||
return 65536;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "gpt-4")) {
|
|
||||||
if (mv_contains(&v, "turbo") || mv_contains(&v, "mini") || mv_contains(&v, "omni")) {
|
|
||||||
return 128000;
|
|
||||||
}
|
|
||||||
return 8192;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "gpt-3.5")) {
|
|
||||||
return 16384;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "claude-3.5") || mv_contains(&v, "claude-3")) {
|
|
||||||
return 200000;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "claude")) {
|
|
||||||
return 100000;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "gemini-1.5") || mv_contains(&v, "gemini-2")) {
|
|
||||||
return 1048576;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "gemini")) {
|
|
||||||
return 32768;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "qwen")) {
|
|
||||||
return 131072;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "glm") || mv_contains(&v, "chatglm")) {
|
|
||||||
return 131072;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "llama-3")) {
|
|
||||||
return 8192;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "llama-2")) {
|
|
||||||
return 4096;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "mistral") || mv_contains(&v, "mixtral")) {
|
|
||||||
return 32768;
|
|
||||||
}
|
|
||||||
/* "yi-" 与 "零一"(UTF-8 字面量)——contains_ci 对字节精确比较,
|
|
||||||
* 故中文部分不受折叠影响,与 Go 的 strings.Contains 一致。 */
|
|
||||||
if (mv_contains(&v, "yi-") || mv_contains(&v, "\xe9\x9b\xb6\xe4\xb8\x80")) {
|
|
||||||
return 200000;
|
|
||||||
}
|
|
||||||
if (mv_contains(&v, "moonshot") || mv_contains(&v, "kimi")) {
|
|
||||||
return 131072;
|
|
||||||
}
|
|
||||||
|
|
||||||
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* UTF-8 解码(与 Go utf8.DecodeRuneInString 逐值等价) */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* 返回 s[0] 起始字符的字节长度(1..4)。
|
|
||||||
*
|
|
||||||
* 必须与 Go 的 utf8.DecodeRuneInString 语义一致——**包括无效序列只前进
|
|
||||||
* 1 字节**(Go 对无效/截断序列返回 RuneError 且 size=1),否则 rune 计数
|
|
||||||
* 会与 Go 分叉。这正是黄金对照测试用畸形输入能抓到的地方。
|
|
||||||
*
|
|
||||||
* remaining 是当前可读字节数。 */
|
|
||||||
static inline size_t utf8_char_len(const char *s, size_t remaining) {
|
|
||||||
unsigned char c0 = (unsigned char)s[0];
|
|
||||||
|
|
||||||
if (c0 < 0x80) {
|
|
||||||
return 1; /* ASCII */
|
|
||||||
}
|
|
||||||
if (c0 < 0xC2) {
|
|
||||||
return 1; /* 0x80..0xC1:续字节或过长编码 → Go 判无效,size=1 */
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c0 < 0xE0) { /* 2 字节:0xC2..0xDF */
|
|
||||||
if (remaining < 2) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (((unsigned char)s[1] & 0xC0) != 0x80) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c0 < 0xF0) { /* 3 字节:0xE0..0xEF */
|
|
||||||
if (remaining < 3) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
/* 用 (c & 0xC0) == 0x80 走单条 AND+CMP(而非两条范围比较),
|
|
||||||
* 并用 & 而非 && 避免短路分支——这是 CJK 主路径,须最短。 */
|
|
||||||
unsigned char c1 = (unsigned char)s[1];
|
|
||||||
unsigned char c2 = (unsigned char)s[2];
|
|
||||||
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80)) {
|
|
||||||
/* 常见情形:既非 0xE0(防过长编码)也非 0xED(防代理对) */
|
|
||||||
if (c0 != 0xE0 && c0 != 0xED) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if ((c0 == 0xE0 && c1 >= 0xA0) || (c0 == 0xED && c1 <= 0x9F)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c0 < 0xF5) { /* 4 字节:0xF0..0xF4 */
|
|
||||||
if (remaining < 4) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
unsigned char c1 = (unsigned char)s[1];
|
|
||||||
unsigned char c2 = (unsigned char)s[2];
|
|
||||||
unsigned char c3 = (unsigned char)s[3];
|
|
||||||
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80) & ((c3 & 0xC0) == 0x80)) {
|
|
||||||
if (c0 != 0xF0 && c0 != 0xF4) {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
if ((c0 == 0xF0 && c1 >= 0x90) || (c0 == 0xF4 && c1 <= 0x8F)) {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1; /* 0xF5..0xFF:无效 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ASCII 批量扫描:返回从 text[i] 起连续 ASCII 的字节数(扫到串尾)。
|
|
||||||
*
|
|
||||||
* ★ 字(word)级探测:一次读 8 字节,用单条掩码判断「8 字节是否全为 ASCII」。
|
|
||||||
* 逐字节比较会让 1KB ASCII 明显慢于纯 Go(后者内部有 8 字节快路径)。
|
|
||||||
* 实测:逐字节版 ascii_1k 约 2318ns(比 Go 慢 7×),改字级后大幅收敛。 */
|
|
||||||
#define HA_HIGH_BITS 0x8080808080808080ULL
|
|
||||||
|
|
||||||
static size_t ascii_run(const char *text, size_t i, size_t len) {
|
|
||||||
size_t j = i;
|
|
||||||
while (j + 8 <= len) {
|
|
||||||
uint64_t v;
|
|
||||||
memcpy(&v, text + j, 8); /* memcpy 让编译器按需生成未对齐安全加载 */
|
|
||||||
if (v & HA_HIGH_BITS) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
j += 8;
|
|
||||||
}
|
|
||||||
while (j < len && (unsigned char)text[j] < 0x80) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
return j - i;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* token 估算 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_codec_estimate_tokens(const char *text, size_t text_len) {
|
|
||||||
if (text == NULL || text_len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t runes = 0;
|
|
||||||
size_t i = 0;
|
|
||||||
while (i < text_len) {
|
|
||||||
if ((unsigned char)text[i] < 0x80) {
|
|
||||||
size_t n = ascii_run(text, i, text_len);
|
|
||||||
runes += n;
|
|
||||||
i += n;
|
|
||||||
} else {
|
|
||||||
i += utf8_char_len(text + i, text_len - i);
|
|
||||||
runes++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 与 Go 侧一致:t = runeCount * 2;t < 1 时取 1。
|
|
||||||
* runes > 0 时 t >= 2,故只需处理溢出与下限。 */
|
|
||||||
if (runes > (size_t)0x3FFFFFFF) { /* 防 int 溢出 */
|
|
||||||
return 0x7FFFFFFF;
|
|
||||||
}
|
|
||||||
int t = (int)(runes * 2);
|
|
||||||
if (t < 1) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 按 token 预算计算应保留的字节数 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len,
|
|
||||||
int max_tokens) {
|
|
||||||
if (text == NULL || text_len == 0 || max_tokens <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 要保留的 rune 数(与 Go 一致:整数除法)。keep==0 时循环首轮即返回 0。 */
|
|
||||||
size_t keep = (size_t)(max_tokens / 2);
|
|
||||||
|
|
||||||
/* 提前短路:keep 个 rune 数满而串仍有剩余 ⇒ 必然截断,直接返回该字节边界,
|
|
||||||
* 不必扫完整串(长文本上的主要收益)。
|
|
||||||
* 若数完整串仍未数满 keep ⇒ 未超预算,返回全长(= 不截断)。 */
|
|
||||||
size_t runes = 0;
|
|
||||||
size_t i = 0;
|
|
||||||
while (i < text_len) {
|
|
||||||
if (runes == keep) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
if ((unsigned char)text[i] < 0x80) {
|
|
||||||
size_t n = ascii_run(text, i, text_len);
|
|
||||||
if (runes + n >= keep) {
|
|
||||||
/* keep 落在这批 ASCII 内:批内每字节一个 rune */
|
|
||||||
return i + (keep - runes);
|
|
||||||
}
|
|
||||||
runes += n;
|
|
||||||
i += n;
|
|
||||||
} else {
|
|
||||||
runes++;
|
|
||||||
i += utf8_char_len(text + i, text_len - i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return text_len; /* 未超预算:整串都留 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* ABI 自述 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_codec_abi_version(void) {
|
|
||||||
return HA_CODEC_ABI_VERSION;
|
|
||||||
}
|
|
||||||
@ -1,825 +0,0 @@
|
|||||||
/*
|
|
||||||
* ha_json_scan.c — HomeAgent 内核 LLM 协议层 JSON 扫描/取值(C 实现)
|
|
||||||
*
|
|
||||||
* ============================ 性能设计(勿回退) ============================
|
|
||||||
* 1. **不 malloc**:一切结果以 span 回传,Go 侧零拷贝切片
|
|
||||||
* 2. **不 strlen**:长度由调用方传入
|
|
||||||
* 3. **不预扫**:scan 只在需要时前进一步;「找键」靠 members 迭代单趟,
|
|
||||||
* 不先扫一遍收集全部键(那会缓存踩踏 + 二次遍历)
|
|
||||||
* 4. **整数不走 strtoll**:strtoll 要 NUL 结尾或处理 locale,
|
|
||||||
* 自写定点解析只认 JSON 整数语法,顺带把溢出判掉
|
|
||||||
* 5. **字符串不建索引**:不记录转义位置。需要时按需解码
|
|
||||||
*
|
|
||||||
* 参照第一刀的教训(docs/zh/c-core/llm-orchestration-c.md §7.1):
|
|
||||||
* 初版每次调用 C.CString(malloc+拷贝)+ C 侧 strlen,单这两项就吃掉
|
|
||||||
* 82% 的时间 —— 那不是 cgo 的固有成本,是自找的。本库从设计上排除这类开销。
|
|
||||||
*
|
|
||||||
* 语义必须与 Go `encoding/json` 一致,由黄金对照测试钉死
|
|
||||||
* (真值表见 docs/zh/c-core/sse-codec-c.md §二)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ha_json_scan.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* ABI 自述 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_json_scan_abi_version(void) {
|
|
||||||
return HA_JSON_SCAN_ABI_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 基础工具 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* JSON 空白:Go 的 encoding/json 只认这四个(不是 isspace)。
|
|
||||||
* 差一个字符就会与 Go 分叉,故显式列举而非用 ctype。 */
|
|
||||||
static int is_ws(unsigned char c) {
|
|
||||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
|
||||||
}
|
|
||||||
|
|
||||||
static unsigned char ascii_lower(unsigned char c) {
|
|
||||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n) {
|
|
||||||
if (sc == NULL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sc->s = (s != NULL) ? s : "";
|
|
||||||
sc->n = (s != NULL) ? n : 0;
|
|
||||||
sc->i = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_scan_ws(ha_json_scan *sc) {
|
|
||||||
if (sc == NULL) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
while (sc->i < sc->n && is_ws((unsigned char)sc->s[sc->i])) {
|
|
||||||
sc->i++;
|
|
||||||
}
|
|
||||||
return (sc->i < sc->n) ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_scan_eof(const ha_json_scan *sc) {
|
|
||||||
if (sc == NULL) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return (sc->i >= sc->n) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 当前字符;到结尾返回 '\0'(0)。调用方需先判 eof。 */
|
|
||||||
static char peek(const ha_json_scan *sc) {
|
|
||||||
return (sc->i < sc->n) ? sc->s[sc->i] : '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 前进一字节;越界时不动(保持 eof 语义稳定)。 */
|
|
||||||
static void bump(ha_json_scan *sc) {
|
|
||||||
if (sc->i < sc->n) {
|
|
||||||
sc->i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int expect(ha_json_scan *sc, char c) {
|
|
||||||
if (ha_json_scan_ws(sc) || peek(sc) != c) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
bump(sc);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 值扫描(skip 一个完整值) */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
static int scan_value(ha_json_scan *sc, int depth);
|
|
||||||
static int hex_val(unsigned char c);
|
|
||||||
|
|
||||||
/* 扫描字符串(含引号),出原始内容 span。
|
|
||||||
* depth 传入是因为 scan_value 会递归;字符串本身不递归但需要限额。 */
|
|
||||||
static int scan_string_raw(ha_json_scan *sc, ha_span *raw, int depth) {
|
|
||||||
if (depth > 128) {
|
|
||||||
return 0; /* 深度保险,正常文档远小于此 */
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(sc) || peek(sc) != '"') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
bump(sc); /* 开引号 */
|
|
||||||
size_t start = sc->i;
|
|
||||||
while (sc->i < sc->n) {
|
|
||||||
char c = sc->s[sc->i];
|
|
||||||
if (c == '"') {
|
|
||||||
if (raw != NULL) {
|
|
||||||
raw->p = sc->s + start;
|
|
||||||
raw->len = sc->i - start;
|
|
||||||
}
|
|
||||||
bump(sc); /* 闭引号 */
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (c == '\\') {
|
|
||||||
bump(sc);
|
|
||||||
if (sc->i >= sc->n) {
|
|
||||||
return 0; /* 末尾悬空反斜杠 */
|
|
||||||
}
|
|
||||||
/* ★ 必须校验转义字符本身合法:Go 的 unquoteBytes 对未知转义
|
|
||||||
* (\q、\x、单独 \p)返回错误 ⇒ 整个 Unmarshal 失败。
|
|
||||||
* 初版只 bump 不校验,于是 `{"a":"\q"}` 被 C 判为合法,
|
|
||||||
* 而 json.Valid=false —— 黄金对照当场抓到。
|
|
||||||
* (`\u` 的 4 位十六进制在解码阶段校验:那是**值**层面的
|
|
||||||
* 错误,与扫描阶段的「转义序列形状」是两回事。) */
|
|
||||||
char e = sc->s[sc->i];
|
|
||||||
if (e != '"' && e != '\\' && e != '/' && e != 'b' && e != 'f' &&
|
|
||||||
e != 'n' && e != 'r' && e != 't' && e != 'u') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* ★ `\u` 必须紧跟 **4 位十六进制**,且这一校验属于**扫描**阶段:
|
|
||||||
* Go 的 json.Valid 会拒绝 `{"a":"\u00"}`(不足 4 位),
|
|
||||||
* 而初版把它留到解码阶段 ⇒ scan 判合法、json.Valid 判非法,
|
|
||||||
* 黄金对照当场抓到这条分叉。
|
|
||||||
* 校验放在扫描阶段还有一个好处:畸形的 wire 数据在
|
|
||||||
* 「找键」阶段就被拒,不必等到取值。 */
|
|
||||||
if (e == 'u') {
|
|
||||||
/* 用 size_t 递推偏移,避免 int 与 size_t 混算
|
|
||||||
* (-Wconversion/-Wsign-conversion 会拦下 sign-change)。 */
|
|
||||||
if (sc->n - sc->i < 5u) {
|
|
||||||
return 0; /* 位数不足:还需 'u' 之后 4 位 */
|
|
||||||
}
|
|
||||||
for (size_t k = 1; k <= 4u; k++) {
|
|
||||||
if (hex_val((unsigned char)sc->s[sc->i + k]) < 0) {
|
|
||||||
return 0; /* 非十六进制 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bump(sc); /* 被转义的字符;\u 的 4 位十六进制由上面的循环覆盖 */
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ((unsigned char)c < 0x20) {
|
|
||||||
return 0; /* Go 拒绝字符串里的裸控制字符 */
|
|
||||||
}
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
return 0; /* 未闭合 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 扫描字面量:true / false / null。 */
|
|
||||||
static int scan_literal(ha_json_scan *sc) {
|
|
||||||
static const char kTrue[] = "true";
|
|
||||||
static const char kFalse[] = "false";
|
|
||||||
static const char kNull[] = "null";
|
|
||||||
size_t rest = sc->n - sc->i;
|
|
||||||
const char *p = sc->s + sc->i;
|
|
||||||
|
|
||||||
if (rest >= 4 && memcmp(p, kTrue, 4) == 0) {
|
|
||||||
sc->i += 4;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (rest >= 5 && memcmp(p, kFalse, 5) == 0) {
|
|
||||||
sc->i += 5;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (rest >= 4 && memcmp(p, kNull, 4) == 0) {
|
|
||||||
sc->i += 4;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 数字:只校验**语法**(不求值)。求值由 ha_json_get_int / 调用方负责。
|
|
||||||
* 这与 Go 的分工一致:Go 在 unmarshal 时求值并做范围检查,
|
|
||||||
* 而本层的取整数是独立的一步。 */
|
|
||||||
static int scan_number(ha_json_scan *sc) {
|
|
||||||
size_t start = sc->i;
|
|
||||||
if (sc->i < sc->n && peek(sc) == '-') {
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
/* 整数部分:0 或 [1-9][0-9]*(禁止前导零,与 Go 一致) */
|
|
||||||
if (sc->i >= sc->n) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (peek(sc) == '0') {
|
|
||||||
bump(sc);
|
|
||||||
} else if (peek(sc) >= '1' && peek(sc) <= '9') {
|
|
||||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* 小数部分 */
|
|
||||||
if (sc->i < sc->n && peek(sc) == '.') {
|
|
||||||
bump(sc);
|
|
||||||
if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') {
|
|
||||||
return 0; /* "1." 与 "1.e3" 非法 */
|
|
||||||
}
|
|
||||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 指数部分 */
|
|
||||||
if (sc->i < sc->n && (peek(sc) == 'e' || peek(sc) == 'E')) {
|
|
||||||
bump(sc);
|
|
||||||
if (sc->i < sc->n && (peek(sc) == '+' || peek(sc) == '-')) {
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
|
||||||
bump(sc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (sc->i > start) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 扫描数组/对象。用显式 depth 递归(不用堆栈,零分配)。 */
|
|
||||||
static int scan_container(ha_json_scan *sc, char open, char close, int depth) {
|
|
||||||
if (!expect(sc, open)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(sc)) {
|
|
||||||
return 0; /* 未闭合 */
|
|
||||||
}
|
|
||||||
if (peek(sc) == close) {
|
|
||||||
bump(sc);
|
|
||||||
return 1; /* 空容器 */
|
|
||||||
}
|
|
||||||
for (;;) {
|
|
||||||
if (open == '{') {
|
|
||||||
ha_span k;
|
|
||||||
if (!scan_string_raw(sc, &k, depth + 1)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!expect(sc, ':')) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!scan_value(sc, depth + 1)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (peek(sc) == ',') {
|
|
||||||
bump(sc);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (peek(sc) == close) {
|
|
||||||
bump(sc);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0; /* 缺 '}' 或多余的 ',' 之后没有键 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int scan_value(ha_json_scan *sc, int depth) {
|
|
||||||
if (depth > 128) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
char c = peek(sc);
|
|
||||||
switch (c) {
|
|
||||||
case '{': return scan_container(sc, '{', '}', depth);
|
|
||||||
case '[': return scan_container(sc, '[', ']', depth);
|
|
||||||
case '"': {
|
|
||||||
ha_span tmp;
|
|
||||||
return scan_string_raw(sc, &tmp, depth);
|
|
||||||
}
|
|
||||||
case 't': case 'f': case 'n': return scan_literal(sc);
|
|
||||||
default:
|
|
||||||
if (c == '-' || (c >= '0' && c <= '9')) {
|
|
||||||
return scan_number(sc);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_skip(ha_json_scan *sc) {
|
|
||||||
if (sc == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return scan_value(sc, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_scan_string(ha_json_scan *sc, ha_span *raw) {
|
|
||||||
if (sc == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return scan_string_raw(sc, raw, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 顶层对象成员迭代 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_json_members_init(ha_json_members *m, const char *s, size_t n) {
|
|
||||||
if (m == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_json_scan_init(&m->sc, s, n);
|
|
||||||
m->started = 0;
|
|
||||||
m->done = 0;
|
|
||||||
m->error = 0;
|
|
||||||
if (ha_json_scan_ws(&m->sc) || peek(&m->sc) != '{') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
bump(&m->sc);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val) {
|
|
||||||
if (m == NULL || m->done) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(&m->sc)) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1; /* 未闭合 */
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (peek(&m->sc) == '}') {
|
|
||||||
bump(&m->sc);
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 0; /* 正常结束 */
|
|
||||||
return 0; /* 没有更多成员 */
|
|
||||||
}
|
|
||||||
/* ★ 不接受尾逗号:Go 的 decoder 在 ',' 之后要求必有下一个键。
|
|
||||||
* `{"a":1,}` 在 Go 侧是语法错误,故这里也必须拒绝。 */
|
|
||||||
if (m->started) {
|
|
||||||
if (peek(&m->sc) != ',') {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
bump(&m->sc);
|
|
||||||
if (ha_json_scan_ws(&m->sc)) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (peek(&m->sc) == '}') {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1; /* 尾逗号 */
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_span k;
|
|
||||||
if (!scan_string_raw(&m->sc, &k, 0)) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!expect(&m->sc, ':')) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (ha_json_scan_ws(&m->sc)) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ★ 就地完整跳过一个值,得到它的精确 span。
|
|
||||||
* 这样「返回 1」就蕴含「该成员良构」,且游标已推进到值之后。 */
|
|
||||||
size_t vstart = m->sc.i;
|
|
||||||
if (!scan_value(&m->sc, 0)) {
|
|
||||||
m->done = 1;
|
|
||||||
m->error = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
size_t vend = m->sc.i;
|
|
||||||
|
|
||||||
m->started = 1;
|
|
||||||
if (key != NULL) {
|
|
||||||
*key = k;
|
|
||||||
}
|
|
||||||
if (val != NULL) {
|
|
||||||
val->p = m->sc.s + vstart;
|
|
||||||
val->len = vend - vstart;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_members_complete(const ha_json_members *m) {
|
|
||||||
if (m == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (m->done && !m->error) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_key_eq(ha_span key, const char *name) {
|
|
||||||
if (name == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
size_t nl = 0;
|
|
||||||
while (name[nl] != '\0') {
|
|
||||||
nl++;
|
|
||||||
}
|
|
||||||
if (key.len != nl) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
for (size_t i = 0; i < nl; i++) {
|
|
||||||
if (ascii_lower((unsigned char)key.p[i]) !=
|
|
||||||
ascii_lower((unsigned char)name[i])) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 字符串解码 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* U+FFFD 的 UTF-8 编码(Go 对非法字节的替换目标)。 */
|
|
||||||
static const char kReplacement[3] = { (char)0xEF, (char)0xBF, (char)0xBD };
|
|
||||||
|
|
||||||
/* 十六进制值;非十六进制返回 -1。 */
|
|
||||||
static int hex_val(unsigned char c) {
|
|
||||||
if (c >= '0' && c <= '9') return c - '0';
|
|
||||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
|
||||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 把码点编码成 UTF-8 写给 sink。返回写入字节数。 */
|
|
||||||
static size_t emit_rune(unsigned long cp, ha_json_sink sink, void *ctx) {
|
|
||||||
unsigned char buf[4];
|
|
||||||
size_t len;
|
|
||||||
if (cp < 0x80) {
|
|
||||||
buf[0] = (unsigned char)cp;
|
|
||||||
len = 1;
|
|
||||||
} else if (cp < 0x800) {
|
|
||||||
buf[0] = (unsigned char)(0xC0 | (cp >> 6));
|
|
||||||
buf[1] = (unsigned char)(0x80 | (cp & 0x3F));
|
|
||||||
len = 2;
|
|
||||||
} else if (cp < 0x10000) {
|
|
||||||
buf[0] = (unsigned char)(0xE0 | (cp >> 12));
|
|
||||||
buf[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
|
|
||||||
buf[2] = (unsigned char)(0x80 | (cp & 0x3F));
|
|
||||||
len = 3;
|
|
||||||
} else {
|
|
||||||
buf[0] = (unsigned char)(0xF0 | (cp >> 18));
|
|
||||||
buf[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
|
|
||||||
buf[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
|
|
||||||
buf[3] = (unsigned char)(0x80 | (cp & 0x3F));
|
|
||||||
len = 4;
|
|
||||||
}
|
|
||||||
sink(ctx, (const char *)buf, len);
|
|
||||||
return len;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解码一段 raw(已定位转义与续字节的边界)。
|
|
||||||
*
|
|
||||||
* 非法 UTF-8 语义必须与 Go 逐字节一致:
|
|
||||||
* Go 的 unquoteBytes 遇到非法序列时,把**能构成前缀的最长合法部分**先解出,
|
|
||||||
* 再对**第一个坏字节**产出单个 U+FFFD,然后从坏字节**之后**继续。
|
|
||||||
* 即:一个坏字节 = 一个 U+FFFD(不是整个序列变一个)。
|
|
||||||
* 典型:`\xff\xfe` → 两个 U+FFFD(真值表 §2.8 实测确认)。
|
|
||||||
*/
|
|
||||||
static size_t decode_body(ha_span raw, ha_json_sink sink, void *ctx, int *err) {
|
|
||||||
size_t out = 0;
|
|
||||||
size_t i = 0;
|
|
||||||
*err = 0;
|
|
||||||
|
|
||||||
while (i < raw.len) {
|
|
||||||
unsigned char c = (unsigned char)raw.p[i];
|
|
||||||
|
|
||||||
/* --- 转义 --- */
|
|
||||||
if (c == '\\') {
|
|
||||||
if (i + 1 >= raw.len) {
|
|
||||||
*err = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
unsigned char e = (unsigned char)raw.p[i + 1];
|
|
||||||
switch (e) {
|
|
||||||
case '"': sink(ctx, "\"", 1); out += 1; i += 2; continue;
|
|
||||||
case '\\': sink(ctx, "\\", 1); out += 1; i += 2; continue;
|
|
||||||
case '/': sink(ctx, "/", 1); out += 1; i += 2; continue;
|
|
||||||
case 'b': sink(ctx, "\b", 1); out += 1; i += 2; continue;
|
|
||||||
case 'f': sink(ctx, "\f", 1); out += 1; i += 2; continue;
|
|
||||||
case 'n': sink(ctx, "\n", 1); out += 1; i += 2; continue;
|
|
||||||
case 'r': sink(ctx, "\r", 1); out += 1; i += 2; continue;
|
|
||||||
case 't': sink(ctx, "\t", 1); out += 1; i += 2; continue;
|
|
||||||
case 'u': {
|
|
||||||
/* 需要 4 位十六进制:i+2 .. i+5 */
|
|
||||||
if (i + 6 > raw.len) {
|
|
||||||
*err = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
int h0 = hex_val((unsigned char)raw.p[i + 2]);
|
|
||||||
int h1 = hex_val((unsigned char)raw.p[i + 3]);
|
|
||||||
int h2 = hex_val((unsigned char)raw.p[i + 4]);
|
|
||||||
int h3 = hex_val((unsigned char)raw.p[i + 5]);
|
|
||||||
if (h0 < 0 || h1 < 0 || h2 < 0 || h3 < 0) {
|
|
||||||
*err = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
unsigned long cp = (unsigned long)((h0 << 12) | (h1 << 8) |
|
|
||||||
(h2 << 4) | h3);
|
|
||||||
size_t adv = 6;
|
|
||||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
|
||||||
/* 高代理:尝试与紧随的 \uDC00-\uDFFF 合成 4 字节 rune。
|
|
||||||
*
|
|
||||||
* ★ 必须用 combined 标志,而不是「合成成功就直接落到底部」:
|
|
||||||
* 本块末尾有一段**无条件的** replacement 发射(处理合成
|
|
||||||
* 失败的情形)。若成功的分支只设 cp/adv 而不跳过那一段,
|
|
||||||
* 会先把合成好的码点丢掉、再发一个 U+FFFD ——
|
|
||||||
* 实测症状:`\ud83d\ude00`(😀)得到 `\xef\xbf\xbd\xef\xbf\xbd`。
|
|
||||||
* 这个 bug 只有**真的代理对**才会触发(`\u4f60` 这类
|
|
||||||
* 非代理码点根本不进本块),是黄金对照最容易漏的一类。
|
|
||||||
*
|
|
||||||
* 下界必须是 'i + 6 < raw.len'(而非一次判 i+12 <= len):
|
|
||||||
* 后者会连带拒绝「合法高代理位于字符串末尾」的正确输入。 */
|
|
||||||
int combined = 0;
|
|
||||||
if (i + 6 < raw.len && raw.p[i + 6] == '\\' &&
|
|
||||||
raw.p[i + 7] == 'u') {
|
|
||||||
int g0 = hex_val((unsigned char)raw.p[i + 8]);
|
|
||||||
int g1 = hex_val((unsigned char)raw.p[i + 9]);
|
|
||||||
int g2 = hex_val((unsigned char)raw.p[i + 10]);
|
|
||||||
int g3 = hex_val((unsigned char)raw.p[i + 11]);
|
|
||||||
if (g0 >= 0 && g1 >= 0 && g2 >= 0 && g3 >= 0) {
|
|
||||||
unsigned long lo = (unsigned long)(
|
|
||||||
(g0 << 12) | (g1 << 8) | (g2 << 4) | g3);
|
|
||||||
if (lo >= 0xDC00 && lo <= 0xDFFF) {
|
|
||||||
cp = 0x10000UL + ((cp - 0xD800UL) << 10) +
|
|
||||||
(lo - 0xDC00UL);
|
|
||||||
adv = 12;
|
|
||||||
combined = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!combined) {
|
|
||||||
/* 高代理后面不是合法低代理:发一个 U+FFFD,
|
|
||||||
* 只消费掉这个 6 字节 \uXXXX,让后面的内容按原样
|
|
||||||
* 继续解析(与 Go unquote 的行为一致)。 */
|
|
||||||
sink(ctx, kReplacement, 3);
|
|
||||||
out += 3;
|
|
||||||
i += adv;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
|
||||||
/* 孤立低代理 → U+FFFD */
|
|
||||||
sink(ctx, kReplacement, 3);
|
|
||||||
out += 3;
|
|
||||||
i += 6;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out += emit_rune(cp, sink, ctx);
|
|
||||||
i += adv;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
/* Go 对未知转义(如 \q)报错 */
|
|
||||||
*err = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- 普通字节 / 多字节序列 --- */
|
|
||||||
if (c < 0x80) {
|
|
||||||
char ch = (char)c;
|
|
||||||
sink(ctx, &ch, 1);
|
|
||||||
out += 1;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 尝试解析一个合法多字节序列。
|
|
||||||
*
|
|
||||||
* ★ 过长编码(overlong)检查**必须在续字节全部并入之后**做。
|
|
||||||
* 初版把它写在这里、只用首字节的 cp:
|
|
||||||
* else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; }
|
|
||||||
* if (need == 3 && cp < 0x800) valid = 0; // ← 此时 cp 只有首字节的位
|
|
||||||
* 而 0xE4 恰好满足 0x0F 掩码 ⇒ cp = 4 ⇒ 4 < 0x800 ⇒ 误判非法
|
|
||||||
* ⇒ 正常的「你」(e4 bd a0)被逐字节换成 6 个 U+FFFD(实测症状)。
|
|
||||||
* 过长的真实判据是「完整码点 < 该长度的最小值」,
|
|
||||||
* 即 0xC0/0x80、0xE0 0x80、0xF0 0x80/0x90 这几类前缀。 */
|
|
||||||
size_t need;
|
|
||||||
unsigned long cp;
|
|
||||||
unsigned char b0 = c;
|
|
||||||
if ((b0 & 0xE0) == 0xC0) { need = 2; cp = b0 & 0x1Fu; }
|
|
||||||
else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; }
|
|
||||||
else if ((b0 & 0xF8) == 0xF0) { need = 4; cp = b0 & 0x07u; }
|
|
||||||
else { need = 0; cp = 0; }
|
|
||||||
|
|
||||||
int valid = (need != 0);
|
|
||||||
if (valid) {
|
|
||||||
for (size_t k = 1; k < need; k++) {
|
|
||||||
if (i + k >= raw.len) { valid = 0; break; }
|
|
||||||
unsigned char nb = (unsigned char)raw.p[i + k];
|
|
||||||
if ((nb & 0xC0) != 0x80) { valid = 0; break; }
|
|
||||||
cp = (cp << 6) | (unsigned long)(nb & 0x3F);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
/* 过长编码:按**完整码点**比该长度的最小合法值
|
|
||||||
* (2B:0x80 / 3B:0x800 / 4B:0x10000) */
|
|
||||||
if (need == 2 && cp < 0x80) valid = 0;
|
|
||||||
if (need == 3 && cp < 0x800) valid = 0;
|
|
||||||
if (need == 4 && cp < 0x10000) valid = 0;
|
|
||||||
/* 代理区编码(CESU-8 / WTF-8)Go 判非法 */
|
|
||||||
if (cp >= 0xD800 && cp <= 0xDFFF) valid = 0;
|
|
||||||
if (cp > 0x10FFFF) valid = 0;
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
sink(ctx, raw.p + i, need);
|
|
||||||
out += need;
|
|
||||||
i += need;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 非法:单个字节 → 一个 U+FFFD,然后继续(与 Go 逐字节一致) */
|
|
||||||
sink(ctx, kReplacement, 3);
|
|
||||||
out += 3;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx,
|
|
||||||
size_t *out_len) {
|
|
||||||
if (sink == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
int err = 0;
|
|
||||||
size_t n = decode_body(raw, sink, ctx, &err);
|
|
||||||
if (out_len != NULL) {
|
|
||||||
*out_len = n;
|
|
||||||
}
|
|
||||||
return err ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 写入缓冲的 sink */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char *out;
|
|
||||||
size_t cap;
|
|
||||||
size_t len;
|
|
||||||
} buf_sink;
|
|
||||||
|
|
||||||
static void buf_write(void *ctx, const char *b, size_t n) {
|
|
||||||
buf_sink *s = (buf_sink *)ctx;
|
|
||||||
/* 缓冲不足时,**绝不再往后写**,并标记溢出(len > cap 即可辨认)。
|
|
||||||
*
|
|
||||||
* ★ 契约是「不越界写」,不是「一个字节都不写」:本函数是流式的,
|
|
||||||
* 写到这里才知道放不下,之前已写出的部分无法撤销。
|
|
||||||
* 调用方拿到 (size_t)-1 时**必须丢弃整个结果**(Go 侧就是这么做的)。
|
|
||||||
* 若真需要 all-or-nothing,调用方应先测得长度再分配(两趟)。
|
|
||||||
* 这个取舍是有意的:单趟更快,而丢弃结果对调用方是廉价的。 */
|
|
||||||
if (s->len + n > s->cap) {
|
|
||||||
s->len = s->cap + 1; /* 标记溢出 */
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
memcpy(s->out + s->len, b, n);
|
|
||||||
s->len += n;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap) {
|
|
||||||
if (out == NULL || out_cap == 0) {
|
|
||||||
return (size_t)-1;
|
|
||||||
}
|
|
||||||
buf_sink s;
|
|
||||||
s.out = out;
|
|
||||||
s.cap = out_cap - 1; /* 留一位给结尾 NUL */
|
|
||||||
s.len = 0;
|
|
||||||
int err = 0;
|
|
||||||
(void)decode_body(raw, buf_write, &s, &err);
|
|
||||||
if (err || s.len > s.cap) {
|
|
||||||
return (size_t)-1;
|
|
||||||
}
|
|
||||||
out[s.len] = '\0';
|
|
||||||
return s.len;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 整数 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
int ha_json_get_int(ha_span raw, long long *out) {
|
|
||||||
if (raw.len == 0 || out == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
size_t i = 0;
|
|
||||||
int neg = 0;
|
|
||||||
if (raw.p[0] == '-') {
|
|
||||||
neg = 1;
|
|
||||||
i = 1;
|
|
||||||
if (raw.len == 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 只接受 **JSON 整数语法**:可选 '-' + (0 | [1-9][0-9]*)。
|
|
||||||
* 小数点 / 指数一律判「不是整数」,由调用方按 Go 的
|
|
||||||
* 「类型不匹配 ⇒ 整块作废」语义处理。
|
|
||||||
*
|
|
||||||
* ★ 必须禁前导零:JSON 里 `007` / `00` 是**非法数字**,
|
|
||||||
* 而 strconv.ParseInt 会接受它。若这里跟着接受,
|
|
||||||
* 就会出现「C 认得、json.Unmarshal 报错」的分叉 ——
|
|
||||||
* 黄金对照当场抓到(实测分歧:"007"、"00")。
|
|
||||||
* 本层的职责是回答「这是不是 JSON 整数」,不是「能不能转成数字」。 */
|
|
||||||
if (raw.p[i] == '0' && raw.len - i > 1) {
|
|
||||||
return 0; /* 前导零:00 / 01 / 007 均非法 */
|
|
||||||
}
|
|
||||||
for (size_t k = i; k < raw.len; k++) {
|
|
||||||
if (raw.p[k] < '0' || raw.p[k] > '9') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsigned long long acc = 0;
|
|
||||||
const unsigned long long limit =
|
|
||||||
neg ? 9223372036854775807ULL + 1ULL : 9223372036854775807ULL;
|
|
||||||
for (size_t k = i; k < raw.len; k++) {
|
|
||||||
unsigned d = (unsigned)(raw.p[k] - '0');
|
|
||||||
if (acc > (limit - d) / 10ULL) {
|
|
||||||
return 0; /* 溢出(与 Go 报错等价) */
|
|
||||||
}
|
|
||||||
acc = acc * 10ULL + d;
|
|
||||||
}
|
|
||||||
if (neg) {
|
|
||||||
*out = (acc == 9223372036854775808ULL)
|
|
||||||
? (-9223372036854775807LL - 1)
|
|
||||||
: -(long long)acc;
|
|
||||||
} else {
|
|
||||||
*out = (long long)acc;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 便捷取值 */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* 在对象里定位键 name 的值 span。
|
|
||||||
*
|
|
||||||
* 找到返回 1 且 *val 覆盖该值的原始字节(未解码);未找到 / 语法错返回 0。
|
|
||||||
* 重复键取**最后一次**(与 Go 的后者胜一致)。
|
|
||||||
*
|
|
||||||
* 实现要点:members 迭代器只报「值的起始位置」,值本身由本函数用
|
|
||||||
* ha_json_skip 消费并算出 span —— 这样两种便捷取值共用同一套定位逻辑,
|
|
||||||
* 不会因各自实现而分叉。 */
|
|
||||||
static int find_value(ha_span obj, const char *name, ha_span *val) {
|
|
||||||
ha_json_members m;
|
|
||||||
if (!ha_json_members_init(&m, obj.p, obj.len)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_span key;
|
|
||||||
ha_span v;
|
|
||||||
int found = 0;
|
|
||||||
ha_span last = { NULL, 0 };
|
|
||||||
|
|
||||||
while (ha_json_members_next(&m, &key, &v)) {
|
|
||||||
if (ha_json_key_eq(key, name)) {
|
|
||||||
last = v;
|
|
||||||
found = 1; /* 重复键后者胜:继续扫,只保留最后一次 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* ★ 严格性:畸形输入必须判「找不到键」——
|
|
||||||
* Go 侧语法错误会让 json.Unmarshal 失败、整块作废,
|
|
||||||
* 若这里放宽成「扫到哪算哪」,就会比 Go 宽松(见真值表 §2.2)。 */
|
|
||||||
if (!ha_json_members_complete(&m)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (found && val != NULL) {
|
|
||||||
*val = last;
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t ha_json_object_get_string(ha_span obj, const char *name,
|
|
||||||
char *out, size_t out_cap) {
|
|
||||||
if (out == NULL || out_cap == 0) {
|
|
||||||
return (size_t)-1;
|
|
||||||
}
|
|
||||||
ha_span val;
|
|
||||||
if (!find_value(obj, name, &val)) {
|
|
||||||
return (size_t)-1;
|
|
||||||
}
|
|
||||||
/* 只接受字符串值;其他类型视为「取不到」(类型判断由调用方按
|
|
||||||
* Go 的 interface{}/强类型语义决定,见 sse-codec-c.md §2.2) */
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, val.p, val.len);
|
|
||||||
ha_span raw;
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return (size_t)-1;
|
|
||||||
}
|
|
||||||
return ha_json_decode_string_into(raw, out, out_cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_object_get_int(ha_span obj, const char *name, long long *out) {
|
|
||||||
if (out == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_span val;
|
|
||||||
if (!find_value(obj, name, &val)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return ha_json_get_int(val, out);
|
|
||||||
}
|
|
||||||
@ -1,673 +0,0 @@
|
|||||||
/*
|
|
||||||
* ha_sse.c — LLM 流式协议(SSE 分块)结构导航辅助层
|
|
||||||
*
|
|
||||||
* 语义与理由见 include/ha_sse.h。本文件被 C 门禁全量覆盖
|
|
||||||
* (告警 / ASan+UBSan / arm64 交叉编译 / libFuzzer),故**不放**在
|
|
||||||
* Go 的 cgo 前言里 —— 前言里的 C 代码逃出全部检查。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ha_sse.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
int ha_sse_abi_version(void) {
|
|
||||||
return HA_SSE_ABI_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
int hit = 0;
|
|
||||||
|
|
||||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*dup = 0;
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (keylen == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
/* ★ 精确比较(不做大小写折叠):与 Go 的 map key 语义一致。
|
|
||||||
* §5.4-1 实测 {"TEXT":"up"} 取不到 text。 */
|
|
||||||
if (k.len == keylen && memcmp(k.p, key, keylen) == 0) {
|
|
||||||
if (hit) {
|
|
||||||
*dup = 1; /* 重复键:调用方整体回退 Go */
|
|
||||||
}
|
|
||||||
hit = 1;
|
|
||||||
*out = v; /* 后者胜 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ha_json_members_complete(&m)) {
|
|
||||||
return -1; /* 对象畸形 */
|
|
||||||
}
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单字节 ASCII 小写折叠(非 ASCII 原样,与 Go 对 ASCII 字段名的行为一致)。 */
|
|
||||||
static unsigned char sse_lower(unsigned char c) {
|
|
||||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
int hit = 0;
|
|
||||||
|
|
||||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*dup = 0;
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (keylen == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
if (k.len == keylen) {
|
|
||||||
size_t j = 0;
|
|
||||||
while (j < keylen &&
|
|
||||||
sse_lower((unsigned char)k.p[j]) ==
|
|
||||||
sse_lower((unsigned char)key[j])) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j == keylen) {
|
|
||||||
if (hit) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
hit = 1;
|
|
||||||
*out = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ha_json_members_complete(&m)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_root_object(const ha_span *doc) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
|
|
||||||
if (doc == NULL || doc->p == NULL || doc->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_json_scan_init(&sc, doc->p, doc->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '{') {
|
|
||||||
return 0; /* 顶层非对象:Go 的 Unmarshal 进 struct 会失败 */
|
|
||||||
}
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* 尾部只允许空白 —— 复刻 json.Unmarshal 对 trailing garbage 的拒绝 */
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
return ha_json_scan_eof(&sc) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
size_t start;
|
|
||||||
|
|
||||||
if (arr == NULL || out == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (arr->p == NULL || arr->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* ★ 游标的 base 始终是 arr->p,中途只推进 i。
|
|
||||||
*
|
|
||||||
* 初版在这里犯过一个「重新 init 到 sc.s + sc.i」的错:那样 base 变了,
|
|
||||||
* 随后的 start = sc.i 变成 0,out->p = arr->p + 0 ⇒ **返回的是数组本身**
|
|
||||||
* 而不是第一个元素。症状是上层的 fastChoice 拿到 firstByte=='[' 直接回退,
|
|
||||||
* 表现为「快速路径永远不生效」——
|
|
||||||
* 而如果只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是正确)。
|
|
||||||
*
|
|
||||||
* ★ 这正是「优化是否真的生效」必须单独断言的原因:
|
|
||||||
* 等价性测试无法发现「一直回退」。
|
|
||||||
*/
|
|
||||||
ha_json_scan_init(&sc, arr->p, arr->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
sc.i++; /* 跳过 '[' */
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
return 0; /* 空数组 */
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
out->p = arr->p + start;
|
|
||||||
out->len = sc.i - start;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
|
||||||
size_t len = 0;
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
|
|
||||||
if (val == NULL || out == NULL || outlen == NULL ||
|
|
||||||
val->p == NULL || val->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = 0;
|
|
||||||
/* 上界:每个输入字节最坏变 3 字节 U+FFFD。不足则交回 Go 走
|
|
||||||
* json.Unmarshal(宁可慢也不截断)。 */
|
|
||||||
if (cap < val->len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val->p[0] == '"') {
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
size_t n = ha_json_decode_string_into(raw, out, cap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val->p[0] == '[') {
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
sc.i++; /* 跳过 '[' */
|
|
||||||
for (;;) {
|
|
||||||
size_t start;
|
|
||||||
ha_span elem;
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
elem.p = val->p + start;
|
|
||||||
elem.len = sc.i - start;
|
|
||||||
|
|
||||||
/* 只有对象元素才可能有 text(§5.4-2:其余静默跳过) */
|
|
||||||
if (elem.len > 0 && elem.p[0] == '{') {
|
|
||||||
ha_span txt;
|
|
||||||
int dup = 0;
|
|
||||||
int rc = ha_sse_obj_find(&elem, "text", 4, &txt, &dup);
|
|
||||||
if (dup) {
|
|
||||||
return 0; /* 重复 text 键 ⇒ 交回 Go(合并语义) */
|
|
||||||
}
|
|
||||||
/* 只有字符串形态的 text 才取(§5.4-3) */
|
|
||||||
if (rc == 1 && txt.len > 0 && txt.p[0] == '"') {
|
|
||||||
ha_json_scan ts;
|
|
||||||
ha_span traw;
|
|
||||||
size_t n;
|
|
||||||
ha_json_scan_init(&ts, txt.p, txt.len);
|
|
||||||
if (!ha_json_scan_string(&ts, &traw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* cap-len 已保证至少 1 字节可用(含结尾 NUL) */
|
|
||||||
n = ha_json_decode_string_into(traw, out + len, cap - len);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
len += n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ',') {
|
|
||||||
sc.i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0; /* 畸形数组 */
|
|
||||||
}
|
|
||||||
*outlen = len;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 对象 / 数字 / true / false / null ⇒ 需 json.Marshal 重新编码(§5.2) */
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
size_t n;
|
|
||||||
|
|
||||||
if (val == NULL || out == NULL || outlen == NULL ||
|
|
||||||
val->p == NULL || val->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = 0;
|
|
||||||
if (val->p[0] != '"') {
|
|
||||||
return 0; /* 非字符串:交回 Go(需 json.Marshal 重新编码) */
|
|
||||||
}
|
|
||||||
if (cap < val->len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
n = ha_json_decode_string_into(raw, out, cap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================================================================== */
|
|
||||||
/* 批量定位:一次调用返回全部字段 */
|
|
||||||
/* ==================================================================== */
|
|
||||||
|
|
||||||
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count);
|
|
||||||
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out);
|
|
||||||
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup);
|
|
||||||
|
|
||||||
static void slot_reset(ha_chunk_slot *s) {
|
|
||||||
s->span.p = NULL;
|
|
||||||
s->span.len = 0;
|
|
||||||
s->kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void chunk_out_reset(ha_chunk_out *o) {
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; i < (size_t)HA_CHUNK_SLOT_COUNT; i++) {
|
|
||||||
slot_reset(&o->slot[i]);
|
|
||||||
}
|
|
||||||
o->content_off = 0; o->content_len = 0;
|
|
||||||
o->reasoning_off = 0; o->reasoning_len = 0;
|
|
||||||
o->finish_off = 0; o->finish_len = 0;
|
|
||||||
o->choices_span.p = NULL;
|
|
||||||
o->choices_span.len = 0;
|
|
||||||
o->has_choices = 0;
|
|
||||||
o->choices_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
o->choices_count = 0;
|
|
||||||
o->choice0_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 键名比较:大小写不敏感(struct 字段语义)。
|
|
||||||
* ★ 为什么不直接用 ha_json_key_eq:那个接收 ha_span,而这里要按
|
|
||||||
* 已知长度比较(省掉 strlen)—— 且必须与 Go 对 struct 字段的匹配一致。 */
|
|
||||||
static int ci_eq(const char *p, const char *name, size_t n) {
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; i < n; i++) {
|
|
||||||
if (sse_lower((unsigned char)p[i]) != sse_lower((unsigned char)name[i])) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int kind_of(const ha_span *v) {
|
|
||||||
if (v == NULL || v->p == NULL || v->len == 0) {
|
|
||||||
return HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
switch (v->p[0]) {
|
|
||||||
case '"': return HA_CHUNK_KIND_STRING;
|
|
||||||
case '{': return HA_CHUNK_KIND_OBJECT;
|
|
||||||
case '[': return HA_CHUNK_KIND_ARRAY;
|
|
||||||
case 'n': return HA_CHUNK_KIND_NULL;
|
|
||||||
default: return HA_CHUNK_KIND_OTHER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 把字符串值解码进 sbuf 的 [off,off+len)。返回 0 失败(空间不足/语法错)。 */
|
|
||||||
static int emit_decoded(ha_span val, char *sbuf, size_t scap, size_t *off,
|
|
||||||
size_t *outlen) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
size_t n;
|
|
||||||
|
|
||||||
*off = 0;
|
|
||||||
*outlen = 0;
|
|
||||||
ha_json_scan_init(&sc, val.p, val.len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* 上界检查:每个输入字节最坏变 3 字节 U+FFFD */
|
|
||||||
if (scap < raw.len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
n = ha_json_decode_string_into(raw, sbuf, scap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*off = 0;
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 顶层单趟分派:遍历成员表一次,按名字分派到对应槽位。
|
|
||||||
* 顶层键(choices/usage)是 **struct 字段** ⇒ 大小写不敏感。
|
|
||||||
* 返回 0 = 正常(即使有重复键,dup 由调用方检查);-1 = 畸形。
|
|
||||||
*/
|
|
||||||
static int chunk_top_dispatch(ha_span root, ha_chunk_out *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
ha_span el_tmp;
|
|
||||||
int r;
|
|
||||||
|
|
||||||
*dup = 0;
|
|
||||||
if (!ha_json_members_init(&m, root.p, root.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
int t = kind_of(&v);
|
|
||||||
if (k.len == 7 && ci_eq(k.p, "choices", 7)) {
|
|
||||||
if (out->choices_kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1; /* 重复键:字段级合并语义 ⇒ 交回 Go */
|
|
||||||
}
|
|
||||||
out->has_choices = (t != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
t != HA_CHUNK_KIND_NULL) ? 1 : 0;
|
|
||||||
out->choices_kind = t;
|
|
||||||
out->choices_span = v;
|
|
||||||
if (t == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
/* 一趟同时得出「首元素 span」与「元素个数」。
|
|
||||||
* ★ 初版为了拿个数先把整个数组扫一遍、再调 ha_sse_arr_first
|
|
||||||
* 重新扫第二遍 —— 而单趟成员遍历实测 107ns,两趟就是白扔 100ns+。
|
|
||||||
*/
|
|
||||||
if (scan_first_and_count(&v, &el_tmp, &out->choices_count) != 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (out->choices_count > 0) {
|
|
||||||
out->choice0_span = el_tmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 5 && ci_eq(k.p, "usage", 5)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_USAGE].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_USAGE].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_USAGE].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
/* 其余顶层键(id/object/created/model/system_fingerprint…)一律忽略。
|
|
||||||
* ★ Go 侧 struct 未声明 ⇒ 忽略;没有「类型不符」的可能。 */
|
|
||||||
}
|
|
||||||
r = ha_json_members_complete(&m) ? 0 : -1;
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 一趟取数组的首元素 span 与元素个数。
|
|
||||||
* 返回 0 成功;非 0 表示数组畸形。
|
|
||||||
* ★ 超过 2 个元素即停止计数并置 *count = 2(调用方一律回退),
|
|
||||||
* 这样超大数组不会白扫 —— 而 Go 侧那种输入压根不该走快速路径。 */
|
|
||||||
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
int n = 0;
|
|
||||||
first->p = NULL;
|
|
||||||
first->len = 0;
|
|
||||||
ha_json_scan_init(&sc, arr->p, arr->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
sc.i++;
|
|
||||||
for (;;) {
|
|
||||||
size_t start;
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (n == 0) {
|
|
||||||
first->p = arr->p + start;
|
|
||||||
first->len = sc.i - start;
|
|
||||||
}
|
|
||||||
n++;
|
|
||||||
if (n >= 2) {
|
|
||||||
/* 已知 >1:调用方必然回退,无需继续扫 */
|
|
||||||
*count = 2;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ',') {
|
|
||||||
sc.i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
*count = n;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* choice0 内的单趟分派:delta + finish_reason(struct 字段 ⇒ CI)。
|
|
||||||
* 返回 0 正常;非 0 = 畸形或重复键。 */
|
|
||||||
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out) {
|
|
||||||
ha_json_members cm;
|
|
||||||
ha_span ck, cv;
|
|
||||||
|
|
||||||
if (!ha_json_members_init(&cm, choice.p, choice.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&cm, &ck, &cv)) {
|
|
||||||
int t = kind_of(&cv);
|
|
||||||
if (ck.len == 5 && ci_eq(ck.p, "delta", 5)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_DELTA].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
return -1; /* 重复键 */
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_DELTA].span = cv;
|
|
||||||
out->slot[HA_CHUNK_SLOT_DELTA].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ck.len == 13 && ci_eq(ck.p, "finish_reason", 13)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_FINISH_REASON].span = cv;
|
|
||||||
out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ha_json_members_complete(&cm) ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* delta 内单趟分派。delta 是 **struct** ⇒ 字段名大小写不敏感。 */
|
|
||||||
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
|
|
||||||
*dup = 0;
|
|
||||||
if (!ha_json_members_init(&m, delta.p, delta.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
int t = kind_of(&v);
|
|
||||||
if (k.len == 7 && ci_eq(k.p, "content", 7)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_CONTENT].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_CONTENT].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_CONTENT].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 17 && ci_eq(k.p, "reasoning_content", 17)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_REASONING].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_REASONING].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_REASONING].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 10 && ci_eq(k.p, "tool_calls", 10)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ha_json_members_complete(&m) ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
|
|
||||||
char *sbuf, size_t scap, size_t *sused) {
|
|
||||||
ha_span root;
|
|
||||||
int dup = 0;
|
|
||||||
ha_span el, v;
|
|
||||||
|
|
||||||
if (out == NULL || sused == NULL) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
chunk_out_reset(out);
|
|
||||||
*sused = 0;
|
|
||||||
if (data == NULL || len == 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
root.p = data;
|
|
||||||
root.len = len;
|
|
||||||
|
|
||||||
/* 顶层必须是「恰好一个」良构对象(含尾部残留检查) */
|
|
||||||
if (!ha_sse_root_object(&root)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk_top_dispatch(root, out, &dup) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (dup) {
|
|
||||||
return HA_CHUNK_FALLBACK; /* §5.1 字段级合并 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- choices[0] ---- */
|
|
||||||
if (out->choices_kind == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
if (out->choices_count > 1) {
|
|
||||||
/* Go 侧会解析**全部**元素;本层只认 [0],其余元素可能类型不符
|
|
||||||
* 而让 Go 整块作废 ⇒ 无法保证等价,必须回退。 */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (out->choices_count == 0) {
|
|
||||||
out->choice0_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
} else {
|
|
||||||
if (ha_sse_arr_first(&out->choices_span, &el) != 1) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->choice0_kind = kind_of(&el);
|
|
||||||
if (out->choice0_kind != HA_CHUNK_KIND_OBJECT) {
|
|
||||||
/* Go 侧是 []struct:元素非对象 ⇒ 整块作废 */
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
/* 元素内的 delta / finish_reason(struct 字段 ⇒ CI),**一趟**取完。
|
|
||||||
* ★ 初版这里对 choices 数组做了「数个数 + 取首元素」两趟、
|
|
||||||
* 又在 choice0 内单独跑一趟 members —— 合计 3 趟。
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
if (chunk_choice_dispatch(el, out) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- delta 内分派 ---- */
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OBJECT) {
|
|
||||||
v = out->slot[HA_CHUNK_SLOT_DELTA].span;
|
|
||||||
if (chunk_delta_dispatch(v, out, &dup) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (dup) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
} else if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OTHER) {
|
|
||||||
/* delta 非对象:Go 侧 Unmarshal 到 struct 会失败 */
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- content:字符串直接解码;文本数组走 stringify ---- */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *cs = &out->slot[HA_CHUNK_SLOT_CONTENT];
|
|
||||||
if (cs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(cs->span, sbuf, scap, &out->content_off,
|
|
||||||
&out->content_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
*sused = out->content_len;
|
|
||||||
} else if (cs->kind == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
size_t n = 0;
|
|
||||||
if (!ha_sse_stringify(&cs->span, sbuf, scap, &n)) {
|
|
||||||
/* 需 json.Marshal 重新编码(§5.2)⇒ 交回 Go */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->content_off = 0;
|
|
||||||
out->content_len = n;
|
|
||||||
*sused = n;
|
|
||||||
} else if (cs->kind == HA_CHUNK_KIND_OBJECT ||
|
|
||||||
cs->kind == HA_CHUNK_KIND_OTHER) {
|
|
||||||
/* 对象/数字/布尔 ⇒ stringifyContent 走 json.Marshal(§5.2) */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
/* NULL / ABSENT ⇒ content=""(与 Go 的 stringifyContent(nil) 一致) */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- reasoning_content:Go 侧是 **string**(强类型) ----
|
|
||||||
* 若是 string 则解码;若是 null/absent ⇒ "";其它类型 ⇒ 整块作废。 */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *rs = &out->slot[HA_CHUNK_SLOT_REASONING];
|
|
||||||
if (rs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(rs->span, sbuf + *sused, scap - *sused,
|
|
||||||
&out->reasoning_off, &out->reasoning_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->reasoning_off += *sused;
|
|
||||||
*sused += out->reasoning_len;
|
|
||||||
} else if (rs->kind != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
rs->kind != HA_CHUNK_KIND_NULL) {
|
|
||||||
return HA_CHUNK_TYPE_FAIL; /* 与 Go 的 Unmarshal 失败一致 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- finish_reason:Go 侧是 *string ---- */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *fs = &out->slot[HA_CHUNK_SLOT_FINISH_REASON];
|
|
||||||
if (fs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(fs->span, sbuf + *sused, scap - *sused,
|
|
||||||
&out->finish_off, &out->finish_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->finish_off += *sused;
|
|
||||||
*sused += out->finish_len;
|
|
||||||
} else if (fs->kind != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
fs->kind != HA_CHUNK_KIND_NULL) {
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return HA_CHUNK_OK;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user